Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions sdk/typescript/src/api/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return new Promise((resolve) => {
setTimeout(resolve, intervalMs);
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Set retryable only for transient settlement failures

When /payments/settle returns a non-retryable client error after the Solana transfer has already confirmed, such as an expired authorization or invalid signature, this recovery object still advertises retryable: true for the same settlementRequest. Callers using the new recovery state can then keep retrying a request the backend will deterministically reject instead of moving directly to refund/regeneration logic; the flag should account for the thrown TinyPlaceError.status/body or avoid claiming retryability for all failures.

Useful? React with 👍 / 👎.

refundRequired: true,
};
}
return error;
}

function paymentMapToVerifyRequest(
payment: Record<string, string>,
): X402VerifyRequest {
Expand Down
3 changes: 3 additions & 0 deletions sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
121 changes: 121 additions & 0 deletions sdk/typescript/tests/payments.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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<Request> = [];
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<unknown>;
};
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<Request> = [];
Expand Down
Loading