diff --git a/sdk/typescript/src/api/payments.ts b/sdk/typescript/src/api/payments.ts index c7569804..de8df8a6 100644 --- a/sdk/typescript/src/api/payments.ts +++ b/sdk/typescript/src/api/payments.ts @@ -51,6 +51,26 @@ export interface SolanaSettlementResult { settlement: X402SettleResponse; } +export type SolanaSettlementRecoveryState = + "settlement_failed_after_execution"; + +export interface SolanaSettlementRecovery { + state: SolanaSettlementRecoveryState; + action: "retry_settlement_or_refund"; + onChainTx: string; + payment: X402VerifyRequest; + settlementRequest: X402SettleRequest; + retryable: true; + refundRequired: true; +} + +export interface SolanaSettlementFailure extends Error { + execution?: SolanaX402PaymentExecution; + settlementRecovery?: SolanaSettlementRecovery; + onChainTx?: string; + payment?: X402VerifyRequest; +} + function sleep(intervalMs: number): Promise { return new Promise((resolve) => { setTimeout(resolve, intervalMs); @@ -175,13 +195,21 @@ export class PaymentsApi { metadata, }, }); - const settlement = await this.settle({ - payment: paymentMapToVerifyRequest(execution.payment), + const payment = paymentMapToVerifyRequest(execution.payment); + const settlementRequest: X402SettleRequest = { + payment, settledAmount, feeQuoteId, reference, shielded, - }); + }; + + let settlement: X402SettleResponse; + try { + settlement = await this.settle(settlementRequest); + } catch (error) { + throw attachSolanaSettlementRecovery(error, execution, settlementRequest); + } return { execution, settlement }; } @@ -248,6 +276,29 @@ export class PaymentsApi { } } +function attachSolanaSettlementRecovery( + error: unknown, + execution: SolanaX402PaymentExecution, + settlementRequest: X402SettleRequest, +): unknown { + if (typeof error === "object" && error !== null) { + const failure = error as SolanaSettlementFailure; + failure.execution = execution; + failure.onChainTx = execution.signature; + failure.payment = settlementRequest.payment; + failure.settlementRecovery = { + state: "settlement_failed_after_execution", + action: "retry_settlement_or_refund", + onChainTx: execution.signature, + payment: settlementRequest.payment, + settlementRequest, + retryable: true, + refundRequired: true, + }; + } + return error; +} + function paymentMapToVerifyRequest( payment: Record, ): X402VerifyRequest { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index ee4059bf..1ff4b72e 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -109,7 +109,10 @@ export { DirectoryApi } from "./api/directory.js"; export { GroupsApi } from "./api/groups.js"; export { PaymentsApi } from "./api/payments.js"; export type { + SolanaSettlementFailure, SolanaSettlementOptions, + SolanaSettlementRecovery, + SolanaSettlementRecoveryState, SolanaSettlementResult, } from "./api/payments.js"; export { LedgerApi } from "./api/ledger.js"; diff --git a/sdk/typescript/tests/payments.test.ts b/sdk/typescript/tests/payments.test.ts index c647ab34..b92068af 100644 --- a/sdk/typescript/tests/payments.test.ts +++ b/sdk/typescript/tests/payments.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; import { LocalSigner, + SOLANA_NATIVE_ASSET, SOLANA_MAINNET_NETWORK, + TinyPlaceError, TinyPlaceClient, + type SolanaSettlementFailure, } from "../src/index.js"; describe("PaymentsApi", () => { @@ -231,6 +234,124 @@ describe("PaymentsApi", () => { ]); }); + it("preserves retry and refund recovery state when settlement fails after Solana execution", async () => { + const seed = new Uint8Array(32).fill(49); + const signer = await LocalSigner.fromSeed(seed); + const secretKey = new Uint8Array(64); + secretKey.set(seed, 0); + secretKey.set(signer.publicKey, 32); + const transaction = + "3WcpF4Mp6LXjYQjKX9N1yGrYtAg1N1wPiLPZidFqBtb4ppE9TFkHrGSybvcFJwzo66rucssBmB7yVbK6FFMX2ErW"; + const settleRequests: Array = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + if (request.url === "https://example.test/payments/settle") { + settleRequests.push(request); + return Response.json( + { error: "executor failed after payment settlement" }, + { status: 500 }, + ); + } + + const body = JSON.parse(String(init?.body)) as { + method: string; + params: Array; + }; + switch (body.method) { + case "getLatestBlockhash": + return Response.json({ + jsonrpc: "2.0", + id: body.method, + result: { value: { blockhash: "11111111111111111111111111111111" } }, + }); + case "sendTransaction": + return Response.json({ + jsonrpc: "2.0", + id: body.method, + result: transaction, + }); + case "getSignatureStatuses": + return Response.json({ + jsonrpc: "2.0", + id: body.method, + result: { + value: [{ confirmationStatus: "confirmed", err: null }], + }, + }); + default: + return Response.json( + { + jsonrpc: "2.0", + id: body.method, + error: { message: `unexpected method ${body.method}` }, + }, + { status: 500 }, + ); + } + }; + const client = new TinyPlaceClient({ + baseUrl: "https://example.test", + signer, + fetch, + }); + + try { + await client.payments.settleWithSolanaPayment({ + rpcUrl: "https://solana.example.test", + secretKey, + fetch, + network: SOLANA_MAINNET_NETWORK, + asset: SOLANA_NATIVE_ASSET, + amount: "1", + from: signer.agentId, + to: "F8zMkwbG3hp1k2t3eQWQh9bsh8qrK8CtqfZ2dBrrW3Ee", + nonce: "settle-failure-nonce", + expiresAt: "2026-06-13T10:00:00Z", + reference: { kind: "swap", operationId: "swap_123" }, + shielded: true, + }); + expect.fail("should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(TinyPlaceError); + const failure = error as TinyPlaceError & SolanaSettlementFailure; + expect(failure.status).toBe(500); + expect(failure.onChainTx).toBe(transaction); + expect(failure.execution?.signature).toBe(transaction); + expect(failure.settlementRecovery).toMatchObject({ + state: "settlement_failed_after_execution", + action: "retry_settlement_or_refund", + onChainTx: transaction, + retryable: true, + refundRequired: true, + settlementRequest: { + reference: { kind: "swap", operationId: "swap_123" }, + shielded: true, + }, + }); + expect(failure.settlementRecovery?.payment).toMatchObject({ + amount: "1", + asset: SOLANA_NATIVE_ASSET, + from: signer.agentId, + network: SOLANA_MAINNET_NETWORK, + to: "F8zMkwbG3hp1k2t3eQWQh9bsh8qrK8CtqfZ2dBrrW3Ee", + nonce: "settle-failure-nonce", + metadata: { + domain: "tiny.place", + onChainTx: transaction, + publicKey: signer.publicKeyBase64, + transaction, + tx: transaction, + }, + }); + } + + expect(settleRequests).toHaveLength(1); + await expect(settleRequests[0]!.json()).resolves.toMatchObject({ + reference: { kind: "swap", operationId: "swap_123" }, + shielded: true, + }); + }); + it("signs operator payment maintenance routes with admin authorization", async () => { const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(46)); const requests: Array = [];