Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1cd3f9e
fix(payments): refuse a too-precise amount before the payment row is …
multipletwigs Oct 21, 2026
905903f
test(payments): refuse too-precise amounts before rows are stored
multipletwigs Oct 21, 2026
00ee79f
fix(payments): refuse a subscription plan whose amount its mint canno…
multipletwigs Oct 21, 2026
f344fce
test(payments): refuse a too-precise subscription plan amount at create
multipletwigs Oct 21, 2026
5302967
fix(payments): let recovery settle a recurring update or resume befor…
multipletwigs Oct 21, 2026
f094d1e
test(payments): recover a recurring update or resume after its issued…
multipletwigs Oct 21, 2026
d760d25
fix(payments): ask resume token terms only before a new signature, an…
multipletwigs Oct 21, 2026
ccc1888
test(payments): refuse a re-signing resume or fresh update on a pause…
multipletwigs Oct 21, 2026
3abe0ae
fix(payments): check token admission before unfinished update sends
multipletwigs Oct 21, 2026
b201793
refactor(payments): centralize update send eligibility
multipletwigs Oct 21, 2026
9e15d0b
merge: carry strict mint validation into payment precision checks
multipletwigs Oct 21, 2026
9bbae33
fix(payments): settle payment requests against exact raw balances
multipletwigs Oct 21, 2026
d088eaf
merge: refresh exact payment amounts on current main
multipletwigs Oct 21, 2026
e30faf7
fix(payments): map invalid SOL batch amounts to bad requests
multipletwigs Oct 21, 2026
0181f57
fix(payments): isolate legacy mint failures and settle fresh token ac…
multipletwigs Oct 21, 2026
c33e4b2
Merge branch 'bashtwigs/ape-355-399-token-2022-ui-amounts' into basht…
multipletwigs Oct 21, 2026
a9391d0
Merge branch 'bashtwigs/ape-355-399-token-2022-ui-amounts' into basht…
multipletwigs Oct 22, 2026
177f0f3
fix(payments): settle lookup-table payments, read RPC from the cluste…
multipletwigs Oct 22, 2026
de37bef
test(api): settle a lookup-table SPL payment and follow the cluster-s…
multipletwigs Oct 22, 2026
785c863
fix(payments): keep RPC configuration errors loud on best-effort paym…
multipletwigs Oct 22, 2026
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
286 changes: 285 additions & 1 deletion apps/sdp-api/src/routes/payments.recurring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,27 @@ async function seedIssuedTokenMint(params: {
return tokenId;
}

/** An active schedule on a token issued in the project, and a way to pause that token. */
async function activateOnIssuedToken() {
const issuedMint = (await generateKeyPairSigner()).address;
const issuedTokenId = await seedIssuedTokenMint({
projectId: TEST_PROJECT.id,
mintAddress: issuedMint,
status: "active",
});
const activated = await activateRecurringPaymentFixture({
...DEFAULT_RECURRING_FIXTURE,
token: issuedMint,
});
const pauseToken = async () => {
await getDb(env)
.prepare("UPDATE issued_tokens SET status = 'paused' WHERE id = ?")
.bind(issuedTokenId)
.run();
};
return { activated, issuedMint, pauseToken };
}

function _expectPreparedSubscriptionTransaction(
preparedTransaction: {
serialized: string;
Expand Down Expand Up @@ -894,6 +915,103 @@ describe("Payments routes — recurring", () => {
expect(stableBody.data.recurringPayment.token).toBe(DEVNET_USDC_MINT);
});

it("refuses a USDC amount finer than its decimals and stores no recurring payment", async () => {
const counterpartyId = await seedCounterparty({ externalId: "recurring_precision_gate" });
const counterpartyAccountId = await seedCryptoWalletCounterpartyAccount({
counterpartyId,
address: TEST_SOLANA_ADDRESSES.wallet2,
});

const res = await app.request(
"/v1/payments/recurring-payments",
{
method: "POST",
headers: RECURRING_HEADERS,
body: JSON.stringify({
sourceCustodyWalletId: TEST_CUSTODY_WALLET_ID,
counterpartyId,
counterpartyAccountId,
token: "USDC",
amount: "25.0000001",
periodHours: 24,
}),
},
env
);

expect(res.status).toBe(400);
expect(errorResponseSchema.parse(await res.json()).error.code).toBe("BAD_REQUEST");
const stored = await getDb(env)
.prepare("SELECT COUNT(*)::integer AS count FROM payment_recurring_payments")
.first<{ count: number }>();
expect(stored).toEqual({ count: 0 });
});

it("refuses an update to a USDC amount finer than its decimals and keeps the stored amount", async () => {
const created = await createRecurringPaymentFixture(DEFAULT_RECURRING_FIXTURE);

const res = await app.request(
`/v1/payments/recurring-payments/${created.id}`,
{
method: "PATCH",
headers: RECURRING_HEADERS,
body: JSON.stringify({ amount: "35.0000001" }),
},
env
);

expect(res.status).toBe(400);
expect(errorResponseSchema.parse(await res.json()).error.code).toBe("BAD_REQUEST");
const stored = await getDb(env)
.prepare("SELECT amount FROM payment_recurring_payments WHERE id = ?")
.bind(created.id)
.first<{ amount: string }>();
expect(stored).toEqual({ amount: DEFAULT_RECURRING_FIXTURE.amount });
});

it("refuses a fresh update of an active schedule to a paused issued token, sending nothing", async () => {
const activated = await activateRecurringPaymentFixture(DEFAULT_RECURRING_FIXTURE);
const pausedMint = (await generateKeyPairSigner()).address;
await seedIssuedTokenMint({
projectId: TEST_PROJECT.id,
mintAddress: pausedMint,
status: "paused",
});
const sendsBefore = sendTransactionMock.mock.calls.length;

const res = await app.request(
`/v1/payments/recurring-payments/${activated.id}`,
{ method: "PATCH", headers: RECURRING_HEADERS, body: JSON.stringify({ token: pausedMint }) },
env
);

expect(res.status).toBe(400);
expect(errorResponseSchema.parse(await res.json()).error.message).toBe(
"Recurring payments support USD stablecoins and tokens issued in this project; native SOL is not supported"
);
expect((await recurringPaymentDueAt(activated.id)).status).toBe("active");
expect(sendTransactionMock).toHaveBeenCalledTimes(sendsBefore);
});

it("refuses a fresh update that keeps its issued token after the token pauses, sending nothing", async () => {
const { activated, pauseToken } = await activateOnIssuedToken();
await pauseToken();
const sendsBefore = sendTransactionMock.mock.calls.length;

const res = await app.request(
`/v1/payments/recurring-payments/${activated.id}`,
{ method: "PATCH", headers: RECURRING_HEADERS, body: JSON.stringify({ amount: "30.00" }) },
env
);

expect(res.status).toBe(400);
expect(errorResponseSchema.parse(await res.json()).error.message).toBe(
"Recurring payments support USD stablecoins and tokens issued in this project; native SOL is not supported"
);
expect((await recurringPaymentDueAt(activated.id)).status).toBe("active");
expect(sendTransactionMock).toHaveBeenCalledTimes(sendsBefore);
});

it("activates recurring payments through SDP API routes", async () => {
const signAsFeePayerMock = recurringExecution.signAsFeePayerMock();
const created = await createRecurringPaymentFixture({
Expand Down Expand Up @@ -4012,8 +4130,8 @@ describe("Payments routes — recurring", () => {
dueAt: new Date(now.getTime() - 60 * 1000).toISOString(),
});
}
const requestsBefore = recurringExecution.rpc.requests.length;

const requestsBefore = recurringExecution.rpc.requests.length;
await runInEachClusterScope(async () => {
await collectDueRecurringPayments(env, now);
});
Expand Down Expand Up @@ -6721,6 +6839,120 @@ describe("Payments routes — recurring", () => {
expect(attempt?.status).toBe("processing");
});

it.each([
{ metadataUri: "https://example.com/recurring/paused-before-send.json" },
{ amount: "35.00" },
])(
"refuses a claimed update before sending when its issued token pauses: %j",
async (update) => {
const { activated, issuedMint, pauseToken } = await activateOnIssuedToken();
const key = crypto.randomUUID();
const request = () =>
app.request(
actionPath(activated.id),
{
method: "PATCH",
headers: keyed(key),
body: JSON.stringify({ token: issuedMint, ...update }),
},
env
);
const sent = sends();
sent.mockRejectedValueOnce(new Error("process died before signing"));
expect((await request()).status).toBe(500);
await leaveUpdateCrashedAt(activated.id, key);
await getDb(env)
.prepare(
`UPDATE payment_recurring_payment_update_attempts SET stage = 'claim'
WHERE recurring_payment_id = ?`
)
.bind(activated.id)
.run();
await pauseToken();
sent.mockClear();

const retry = await request();

expect(retry.status).toBe(400);
expect(sent).not.toHaveBeenCalled();
}
);

it("settles a landed replacement plan but refuses new authorization when its token pauses", async () => {
const { activated, issuedMint, pauseToken } = await activateOnIssuedToken();
const key = crypto.randomUUID();
const request = () =>
app.request(
actionPath(activated.id),
{
method: "PATCH",
headers: keyed(key),
body: JSON.stringify({ token: issuedMint, amount: "35.00" }),
},
env
);
sendTransactionMock.mockRejectedValueOnce(new Error("process died after broadcast"));
expect((await request()).status).toBe(500);
await leaveUpdateCrashedAt(activated.id, key);
await pauseToken();
delegationsBesideActiveOld(required(activated.subscriptionPda), {
exists: false,
address: address(TEST_SOLANA_ADDRESSES.wallet2),
});
const sent = sends();

const retry = await request();

expect(retry.status).toBe(400);
expect(sent).not.toHaveBeenCalled();
expect(finalizedReads(vi.mocked(subscriptionsProgram.fetchMaybePlan).mock.calls)).toBe(1);
});

it("finishes a landed update naming its issued token after the token pauses, without sending it again", async () => {
const { activated, issuedMint, pauseToken } = await activateOnIssuedToken();
const key = crypto.randomUUID();
const metadataUri = "https://example.com/recurring/paused-token.json";
const request = () =>
app.request(
actionPath(activated.id),
{
method: "PATCH",
headers: keyed(key),
body: JSON.stringify({ token: issuedMint, metadataUri }),
},
env
);
sendTransactionMock.mockRejectedValueOnce(new Error("process died after broadcast"));
expect((await request()).status).toBe(500);
await leaveUpdateCrashedAt(activated.id, key);
await getDb(env)
.prepare(
`UPDATE payment_recurring_payment_update_attempts SET stage = 'update_plan'
WHERE recurring_payment_id = ?`
)
.bind(activated.id)
.run();
await pauseToken();
vi.mocked(subscriptionsProgram.fetchMaybePlan).mockResolvedValue(planAccount(metadataUri));
const sent = sends();

const retry = await request();

expect(retry.status).toBe(200);
expect((await parseRecurringResponse(retry)).data.recurringPayment).toMatchObject({
status: "active",
metadataUri,
});
expect(sent).not.toHaveBeenCalled();
const attempt = await getDb(env)
.prepare(
"SELECT status FROM payment_recurring_payment_update_attempts WHERE recurring_payment_id = ?"
)
.bind(activated.id)
.first<{ status: PaymentRecurringPaymentAttemptStatus }>();
expect(attempt).toEqual({ status: "confirmed" });
});

it("keeps a stale cancel in flight when only a lagging `confirmed` view shows it", async () => {
const activated = await activateRecurringPaymentFixture(DEFAULT_RECURRING_FIXTURE);
const key = crypto.randomUUID();
Expand Down Expand Up @@ -6810,6 +7042,58 @@ describe("Payments routes — recurring", () => {
]);
});

it("finishes a landed resume after its issued token pauses, without sending it again", async () => {
const { activated, pauseToken } = await activateOnIssuedToken();
const lifecycle = (action: "cancel" | "resume", key: string) =>
app.request(
actionPath(activated.id, action),
{ method: "POST", headers: keyed(key), body: "{}" },
env
);
expect((await lifecycle("cancel", crypto.randomUUID())).status).toBe(200);
const key = crypto.randomUUID();
expect((await lifecycle("resume", key)).status).toBe(200);
await leaveUnsavedSend({ recurringPaymentId: activated.id, operation: "resume", key });
await pauseToken();
fetchMaybeSubscriptionDelegationMock.mockResolvedValue(delegation(0n));
const sent = sends();

const retry = await lifecycle("resume", crypto.randomUUID());

expect(retry.status).toBe(200);
expect((await parseRecurringResponse(retry)).data.recurringPayment.status).toBe("active");
expect(sent).not.toHaveBeenCalled();
expect((await lifecycleAttempts(activated.id, "resume")).results).toEqual([
{ status: "confirmed", stage: "finalize", signature: null },
]);
});

it("refuses a resume that did not land once its issued token pauses, back to canceled and sending nothing", async () => {
const { activated, pauseToken } = await activateOnIssuedToken();
const lifecycle = (action: "cancel" | "resume", key: string) =>
app.request(
actionPath(activated.id, action),
{ method: "POST", headers: keyed(key), body: "{}" },
env
);
expect((await lifecycle("cancel", crypto.randomUUID())).status).toBe(200);
const key = crypto.randomUUID();
expect((await lifecycle("resume", key)).status).toBe(200);
await leaveUnsavedSend({ recurringPaymentId: activated.id, operation: "resume", key });
await pauseToken();
fetchMaybeSubscriptionDelegationMock.mockResolvedValue(delegation(1_800_000_000n));
const sent = sends();

const retry = await lifecycle("resume", crypto.randomUUID());

expect(retry.status).toBe(400);
expect(errorResponseSchema.parse(await retry.json()).error.message).toBe(
"Recurring payments support USD stablecoins and tokens issued in this project; native SOL is not supported"
);
expect(sent).not.toHaveBeenCalled();
expect((await recurringPaymentDueAt(activated.id)).status).toBe("canceled");
});

it("finishes an activation whose plan landed before its signature was saved, sending only the subscription", async () => {
const sourceSigner = recurringExecution.sourceSigner();
const created = await createRecurringPaymentFixture(DEFAULT_RECURRING_FIXTURE);
Expand Down
19 changes: 19 additions & 0 deletions apps/sdp-api/src/routes/payments.requests.idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
seedProductionPrivyWallet,
TEST_API_KEY,
TEST_PRODUCTION_PROJECT_ID,
TEST_PROJECT,
TEST_WALLET_ID,
} from "@/test/helpers/payments-routes";
import { required } from "@/test/helpers/required";
Expand Down Expand Up @@ -95,6 +96,24 @@ describe("Payments routes — payment request token cluster", () => {
.first<{ count: number }>();
expect(stored).toEqual({ count: 0 });
});

it.each(["1.0000001", "18446744073709.551616"])(
"refuses invalid USDC amount %s and stores no request",
async (amount) => {
const response = await postPaymentRequest(
{ walletId: TEST_WALLET_ID, token: DEVNET_USDC_MINT, amount },
"payreq-too-precise-amount"
);

expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ error: { code: "BAD_REQUEST" } });
const stored = await getDb(env)
.prepare("SELECT COUNT(*)::integer AS count FROM payment_requests WHERE project_id = ?")
.bind(TEST_PROJECT.id)
.first<{ count: number }>();
expect(stored).toEqual({ count: 0 });
}
);
});

describe("Payments routes — subscription plan wallets before the key", () => {
Expand Down
27 changes: 27 additions & 0 deletions apps/sdp-api/src/routes/payments.subscriptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,33 @@ describe("Payments routes — subscriptions", () => {
});
});

it("refuses a plan amount finer than USDC's decimals with 400 and stores no plan", async () => {
const planRes = await app.request(
"/v1/payments/subscription-plans",
{
method: "POST",
headers: SUBSCRIPTION_HEADERS,
body: JSON.stringify({
ownerWalletId: TEST_WALLET_ID,
token: DEVNET_USDC_MINT,
amount: "1.0000001",
periodHours: 720,
}),
},
env
);

expect(planRes.status).toBe(400);
expect(errorResponseSchema.parse(await planRes.json()).error.code).toBe("BAD_REQUEST");
const stored = await getDb(env)
.prepare(
"SELECT COUNT(*)::integer AS count FROM payment_subscription_plans WHERE project_id = ?"
)
.bind(TEST_PROJECT.id)
.first<{ count: number }>();
expect(stored).toEqual({ count: 0 });
});

// A plan is a record and an unsigned transaction the owner signs; SDP signs
// nothing, so a key's wallet scope does not limit it (HOO-2099).
it("lets a key that signs with no wallet archive a project plan", async () => {
Expand Down
Loading
Loading