diff --git a/apps/sdp-api/src/routes/pay.test.ts b/apps/sdp-api/src/routes/pay.test.ts index 62a66822e0..cda709fa88 100644 --- a/apps/sdp-api/src/routes/pay.test.ts +++ b/apps/sdp-api/src/routes/pay.test.ts @@ -132,6 +132,21 @@ describe("Public payment request routes", () => { }); } + it.each(["1.0000000001", "18446744073.709551616"])( + "returns 400 for a legacy invalid SOL pay-link amount %s", + async (amount) => { + const request = await createAwaitingPaymentRequest(SANDBOX_REQUEST_WALLET); + if (request === null) throw new Error("Expected a payment request fixture"); + await getDb(env) + .prepare("UPDATE payment_requests SET amount = ? WHERE id = ?") + .bind(amount, request.id) + .run(); + const response = await postTransaction(request.public_token); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "BAD_REQUEST" } }); + } + ); + function calledClusters() { return new Set(rpc.calls.map((call) => call.cluster)); } diff --git a/apps/sdp-api/src/routes/payments.recurring.test.ts b/apps/sdp-api/src/routes/payments.recurring.test.ts index 97822a4362..659ff31be3 100644 --- a/apps/sdp-api/src/routes/payments.recurring.test.ts +++ b/apps/sdp-api/src/routes/payments.recurring.test.ts @@ -35,7 +35,7 @@ import * as paymentSubscriptionsRepositoryPostgres from "@/db/repositories/payme import { createPostgresPaymentSubscriptionsRepository } from "@/db/repositories/payment-subscriptions.repository.postgres"; import * as paymentsRepositoryPostgres from "@/db/repositories/payments.repository.postgres"; import { app as baseApp } from "@/index"; -import { AppError, PUBLIC_INTERNAL_ERROR_MESSAGE } from "@/lib/errors"; +import { AppError, badRequest, PUBLIC_INTERNAL_ERROR_MESSAGE } from "@/lib/errors"; import { MoneyMovementRefusedError } from "@/lib/money-admission"; import * as organizationAccess from "@/lib/organization-access"; import { deriveIdempotencyRecordKey } from "@/middleware/idempotency"; @@ -277,6 +277,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; @@ -938,6 +959,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({ @@ -3189,6 +3307,12 @@ describe("Payments routes — recurring", () => { "3hdAMf5sGEHn2UAjViFvX9YtZQdRfeHEGwNEc8GjVKFG5MGNs27jVrNuQXHcr1JAkzjcJtS4Lo6z33Z5fbT2gq13" ); const activated = await activateRecurringPaymentFixture(DEFAULT_RECURRING_FIXTURE); + const mintAdmission = vi + .spyOn(paymentAmounts, "loadPaymentMint") + .mockRejectedValue( + badRequest("Payments do not support the InterestBearingConfig mint extension") + ); + onTestFinished(() => mintAdmission.mockRestore()); const dueAt = new Date(Date.now() - 60 * 1000).toISOString(); const now = new Date().toISOString(); const staleAt = new Date(Date.now() - 20 * 60 * 1000).toISOString(); @@ -3307,6 +3431,7 @@ describe("Payments routes — recurring", () => { status: "confirmed", signature: submittedSignature, }); + expect(mintAdmission).not.toHaveBeenCalled(); expect(sendTransactionMock).toHaveBeenCalledTimes(2); } ); @@ -4146,8 +4271,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); }); @@ -6883,6 +7008,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(); @@ -6972,6 +7211,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); diff --git a/apps/sdp-api/src/routes/payments.requests.idempotency.test.ts b/apps/sdp-api/src/routes/payments.requests.idempotency.test.ts index ca39cb4712..d97f04a6ed 100644 --- a/apps/sdp-api/src/routes/payments.requests.idempotency.test.ts +++ b/apps/sdp-api/src/routes/payments.requests.idempotency.test.ts @@ -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"; @@ -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", () => { diff --git a/apps/sdp-api/src/routes/payments.subscriptions.test.ts b/apps/sdp-api/src/routes/payments.subscriptions.test.ts index 4266c0d7e2..1ffe04613f 100644 --- a/apps/sdp-api/src/routes/payments.subscriptions.test.ts +++ b/apps/sdp-api/src/routes/payments.subscriptions.test.ts @@ -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 () => { diff --git a/apps/sdp-api/src/routes/payments.transfers.onchain.test.ts b/apps/sdp-api/src/routes/payments.transfers.onchain.test.ts index b0a4508742..6d1873ff6c 100644 --- a/apps/sdp-api/src/routes/payments.transfers.onchain.test.ts +++ b/apps/sdp-api/src/routes/payments.transfers.onchain.test.ts @@ -158,6 +158,25 @@ describe("Payments routes — on-chain transfers", () => { expect(await countTransferRows()).toBe(0); }); + it("refuses a USDC amount finer than its decimals before creating a transfer record", async () => { + const res = await postTransfer( + { + sourceCustodyWalletId: TEST_CUSTODY_WALLET_ID, + destination: TEST_SOLANA_ADDRESSES.wallet2, + token: DEVNET_USDC_MINT, + amount: "1.0000001", + }, + {} + ); + + expect(res.status).toBe(400); + const body = await readErrorResponse(res); + expect(body.error.code).toBe("BAD_REQUEST"); + expect(createOrgSignerForCustodyWalletMock).not.toHaveBeenCalled(); + expect(sendTransactionMock).not.toHaveBeenCalled(); + expect(await countTransferRows()).toBe(0); + }); + it("executes a SOL transfer and returns a confirmed transfer record", async () => { const res = await postTransfer( { diff --git a/apps/sdp-api/src/routes/payments/payment-requests/handlers.ts b/apps/sdp-api/src/routes/payments/payment-requests/handlers.ts index fad63bf389..bcf6d64bba 100644 --- a/apps/sdp-api/src/routes/payments/payment-requests/handlers.ts +++ b/apps/sdp-api/src/routes/payments/payment-requests/handlers.ts @@ -1,3 +1,4 @@ +import { assertValidAddress } from "@sdp/solana/address"; import type { CreatedPaymentRequest, ListPaymentRequestsResponse, @@ -19,6 +20,7 @@ import { isPaymentRequestExpired, reconcilePaymentRequest, } from "@/services/payments/payment-requests"; +import { parseStaticPaymentAmount } from "@/services/payments/token-amount"; import { type createPaymentRequestSchema, listPaymentRequestsQuerySchema, @@ -129,6 +131,8 @@ export async function createPaymentRequest( // wallet is operational is state, checked here. const scope = await resolveScope(c); const wallet = resolveWallet(scope.wallets, body.walletId); + const token = assertValidAddress(normalizePaymentToken(body.token), "token"); + await parseStaticPaymentAmount(body.amount, token); const row = await createPaymentRequestsRepository( c.env, @@ -139,7 +143,7 @@ export async function createPaymentRequest( custodyWalletId: wallet.id, walletId: wallet.walletId, destinationAddress: wallet.publicKey, - token: normalizePaymentToken(body.token), + token, amount: body.amount, expiresAt: body.expiresAt, createdBy: await resolveCreatorUserId(c), diff --git a/apps/sdp-api/src/routes/payments/subscription-plans/handlers.ts b/apps/sdp-api/src/routes/payments/subscription-plans/handlers.ts index 30036ea014..98a3920372 100644 --- a/apps/sdp-api/src/routes/payments/subscription-plans/handlers.ts +++ b/apps/sdp-api/src/routes/payments/subscription-plans/handlers.ts @@ -20,10 +20,12 @@ import { type AppContext, getPaymentSubscriptionsRepository } from "@/routes/pay import { resolveScope, resolveWallet } from "@/routes/payments/wallets"; import { normalizePaymentToken, parseU64String } from "@/services/payment-operation.service"; import { + assertSubscriptionTokenMint, buildPreparedSubscriptionTransaction, derivePlanAddresses, resolvePlanRuntime, } from "@/services/payments/recurring-payments/shared"; +import { parseStaticPaymentAmount } from "@/services/payments/token-amount"; import { resolveRequestSponsorshipScope, resolveSponsoredFeePayer, @@ -108,6 +110,8 @@ export const createSubscriptionPlan = async ( // The route's authorize step checked the credential's access to both wallets. const scope = await resolveScope(c); const ownerWallet = resolveWallet(scope.wallets, body.ownerWalletId); + const token = assertSubscriptionTokenMint(normalizePaymentToken(body.token)); + await parseStaticPaymentAmount(body.amount, token); const puller = await resolvePullerWalletAddress(c, body.pullerWalletId); const now = new Date().toISOString(); @@ -121,7 +125,7 @@ export const createSubscriptionPlan = async ( projectId, ownerWalletId: ownerWallet.walletId, ownerAddress: ownerWallet.publicKey, - token: normalizePaymentToken(body.token), + token, amount: body.amount, periodHours: body.periodHours, programPlanId: body.programPlanId ?? generateProgramPlanId(), diff --git a/apps/sdp-api/src/routes/payments/token-accounts.test.ts b/apps/sdp-api/src/routes/payments/token-accounts.test.ts index f9cb3d16db..64111432b5 100644 --- a/apps/sdp-api/src/routes/payments/token-accounts.test.ts +++ b/apps/sdp-api/src/routes/payments/token-accounts.test.ts @@ -1,4 +1,3 @@ -import { createClusterRpc } from "@sdp/rpc/factory"; import { runInClusterScope } from "@sdp/rpc/scope"; import type { SolanaRpc } from "@sdp/rpc/solana"; import { SPL_TOKEN_PROGRAMS, WELL_KNOWN_TOKENS } from "@sdp/types/well-known-tokens"; @@ -118,7 +117,7 @@ describe("buildSplTransferInstructions", () => { ); const result = runInClusterScope({ projectId: null, environment: "sandbox" }, () => - buildSplTransferInstructions(createClusterRpc(), { + buildSplTransferInstructions({ authority: createNoopSigner(syntheticAddress(13)), destination: syntheticAddress(14), mint, diff --git a/apps/sdp-api/src/routes/payments/token-accounts.ts b/apps/sdp-api/src/routes/payments/token-accounts.ts index b54e0844ab..5c8b9d8620 100644 --- a/apps/sdp-api/src/routes/payments/token-accounts.ts +++ b/apps/sdp-api/src/routes/payments/token-accounts.ts @@ -1,3 +1,4 @@ +import { createClusterRpc } from "@sdp/rpc/factory"; import { getAccountInfo, type SolanaRpc } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; import { SPL_TOKEN_PROGRAMS, WELL_KNOWN_TOKEN_BY_MINT } from "@sdp/types/well-known-tokens"; @@ -179,28 +180,12 @@ export async function getSplTokenAccountAddresses( return addresses; } -function assertSupportedTokenProgram(program: string): Address { - if (program === SPL_TOKEN_PROGRAM_ID || program === SPL_TOKEN_2022_PROGRAM_ID) { - return address(program); - } - throw badRequest("Unsupported token program for mint"); -} - -export async function resolveMintTokenProgram(rpc: SolanaRpc, mint: Address): Promise
{ - const mintAccountInfo = await getAccountInfo(rpc, mint); - if (!mintAccountInfo) { - throw badRequest("Token mint account does not exist"); - } - return assertSupportedTokenProgram(mintAccountInfo.owner); -} - export async function resolveSourceTokenAccount( - rpc: SolanaRpc, owner: Address, mint: Address, tokenProgram: Address ): Promise { - const selected = await findSourceTokenAccount(rpc, owner, mint, tokenProgram); + const selected = await findSourceTokenAccount(owner, mint, tokenProgram); if (!selected) { throw badRequest("Source wallet has no token account for this mint"); @@ -210,7 +195,6 @@ export async function resolveSourceTokenAccount( } export async function resolveSourceTokenAccountOrAta( - rpc: SolanaRpc, owner: Address, mint: Address, paymentMint: PaymentMint @@ -220,7 +204,7 @@ export async function resolveSourceTokenAccountOrAta( tokenProgram: paymentMint.tokenProgram, mint, }); - const tokenAccountInfo = await getAccountInfo(rpc, tokenAccount); + const tokenAccountInfo = await getAccountInfo(createClusterRpc(), tokenAccount); return { tokenAccount, @@ -234,23 +218,19 @@ export async function resolveSourceTokenAccountOrAta( * creation for the destination (rent paid by `ataRentPayer`) followed by a * transferChecked from the authority's largest token account. */ -export async function buildSplTransferInstructions( - rpc: SolanaRpc, - input: { - authority: TransactionSigner; - destination: Address; - mint: Address; - amount: string; - ataRentPayer: Address; - /** The source this request already read, so the build reads nothing again. */ - source?: SplTransferSource; - } -) { +export async function buildSplTransferInstructions(input: { + authority: TransactionSigner; + destination: Address; + mint: Address; + amount: string; + ataRentPayer: Address; + /** The source this request already read, so the build reads nothing again. */ + source?: SplTransferSource; +}) { let source = input.source; if (source === undefined) { const mint = await loadPaymentMint(input.mint); const account = await resolveSourceTokenAccount( - rpc, input.authority.address, input.mint, mint.tokenProgram @@ -293,12 +273,11 @@ export async function buildSplTransferInstructions( } async function findSourceTokenAccount( - rpc: SolanaRpc, owner: Address, mint: Address, tokenProgram: Address ): Promise { - const response = await rpc + const response = await createClusterRpc() .getTokenAccountsByOwner( owner, { programId: tokenProgram }, diff --git a/apps/sdp-api/src/routes/payments/transfer-batches/handlers.test.ts b/apps/sdp-api/src/routes/payments/transfer-batches/handlers.test.ts index 34e0a36986..6c499210cd 100644 --- a/apps/sdp-api/src/routes/payments/transfer-batches/handlers.test.ts +++ b/apps/sdp-api/src/routes/payments/transfer-batches/handlers.test.ts @@ -450,6 +450,53 @@ describe("payment transfer batches", () => { await clearKVStores(env); }); + it.each( + ["/v1/payments/transfer-batches", "/v1/payments/transfer-batches/estimate"].flatMap( + (endpoint) => [ + { endpoint, amount: "1.0000000001", message: "Amount has too many decimal places" }, + { endpoint, amount: "18446744073.709551616", message: "Amount is out of range" }, + ] + ) + )( + "rejects invalid SOL batch amount $amount at $endpoint with 400", + async ({ endpoint, amount, message }) => { + const counterpartyId = await seedCounterparty("batch_invalid_sol_amount"); + const counterpartyAccountId = await seedCryptoWalletCounterpartyAccount({ + counterpartyId, + walletAddress: TEST_SOLANA_ADDRESSES.wallet2, + }); + const response = await app.request( + endpoint, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${TEST_API_KEY.raw}`, + "Idempotency-Key": crypto.randomUUID(), + }, + body: JSON.stringify({ + sourceCustodyWalletId: TEST_CUSTODY_WALLET_ID, + token: "SOL", + recipients: [{ counterpartyId, counterpartyAccountId, amount }], + }), + }, + env + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "BAD_REQUEST", message } }); + expect(createOrgSignerForCustodyWalletMock).not.toHaveBeenCalled(); + expect(sendTransactionMock).not.toHaveBeenCalled(); + expect( + await getDb(env) + .prepare( + "SELECT COUNT(*)::int AS count FROM payment_transfer_batches WHERE organization_id = ? AND project_id = ?" + ) + .bind(TEST_ORG.id, TEST_PROJECT.id) + .first<{ count: number }>() + ).toEqual({ count: 0 }); + } + ); + it("estimates a SOL transfer batch", async () => { rpc.answer("getFeeForMessage", { context: { slot: 1 }, value: 5000 }); diff --git a/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts b/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts index a9e2806b27..2ac0eba27b 100644 --- a/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts +++ b/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts @@ -1,9 +1,9 @@ import { sumDecimalAmounts } from "@sdp/payments/decimal"; import { createClusterRpc } from "@sdp/rpc/factory"; import { assertValidAddress } from "@sdp/solana/address"; -import { AmountError, formatDecimalAmount, parseTokenAmount } from "@sdp/solana/amount"; +import { formatDecimalAmount } from "@sdp/solana/amount"; import { SOL_DECIMALS, SOL_MINT } from "@sdp/types/well-known-tokens"; -import type { Address } from "@solana/kit"; +import { type Address, address } from "@solana/kit"; import type { CounterpartyAccountRow } from "@/db/repositories/counterparty-account.repository"; import { requireProjectId } from "@/lib/auth"; import { badRequest, notFound } from "@/lib/errors"; @@ -26,7 +26,6 @@ import type { CreateTransferBatchInput, ResolvedBatchRequest, ResolvedRecipient, - Rpc, TokenContext, TransferBatchRecipientInput, } from "./types"; @@ -40,47 +39,35 @@ import type { * @returns The amount in token base units. */ function parseRecipientAmount(amount: string, toBaseUnits: PaymentAmountConverter): bigint { - try { - const amountBaseUnits = toBaseUnits(assertPositivePaymentAmount(amount)); - if (amountBaseUnits <= 0n) { - throw badRequest("Recipient amount must be greater than zero"); - } - return amountBaseUnits; - } catch (error) { - if (error instanceof AmountError) { - throw badRequest(error.message); - } - throw error; + const amountBaseUnits = toBaseUnits(assertPositivePaymentAmount(amount)); + if (amountBaseUnits <= 0n) { + throw badRequest("Recipient amount must be greater than zero"); } + return amountBaseUnits; } /** * Resolves the batch token into transfer parameters: native SOL, or an SPL * mint with its token program and the source wallet's token account. * - * @param rpc - Solana RPC client. * @param token - "SOL" or an SPL mint address. * @param sourceAddress - Source wallet address holding the token. * @returns The token context used to build transfer instructions. */ -async function resolveTokenContext( - rpc: Rpc, - token: string, - sourceAddress: Address -): Promise { +async function resolveTokenContext(token: string, sourceAddress: Address): Promise { if (isNativePaymentToken(token)) { + const { toBaseUnits } = await loadPaymentMint(address(SOL_MINT)); return { kind: "sol", token: SOL_MINT, decimals: SOL_DECIMALS, - toBaseUnits: (amount) => parseTokenAmount(amount, SOL_DECIMALS), + toBaseUnits, }; } const mintAddress = assertValidAddress(token, "token"); const { tokenProgram, decimals, toBaseUnits } = await loadPaymentMint(mintAddress); const sourceTokenAccount = await resolveSourceTokenAccount( - rpc, sourceAddress, mintAddress, tokenProgram @@ -218,7 +205,7 @@ export async function resolveBatchMovement( ): Promise { const token = normalizePaymentToken(input.token); const rpc = createClusterRpc(); - const tokenContext = await resolveTokenContext(rpc, token, authorized.sourceAddress); + const tokenContext = await resolveTokenContext(token, authorized.sourceAddress); const recipients = resolveRecipients({ recipients: input.recipients, accounts: authorized.accounts, diff --git a/apps/sdp-api/src/routes/payments/transfers/handlers.ts b/apps/sdp-api/src/routes/payments/transfers/handlers.ts index 7217a8dbd3..1ad1a54e59 100644 --- a/apps/sdp-api/src/routes/payments/transfers/handlers.ts +++ b/apps/sdp-api/src/routes/payments/transfers/handlers.ts @@ -21,6 +21,7 @@ import { type PaymentTransferType as TransferType, WALLET_TRANSFER_TYPES, } from "@sdp/types/payments"; +import { SOL_DECIMALS } from "@sdp/types/well-known-tokens"; import { type Address, address, @@ -103,7 +104,7 @@ import { resolveOutboundPaymentOperation, } from "@/services/payment-operation.service"; import { createTransferSignedSubmissionStore } from "@/services/payments/signed-submission"; -import { loadPaymentMint } from "@/services/payments/token-amount"; +import { loadPaymentMint, parseStaticPaymentAmount } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -256,12 +257,10 @@ async function assertRampDepositFunded( operation: OutboundPaymentOperation ): Promise { if (isNativePaymentToken(operation.token)) return undefined; - const rpc = createClusterRpc(); const mint = assertValidAddress(operation.token, "token"); const paymentMint = await loadPaymentMint(mint); const { tokenProgram, toBaseUnits } = paymentMint; const account = await resolveSourceTokenAccount( - rpc, address(operation.sourceWallet.publicKey), mint, tokenProgram @@ -733,7 +732,7 @@ async function executeSolTransfer( amount: string, submissionStore: SignedSubmissionStore ): Promise<{ signature: string; slot: number | null; blockTime: string | null }> { - const lamports = parseTokenAmount(amount, 9); + const lamports = parseTokenAmount(amount, SOL_DECIMALS); if (lamports <= 0n) { throw badRequest("Transfer amount must be greater than zero"); } @@ -762,9 +761,9 @@ async function executeSplTransfer( return executeSponsoredTransfer(c, { sourceWallet, submissionStore, - buildInstructions: async ({ signer, feePayer, rpc }) => { + buildInstructions: async ({ signer, feePayer }) => { const { createDestinationAtaInstruction, transferInstruction } = - await buildSplTransferInstructions(rpc, { + await buildSplTransferInstructions({ authority: signer, destination: destinationAddress, mint: mintAddress, @@ -870,6 +869,8 @@ function compareTransferRows( export async function createTransfer(c: ValidatedBodyContext) { const body = c.req.valid("json"); const { scope, operation, rampTransfer } = getAuthorized(c); + // Before the row: a too-precise amount is the caller's 400, never a failed transfer. + await parseStaticPaymentAmount(operation.amount, assertValidAddress(operation.token, "token")); // The credential-bound record key (ADR 0009), never the raw client key. const recordKey = getIdempotencyRecordKey(c); const fingerprint = getIdempotencyFingerprint(c); diff --git a/apps/sdp-api/src/services/payments/payment-request-sponsorship.ts b/apps/sdp-api/src/services/payments/payment-request-sponsorship.ts index 310bdefba7..c9f4c3cf27 100644 --- a/apps/sdp-api/src/services/payments/payment-request-sponsorship.ts +++ b/apps/sdp-api/src/services/payments/payment-request-sponsorship.ts @@ -1,13 +1,13 @@ import { hashString } from "@sdp/payments/hash"; import { createClusterRpc } from "@sdp/rpc/factory"; -import { getRecentBlockhash, type SolanaRpc } from "@sdp/rpc/solana"; +import { getRecentBlockhash } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; -import { parseTokenAmount } from "@sdp/solana/amount"; -import { SOL_DECIMALS, SOL_MINT } from "@sdp/types/well-known-tokens"; +import { SOL_MINT } from "@sdp/types/well-known-tokens"; import { type AccountMeta, AccountRole, type Address, + address, appendTransactionMessageInstructions, type Blockhash, compileTransaction, @@ -33,6 +33,7 @@ import { createSystemPaymentRequestsRepository } from "@/db/repositories/reposit import { badRequest, rateLimited } from "@/lib/errors"; import { buildSplTransferInstructions } from "@/routes/payments/token-accounts"; import { AuditService } from "@/services/audit.service"; +import { loadPaymentMint } from "@/services/payments/token-amount"; import { createSponsorshipFeePayment, resolveProjectSponsorshipScope, @@ -186,7 +187,7 @@ export async function issuePaymentRequestTransaction( throw badRequest("Payment payer cannot be the fee sponsor"); } const { blockhash, lastValidBlockHeight } = await getRecentBlockhash(rpc, "confirmed"); - const unsignedBase64 = await buildPaymentTransaction(rpc, { + const unsignedBase64 = await buildPaymentTransaction({ ...params, feePayer, blockhash, @@ -227,18 +228,15 @@ export async function issuePaymentRequestTransaction( * @returns The compiled, unsigned transaction as base64. * @throws AppError `BAD_REQUEST` when a SOL request's amount is not positive. */ -async function buildPaymentTransaction( - rpc: SolanaRpc, - params: { - request: PaymentRequestRow; - payer: Address; - recipient: Address; - reference: Address; - feePayer: Address; - blockhash: Blockhash; - lastValidBlockHeight: bigint; - } -): Promise { +async function buildPaymentTransaction(params: { + request: PaymentRequestRow; + payer: Address; + recipient: Address; + reference: Address; + feePayer: Address; + blockhash: Blockhash; + lastValidBlockHeight: bigint; +}): Promise { const { request, recipient, reference, feePayer, blockhash, lastValidBlockHeight } = params; const withReference = (instruction: Instruction & { accounts: readonly AccountMeta[] }) => ({ ...instruction, @@ -248,7 +246,7 @@ async function buildPaymentTransaction( let instructions: Instruction[]; if (request.token === SOL_MINT) { - const lamports = parseTokenAmount(request.amount, SOL_DECIMALS); + const lamports = (await loadPaymentMint(address(SOL_MINT))).toBaseUnits(request.amount); if (lamports <= 0n) { throw badRequest("Transfer amount must be greater than zero"); } @@ -260,7 +258,7 @@ async function buildPaymentTransaction( instructions = [withReference(transferInstruction)]; } else { const { createDestinationAtaInstruction, transferInstruction } = - await buildSplTransferInstructions(rpc, { + await buildSplTransferInstructions({ authority: payerSigner, destination: recipient, mint: assertValidAddress(request.token, "token"), diff --git a/apps/sdp-api/src/services/payments/payment-request-transfer.ts b/apps/sdp-api/src/services/payments/payment-request-transfer.ts new file mode 100644 index 0000000000..c4e8014b41 --- /dev/null +++ b/apps/sdp-api/src/services/payments/payment-request-transfer.ts @@ -0,0 +1,164 @@ +import { createClusterRpc } from "@sdp/rpc/factory"; +import { + type Address, + decodeTransactionFromRpcResponse, + getAccountMetasFromCompiledTransactionMessage, + getInstructionsFromCompiledTransactionMessage, + type Signature, + type TokenBalance, +} from "@solana/kit"; +import { ValidateTransferError } from "@solana/pay"; +import { + identifySystemInstruction, + parseTransferSolInstruction, + SYSTEM_PROGRAM_ADDRESS, + SystemInstruction, +} from "@solana-program/system"; +import { + findAssociatedTokenPda, + identifyTokenInstruction, + parseTransferCheckedInstruction, + parseTransferInstruction, + TokenInstruction, +} from "@solana-program/token"; + +interface PaymentRequestTransferFields { + recipient: Address; + amount: bigint; + decimals: number; + tokenProgram: Address; + reference: Address; + splToken?: Address; +} + +/** + * Validates the referenced transfer and its exact recipient balance increase in one read. Accounts + * resolve through the transaction's loaded lookup-table addresses, in the runtime's order, so a + * version-0 payment naming its recipient through a lookup table settles like a static one. + * + * @param signature - The transaction that carries the payment request's reference. + * @param fields - The request's recipient, exact base-unit amount, mint facts and reference. + * @returns The confirmed transaction response the transfer was validated against. + * @throws ValidateTransferError when the transaction fails, or its last instruction is not the + * expected transfer, or the recipient received less than the amount. + */ +export async function validatePaymentRequestTransfer( + signature: Signature, + fields: PaymentRequestTransferFields +) { + const response = await createClusterRpc() + .getTransaction(signature, { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + encoding: "base64", + }) + .send(); + if (response === null || response.meta === null || response.meta.err !== null) { + throw new ValidateTransferError("Transaction is missing or failed"); + } + const { compiledMessage, loadedAddresses } = decodeTransactionFromRpcResponse(response); + const candidate = getInstructionsFromCompiledTransactionMessage( + compiledMessage, + loadedAddresses + ).at(-1); + if (candidate === undefined || candidate.accounts === undefined || candidate.data === undefined) { + throw new ValidateTransferError("Missing transfer instruction"); + } + const instruction = { ...candidate, accounts: candidate.accounts, data: candidate.data }; + let recipientAccount: Address; + let requiredAccounts: number; + if (fields.splToken === undefined) { + if ( + instruction.programAddress !== SYSTEM_PROGRAM_ADDRESS || + identifySystemInstruction(instruction) !== SystemInstruction.TransferSol + ) { + throw new ValidateTransferError("Invalid SOL transfer"); + } + const parsed = parseTransferSolInstruction(instruction); + if (parsed.accounts.destination.address !== fields.recipient) + throw new ValidateTransferError("Wrong recipient"); + recipientAccount = fields.recipient; + requiredAccounts = Object.keys(parsed.accounts).length; + } else { + if (instruction.programAddress !== fields.tokenProgram) + throw new ValidateTransferError("Wrong token program"); + [recipientAccount] = await findAssociatedTokenPda({ + owner: fields.recipient, + mint: fields.splToken, + tokenProgram: fields.tokenProgram, + }); + switch (identifyTokenInstruction(instruction)) { + case TokenInstruction.TransferChecked: { + const parsed = parseTransferCheckedInstruction(instruction); + if ( + parsed.accounts.destination.address !== recipientAccount || + parsed.accounts.mint.address !== fields.splToken || + parsed.data.decimals !== fields.decimals + ) + throw new ValidateTransferError("Wrong token transfer"); + requiredAccounts = Object.keys(parsed.accounts).length; + break; + } + case TokenInstruction.Transfer: { + const parsed = parseTransferInstruction(instruction); + if (parsed.accounts.destination.address !== recipientAccount) + throw new ValidateTransferError("Wrong token recipient"); + requiredAccounts = Object.keys(parsed.accounts).length; + break; + } + default: + throw new ValidateTransferError("Invalid token transfer instruction"); + } + } + if ( + instruction.accounts.length !== requiredAccounts + 1 || + instruction.accounts[requiredAccounts].address !== fields.reference + ) { + throw new ValidateTransferError("Wrong payment reference"); + } + const accountIndex = getAccountMetasFromCompiledTransactionMessage( + compiledMessage, + loadedAddresses + ).findIndex((account) => account.address === recipientAccount); + if (accountIndex === -1) throw new ValidateTransferError("Missing recipient account"); + validateReceivedAmount(response.meta, accountIndex, fields); + return response; +} + +/** A new ATA contributes zero pre-balance, but only a matching post-balance proves receipt. */ +function validateReceivedAmount( + meta: { + preBalances: readonly bigint[]; + postBalances: readonly bigint[]; + preTokenBalances?: readonly TokenBalance[]; + postTokenBalances?: readonly TokenBalance[]; + }, + accountIndex: number, + fields: PaymentRequestTransferFields +): void { + let received: bigint; + if (fields.splToken === undefined) { + const pre = meta.preBalances[accountIndex]; + const post = meta.postBalances[accountIndex]; + if (pre === undefined || post === undefined) + throw new ValidateTransferError("Missing SOL balance data"); + received = post - pre; + } else { + const pre = meta.preTokenBalances?.find((balance) => balance.accountIndex === accountIndex); + const post = meta.postTokenBalances?.find((balance) => balance.accountIndex === accountIndex); + if ( + post === undefined || + post.mint !== fields.splToken || + post.uiTokenAmount.decimals !== fields.decimals || + (pre !== undefined && + (pre.mint !== fields.splToken || pre.uiTokenAmount.decimals !== fields.decimals)) + ) { + throw new ValidateTransferError("Missing or mismatched token balance data"); + } + // Newly created ATAs have no pre-token balance; the matching post entry is required. + received = + BigInt(post.uiTokenAmount.amount) - + (pre === undefined ? 0n : BigInt(pre.uiTokenAmount.amount)); + } + if (received < fields.amount) throw new ValidateTransferError("Amount not transferred"); +} diff --git a/apps/sdp-api/src/services/payments/payment-requests.test.ts b/apps/sdp-api/src/services/payments/payment-requests.test.ts index 7bfbfd5e7b..be1e0c0e66 100644 --- a/apps/sdp-api/src/services/payments/payment-requests.test.ts +++ b/apps/sdp-api/src/services/payments/payment-requests.test.ts @@ -1,21 +1,37 @@ import { runInClusterScope } from "@sdp/rpc/scope"; import * as verifiedConfirmation from "@sdp/rpc/verified-confirmation"; +import { formatDecimalAmount } from "@sdp/solana/amount"; import { SOL_MINT, WELL_KNOWN_TOKENS } from "@sdp/types/well-known-tokens"; import { + AccountRole, + type Address, address, + appendTransactionMessageInstructions, blockhash, compileTransaction, + compressTransactionMessageUsingAddressLookupTables, + createNoopSigner, createTransactionMessage, + decodeTransactionFromRpcResponse, getBase58Decoder, getBase64EncodedWireTransaction, + getCompiledTransactionMessageDecoder, lamports, pipe, setTransactionMessageFeePayer, setTransactionMessageLifetimeUsingBlockhash, signature, + stringifiedBigInt, + stringifiedNumber, } from "@solana/kit"; import * as solanaPay from "@solana/pay"; import { FindReferenceError, ValidateTransferError } from "@solana/pay"; +import { getTransferSolInstruction } from "@solana-program/system"; +import { + findAssociatedTokenPda, + getTransferCheckedInstruction, + TOKEN_PROGRAM_ADDRESS, +} from "@solana-program/token"; import { afterEach, beforeAll, @@ -32,8 +48,10 @@ import { createPaymentRequestsRepository, createPaymentsRepository, } from "@/db/repositories/repository-factory"; +import { badRequest } from "@/lib/errors"; import { createTenantScope } from "@/lib/tenant-scope"; import { rootLogger } from "@/runtime/logger"; +import * as paymentAmounts from "@/services/payments/token-amount"; import { TEST_CUSTODY_PUBLIC_KEY } from "@/test/fixtures/custody"; import { TEST_ORG, TEST_USER } from "@/test/fixtures/organizations"; import { TEST_SOLANA_ADDRESSES } from "@/test/fixtures/tokens"; @@ -41,8 +59,10 @@ import { installClusterRpcStub } from "@/test/helpers/cluster-rpc"; import { seedProjectCryptoWalletCounterparty } from "@/test/helpers/counterparties"; import { env } from "@/test/helpers/env"; import { BLANK_MANAGED_POOL_ENV } from "@/test/helpers/managed-pool-env"; +import { syntheticAddress } from "@/test/helpers/parsed-transactions"; import { seedDefaultProjects } from "@/test/helpers/projects"; import { seedTestDatabase } from "@/test/mocks/db"; +import * as paymentRequestTransfer from "./payment-request-transfer"; import { reconcilePaymentRequest } from "./payment-requests"; const TEST_PROJECT_ID = "prj_preq_handler_test"; @@ -52,8 +72,10 @@ const TEST_WALLET_ID = "wal_preq_handler_test"; const SIGNATURE = signature(getBase58Decoder().decode(new Uint8Array(64).fill(5))); const DEVNET_USDC_MINT = WELL_KNOWN_TOKENS.USDC.mints.devnet.address; +const validateExactTransfer = paymentRequestTransfer.validatePaymentRequestTransfer; + let findReferenceSpy: MockInstance; -let validateTransferSpy: MockInstance; +let validateTransferSpy: MockInstance; let verifyTransactionLandedSpy: MockInstance; function verifiedOk(): Awaited> { @@ -75,21 +97,78 @@ function foundSignature(): Awaited> { }; } -function validatedTransfer(): Awaited> { +async function validatedTransfer( + token = SOL_MINT, + rawAmount = token === SOL_MINT ? 1_500_000_000n : 1_500_000n, + reference?: Address, + lookupTable?: Address +): Promise>> { + const owner = address(TEST_CUSTODY_PUBLIC_KEY); + const source = createNoopSigner(syntheticAddress(332)); + const destination = + token === SOL_MINT + ? owner + : ( + await findAssociatedTokenPda({ + owner, + mint: address(token), + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }) + )[0]; + const instruction = + token === SOL_MINT + ? getTransferSolInstruction({ source, destination, amount: rawAmount }) + : getTransferCheckedInstruction({ + source: syntheticAddress(333), + mint: address(token), + destination, + authority: source, + amount: rawAmount, + decimals: 6, + }); + const referenced = + reference === undefined + ? instruction + : { + ...instruction, + accounts: [...instruction.accounts, { address: reference, role: AccountRole.READONLY }], + }; + const message = pipe( + createTransactionMessage({ version: 0 }), + (message) => setTransactionMessageFeePayer(source.address, message), + (message) => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: blockhash(getBase58Decoder().decode(new Uint8Array(32).fill(7))), + lastValidBlockHeight: 1n, + }, + message + ), + (message) => appendTransactionMessageInstructions([referenced], message) + ); const transaction = compileTransaction( - pipe( - createTransactionMessage({ version: 0 }), - (message) => setTransactionMessageFeePayer(address(TEST_CUSTODY_PUBLIC_KEY), message), - (message) => - setTransactionMessageLifetimeUsingBlockhash( - { - blockhash: blockhash(getBase58Decoder().decode(new Uint8Array(32).fill(7))), - lastValidBlockHeight: 1n, - }, - message - ) - ) + lookupTable === undefined + ? message + : compressTransactionMessageUsingAddressLookupTables(message, { + [lookupTable]: [destination], + }) ); + const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); + const loadedWritable = lookupTable === undefined ? [] : [destination]; + const accountKeys = [...compiled.staticAccounts, ...loadedWritable]; + const accountIndex = accountKeys.indexOf(destination); + const tokenBalance = (amount: bigint) => ({ + accountIndex, + mint: address(token), + owner, + programId: TOKEN_PROGRAM_ADDRESS, + uiTokenAmount: { + amount: stringifiedBigInt(amount.toString()), + decimals: 6, + uiAmount: null, + uiAmountString: stringifiedNumber(formatDecimalAmount(amount, 6)), + }, + }); return { slot: 1n, blockTime: null, @@ -98,23 +177,27 @@ function validatedTransfer(): Awaited lamports(0n)), + postBalances: accountKeys.map((account) => + lamports(token === SOL_MINT && account === destination ? rawAmount : 0n) + ), + preTokenBalances: token === SOL_MINT ? [] : [tokenBalance(0n)], + postTokenBalances: token === SOL_MINT ? [] : [tokenBalance(rawAmount)], rewards: [], status: { Ok: null }, - loadedAddresses: { readonly: [], writable: [] }, + loadedAddresses: { readonly: [], writable: loadedWritable }, }, transaction: [getBase64EncodedWireTransaction(transaction), "base64"], }; } -function mockSettlementSucceeds() { +async function mockSettlementSucceeds(token = SOL_MINT) { findReferenceSpy.mockResolvedValue(foundSignature()); verifyTransactionLandedSpy.mockResolvedValue(verifiedOk()); - validateTransferSpy.mockResolvedValue(validatedTransfer()); + validateTransferSpy.mockResolvedValue(await validatedTransfer(token)); } -async function createRequest(params: { token: string; expiresAt: string | null }) { +async function createRequest(params: { token: string; expiresAt: string | null; amount?: string }) { return createPaymentRequestsRepository( env, createTenantScope({ organizationId: TEST_ORG.id, projectId: TEST_PROJECT_ID }) @@ -125,7 +208,7 @@ async function createRequest(params: { token: string; expiresAt: string | null } walletId: TEST_WALLET_ID, destinationAddress: TEST_CUSTODY_PUBLIC_KEY, token: params.token, - amount: "1.5", + amount: params.amount ?? "1.5", expiresAt: params.expiresAt, createdBy: TEST_USER.id, }); @@ -146,7 +229,7 @@ async function listInboundTransfers() { } describe("reconcilePaymentRequest", () => { - installClusterRpcStub(); + const rpc = installClusterRpcStub(); beforeAll(async () => { await seedTestDatabase(env); @@ -158,7 +241,7 @@ describe("reconcilePaymentRequest", () => { beforeEach(async () => { findReferenceSpy = vi.spyOn(solanaPay, "findReference"); - validateTransferSpy = vi.spyOn(solanaPay, "validateTransfer"); + validateTransferSpy = vi.spyOn(paymentRequestTransfer, "validatePaymentRequestTransfer"); verifyTransactionLandedSpy = vi.spyOn(verifiedConfirmation, "verifyTransactionLanded"); const db = getDb(env); @@ -204,7 +287,7 @@ describe("reconcilePaymentRequest", () => { }); it("settles an awaiting request and links an inbound transfer attributed to no counterparty", async () => { - mockSettlementSucceeds(); + await mockSettlementSucceeds(); const request = await createRequest({ token: SOL_MINT, expiresAt: null }); await seedProjectCryptoWalletCounterparty(getDb(env), { organizationId: TEST_ORG.id, @@ -235,7 +318,7 @@ describe("reconcilePaymentRequest", () => { }); it("settles an SPL request, exercising the splToken branch", async () => { - mockSettlementSucceeds(); + await mockSettlementSucceeds(DEVNET_USDC_MINT); const settled = await reconcileOnSandbox( await createRequest({ token: DEVNET_USDC_MINT, expiresAt: null }), @@ -245,12 +328,177 @@ describe("reconcilePaymentRequest", () => { ); expect(settled.status).toBe("paid"); - const fields = validateTransferSpy.mock.calls[0][2]; + const fields = validateTransferSpy.mock.calls[0][1]; expect(fields.splToken).toBe(DEVNET_USDC_MINT); }); + it.each([ + { + label: "legacy excessive precision", + token: DEVNET_USDC_MINT, + amount: "1.0000001", + received: 1_000_000n, + status: "awaiting_payment", + }, + { + label: "exact SPL above 2^53", + token: DEVNET_USDC_MINT, + amount: "9007199254.740993", + received: 9007199254740993n, + status: "paid", + }, + { + label: "one raw unit short above 2^53", + token: DEVNET_USDC_MINT, + amount: "9007199254.740993", + received: 9007199254740992n, + status: "awaiting_payment", + }, + { + label: "one lamport short", + token: SOL_MINT, + amount: "1.5", + received: 1_499_999_999n, + status: "awaiting_payment", + }, + ])("checks $label with real transfer validation", async ({ token, amount, received, status }) => { + const request = await createRequest({ token, amount, expiresAt: null }); + findReferenceSpy.mockResolvedValue(foundSignature()); + verifyTransactionLandedSpy.mockResolvedValue(verifiedOk()); + validateTransferSpy.mockImplementation(validateExactTransfer); + const response = await validatedTransfer(token, received, address(request.reference)); + // JSON-RPC token amounts are strings; all numeric fixture metadata is small. + rpc.answer( + "getTransaction", + JSON.parse( + JSON.stringify(response, (_, value) => (typeof value === "bigint" ? Number(value) : value)) + ) + ); + const settled = await reconcileOnSandbox(request, { bestEffort: false }); + expect(settled.status).toBe(status); + expect(await listInboundTransfers()).toHaveLength(status === "paid" ? 1 : 0); + }); + + it.each([ + { received: 1_500_000n, status: "paid" }, + { received: 1_499_999n, status: "awaiting_payment" }, + ])( + "settles only sufficient payments into a fresh ATA: $received", + async ({ received, status }) => { + const request = await createRequest({ token: DEVNET_USDC_MINT, expiresAt: null }); + findReferenceSpy.mockResolvedValue(foundSignature()); + verifyTransactionLandedSpy.mockResolvedValue(verifiedOk()); + validateTransferSpy.mockImplementation(validateExactTransfer); + const response = await validatedTransfer( + DEVNET_USDC_MINT, + received, + address(request.reference) + ); + if (response.meta === null) throw new Error("Fixture requires metadata"); + const fresh = { ...response, meta: { ...response.meta, preTokenBalances: [] } }; + rpc.answer( + "getTransaction", + JSON.parse( + JSON.stringify(fresh, (_, value) => (typeof value === "bigint" ? Number(value) : value)) + ) + ); + expect((await reconcileOnSandbox(request, { bestEffort: false })).status).toBe(status); + expect(await listInboundTransfers()).toHaveLength(status === "paid" ? 1 : 0); + } + ); + + it.each(["wrong program", "wrong mint", "wrong decimals", "failed transaction"])( + "rejects a fresh ATA payment with %s", + async (fault) => { + const request = await createRequest({ token: DEVNET_USDC_MINT, expiresAt: null }); + findReferenceSpy.mockResolvedValue(foundSignature()); + verifyTransactionLandedSpy.mockResolvedValue(verifiedOk()); + validateTransferSpy.mockImplementation(validateExactTransfer); + const response = await validatedTransfer( + fault === "wrong program" ? SOL_MINT : DEVNET_USDC_MINT, + 1_500_000n, + address(request.reference) + ); + if (response.meta === null) throw new Error("Fixture requires metadata"); + const postTokenBalances = response.meta.postTokenBalances?.map((balance) => ({ + ...balance, + mint: fault === "wrong mint" ? syntheticAddress(502) : balance.mint, + uiTokenAmount: { + ...balance.uiTokenAmount, + decimals: fault === "wrong decimals" ? 5 : balance.uiTokenAmount.decimals, + }, + })); + const invalid = { + ...response, + meta: { + ...response.meta, + preTokenBalances: [], + postTokenBalances, + err: fault === "failed transaction" ? { InstructionError: [0, "InvalidArgument"] } : null, + }, + }; + rpc.answer( + "getTransaction", + JSON.parse( + JSON.stringify(invalid, (_, value) => (typeof value === "bigint" ? Number(value) : value)) + ) + ); + expect((await reconcileOnSandbox(request, { bestEffort: false })).status).toBe( + "awaiting_payment" + ); + expect(await listInboundTransfers()).toHaveLength(0); + } + ); + + it("settles an SPL payment whose recipient ATA loads from an address lookup table", async () => { + const request = await createRequest({ token: DEVNET_USDC_MINT, expiresAt: null }); + findReferenceSpy.mockResolvedValue(foundSignature()); + verifyTransactionLandedSpy.mockResolvedValue(verifiedOk()); + validateTransferSpy.mockImplementation(validateExactTransfer); + const response = await validatedTransfer( + DEVNET_USDC_MINT, + 1_500_000n, + address(request.reference), + syntheticAddress(335) + ); + const [recipientAta] = await findAssociatedTokenPda({ + owner: address(TEST_CUSTODY_PUBLIC_KEY), + mint: address(DEVNET_USDC_MINT), + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const { compiledMessage, loadedAddresses } = decodeTransactionFromRpcResponse(response); + expect(compiledMessage.staticAccounts).not.toContain(recipientAta); + expect(loadedAddresses).toEqual({ readonly: [], writable: [recipientAta] }); + rpc.answer( + "getTransaction", + JSON.parse( + JSON.stringify(response, (_, value) => (typeof value === "bigint" ? Number(value) : value)) + ) + ); + expect((await reconcileOnSandbox(request, { bestEffort: false })).status).toBe("paid"); + expect(await listInboundTransfers()).toHaveLength(1); + }); + + it("still rejects a transfer whose reference does not match the request", async () => { + const request = await createRequest({ token: SOL_MINT, expiresAt: null }); + findReferenceSpy.mockResolvedValue(foundSignature()); + verifyTransactionLandedSpy.mockResolvedValue(verifiedOk()); + validateTransferSpy.mockImplementation(validateExactTransfer); + const response = await validatedTransfer(SOL_MINT, 1_500_000_000n, syntheticAddress(334)); + rpc.answer( + "getTransaction", + JSON.parse( + JSON.stringify(response, (_, value) => (typeof value === "bigint" ? Number(value) : value)) + ) + ); + expect((await reconcileOnSandbox(request, { bestEffort: false })).status).toBe( + "awaiting_payment" + ); + expect(await listInboundTransfers()).toHaveLength(0); + }); + it("converges concurrent reconciles of the same request to a single settlement", async () => { - mockSettlementSucceeds(); + await mockSettlementSucceeds(); const request = await createRequest({ token: SOL_MINT, expiresAt: null }); const [a, b] = await Promise.all([ @@ -299,7 +547,7 @@ describe("reconcilePaymentRequest", () => { .prepare("UPDATE payment_transfers SET custody_wallet_id = NULL WHERE id = ?") .bind(transfer.id) .run(); - mockSettlementSucceeds(); + await mockSettlementSucceeds(); await expect(reconcileOnSandbox(request, { bestEffort: false })).rejects.toThrow( "does not match payment request" @@ -310,7 +558,7 @@ describe("reconcilePaymentRequest", () => { it("does not settle when the signature fails the independent confirmation check", async () => { findReferenceSpy.mockResolvedValue(foundSignature()); verifyTransactionLandedSpy.mockResolvedValue({ ok: false, reason: "not_confirmed" }); - validateTransferSpy.mockResolvedValue(validatedTransfer()); + validateTransferSpy.mockResolvedValue(await validatedTransfer()); const result = await reconcileOnSandbox( await createRequest({ token: SOL_MINT, expiresAt: null }), @@ -379,6 +627,32 @@ describe("reconcilePaymentRequest", () => { ).rejects.toThrow("rpc exploded"); }); + it.each([ + "Token mint account does not exist", + "Unsupported token program for mint", + "Payments do not support the InterestBearingConfig mint extension", + ])("isolates legacy mint rejection from other list rows: %s", async (message) => { + const warn = vi.spyOn(rootLogger, "warn").mockImplementation(() => {}); + const invalidMint = syntheticAddress(501); + const loadPaymentMint = paymentAmounts.loadPaymentMint; + vi.spyOn(paymentAmounts, "loadPaymentMint").mockImplementation((mint) => + mint === invalidMint ? Promise.reject(badRequest(message)) : loadPaymentMint(mint) + ); + const invalid = await createRequest({ token: invalidMint, expiresAt: null }); + const healthy = await createRequest({ token: SOL_MINT, expiresAt: null }); + await mockSettlementSucceeds(); + const [unchanged, settled] = await Promise.all( + [invalid, healthy].map((row) => reconcileOnSandbox(row, { bestEffort: true })) + ); + expect(unchanged).toEqual(invalid); + expect(settled.status).toBe("paid"); + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ payment_request_id: invalid.id, reason: "mint_not_admitted" }), + expect.any(String) + ); + expect(await listInboundTransfers()).toHaveLength(1); + }); + it("best-effort returns the stored row and logs when reconcile errors unexpectedly", async () => { const errorSpy = vi.spyOn(rootLogger, "error").mockImplementation(() => {}); findReferenceSpy.mockRejectedValue(new Error("rpc exploded")); diff --git a/apps/sdp-api/src/services/payments/payment-requests.ts b/apps/sdp-api/src/services/payments/payment-requests.ts index 705b1dc9ef..7e62c841d6 100644 --- a/apps/sdp-api/src/services/payments/payment-requests.ts +++ b/apps/sdp-api/src/services/payments/payment-requests.ts @@ -1,16 +1,11 @@ +import { SdpRpcError } from "@sdp/rpc/errors"; import { createClusterRpc } from "@sdp/rpc/factory"; -import type { SolanaRpc } from "@sdp/rpc/solana"; import { verifyTransactionLanded } from "@sdp/rpc/verified-confirmation"; import { assertValidAddress } from "@sdp/solana/address"; -import { toNumberAmount } from "@sdp/solana/amount"; +import { AmountError, parseTokenAmount } from "@sdp/solana/amount"; import { SOL_MINT } from "@sdp/types/well-known-tokens"; import type { Signature } from "@solana/kit"; -import { - FindReferenceError, - findReference, - ValidateTransferError, - validateTransfer, -} from "@solana/pay"; +import { FindReferenceError, findReference, ValidateTransferError } from "@solana/pay"; import { isPostgresUniqueViolation } from "@/db/postgres-utils"; import type { PaymentRequestRow } from "@/db/repositories/payment-requests.repository"; import { @@ -24,6 +19,8 @@ import { import { AppError, internalError, nullOnExpected } from "@/lib/errors"; import { createTenantScope } from "@/lib/tenant-scope"; import { getLogger } from "@/runtime/logger"; +import { validatePaymentRequestTransfer } from "@/services/payments/payment-request-transfer"; +import { loadPaymentMint, type PaymentMint } from "@/services/payments/token-amount"; import type { Env } from "@/types/env"; export function isPaymentRequestExpired(expiresAt: string | null): boolean { @@ -70,11 +67,15 @@ export async function reconcilePaymentRequest( throw new AppError("CONFLICT", "Payment request wallet identity is unresolved"); } - const rpc = createClusterRpc(); try { - return await settlePaymentRequestIfPaid(env, rpc, row, row.custody_wallet_id); + return await settlePaymentRequestIfPaid(env, row, row.custody_wallet_id); } catch (err) { - if (!options.bestEffort || err instanceof AppError) { + // Best effort degrades on RPC transport failures only; configuration and scope errors stay loud. + if ( + !options.bestEffort || + err instanceof AppError || + (err instanceof SdpRpcError && err.code !== "SOLANA_RPC_ERROR") + ) { throw err; } getLogger().error( @@ -98,7 +99,6 @@ export async function reconcilePaymentRequest( */ async function settlePaymentRequestIfPaid( env: Env, - rpc: SolanaRpc, row: PaymentRequestRow, custodyWalletId: string ): Promise { @@ -111,22 +111,55 @@ async function settlePaymentRequestIfPaid( const reference = assertValidAddress(row.reference, "reference"); const found = await nullOnExpected( - findReference(rpc, reference, { commitment: "confirmed" }), + findReference(createClusterRpc(), reference, { commitment: "confirmed" }), FindReferenceError ); if (found === null) { return row; } - const verified = await verifyTransactionLanded(rpc, found.signature as unknown as Signature); + const verified = await verifyTransactionLanded( + createClusterRpc(), + found.signature as unknown as Signature + ); if (!verified.ok) { return row; } + let mint: PaymentMint; + try { + mint = await loadPaymentMint(assertValidAddress(row.token, "token")); + } catch (error) { + if (!(error instanceof AppError) || error.code !== "BAD_REQUEST") throw error; + getLogger().warn( + { payment_request_id: row.id, reason: "mint_not_admitted", error: error.message }, + "Payment request settlement skipped" + ); + return row; + } + let expectedAmount: bigint; + try { + expectedAmount = parseTokenAmount(row.amount, mint.decimals); + } catch (error) { + // Legacy requests may predate create-time precision checks. They must + // never settle against a rounded or out-of-range amount. + if (error instanceof AmountError) { + getLogger().warn( + { payment_request_id: row.id, reason: "invalid_amount", error: error.message }, + "Payment request settlement skipped" + ); + return row; + } + throw error; + } + const recipient = assertValidAddress(row.destination_address, "destinationAddress"); + const validated = await nullOnExpected( - validateTransfer(rpc, found.signature, { - recipient: assertValidAddress(row.destination_address, "destinationAddress"), - amount: toNumberAmount(row.amount), + validatePaymentRequestTransfer(found.signature, { + recipient, + amount: expectedAmount, + decimals: mint.decimals, + tokenProgram: mint.tokenProgram, reference, ...(row.token === SOL_MINT ? {} : { splToken: assertValidAddress(row.token, "token") }), }), diff --git a/apps/sdp-api/src/services/payments/recurring-payments/activation.ts b/apps/sdp-api/src/services/payments/recurring-payments/activation.ts index 205095318b..52f4d81b4d 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/activation.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/activation.ts @@ -778,7 +778,7 @@ export async function activateRecurringPayment(input: { const mint = assertValidAddress(claimed.token, "token"); const paymentMint = await loadPaymentMint(mint); const { tokenProgram } = paymentMint; - const sourceTokenAccount = await resolveSourceTokenAccountOrAta(rpc, owner, mint, paymentMint); + const sourceTokenAccount = await resolveSourceTokenAccountOrAta(owner, mint, paymentMint); const amountBaseUnits = paymentMint.toBaseUnits(claimed.amount); if (amountBaseUnits <= 0n) { diff --git a/apps/sdp-api/src/services/payments/recurring-payments/authority-revocation.ts b/apps/sdp-api/src/services/payments/recurring-payments/authority-revocation.ts index 173e8cdc26..823230861b 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/authority-revocation.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/authority-revocation.ts @@ -17,10 +17,10 @@ import { type SubscriptionAuthorityRevocationsRepository, } from "@/db/repositories/subscription-authority-revocations.repository"; import { badRequest, conflict } from "@/lib/errors"; -import { resolveMintTokenProgram } from "@/routes/payments/token-accounts"; import { getLogger } from "@/runtime/logger"; import { CustodyRuntimeTargets } from "@/services/domain/signing/custody-runtime-target"; import { normalizePaymentToken } from "@/services/payment-operation.service"; +import { loadPaymentMintFacts } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { isDefiniteSubmissionError, @@ -345,7 +345,7 @@ async function sendRevoke( const instruction = await getRevokeSubscriptionAuthorityOverlayInstructionAsync({ user, tokenMint, - tokenProgram: await resolveMintTokenProgram(createClusterRpc(), tokenMint), + tokenProgram: (await loadPaymentMintFacts(tokenMint)).tokenProgram, // Valid whoever paid: the program requires the recorded payer when it is not the wallet. receiver: input.rentReceiver, }); diff --git a/apps/sdp-api/src/services/payments/recurring-payments/collection.ts b/apps/sdp-api/src/services/payments/recurring-payments/collection.ts index 78be45754f..e277ec77b8 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/collection.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/collection.ts @@ -71,15 +71,12 @@ import { } from "@/lib/errors"; import type { MoneyMovementRefusedError } from "@/lib/money-admission"; import { createTenantScope } from "@/lib/tenant-scope"; -import { - resolveMintTokenProgram, - resolveSourceTokenAccountOrAta, -} from "@/routes/payments/token-accounts"; +import { resolveSourceTokenAccountOrAta } from "@/routes/payments/token-accounts"; import { getLogger } from "@/runtime/logger"; import { logEvent } from "@/runtime/money-path-events"; import { createSigningService } from "@/services/domain/signing.service"; import type { TransferSignedSubmissionStore } from "@/services/payments/signed-submission"; -import { loadPaymentMint } from "@/services/payments/token-amount"; +import { loadPaymentMint, loadPaymentMintFacts } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -310,10 +307,9 @@ async function resolveDestinationTokenAccount(input: { destinationAddress: string; token: string; }): Promise
{ - const rpc = createClusterRpc(); const destinationOwner = assertValidAddress(input.destinationAddress, "destinationAddress"); const mint = assertValidAddress(input.token, "token"); - const tokenProgram = await resolveMintTokenProgram(rpc, mint); + const { tokenProgram } = await loadPaymentMintFacts(mint); const [receiverAta] = await findAssociatedTokenPda({ owner: destinationOwner, tokenProgram, @@ -493,7 +489,7 @@ async function verifyRecurringPaymentCollection(input: { const rpc = createClusterRpc(); const mint = assertValidAddress(input.recurringPayment.token, "token"); - const { tokenProgram, toBaseUnits } = await loadPaymentMint(mint); + const { tokenProgram, toBaseUnits } = await loadPaymentMintFacts(mint); const amountBaseUnits = toBaseUnits(input.recurringPayment.amount); const confirmedTransaction = await getTransaction(rpc, input.signature); if (!confirmedTransaction) { @@ -1748,12 +1744,7 @@ export async function collectRecurringPayment(input: { const paymentMint = await loadPaymentMint(mint); const { tokenProgram } = paymentMint; - const sourceTokenAccount = await resolveSourceTokenAccountOrAta( - rpc, - sourceOwner, - mint, - paymentMint - ); + const sourceTokenAccount = await resolveSourceTokenAccountOrAta(sourceOwner, mint, paymentMint); const amountBaseUnits = paymentMint.toBaseUnits(input.recurringPayment.amount); if (amountBaseUnits <= 0n) { throw badRequest("Subscription amount must be greater than zero"); diff --git a/apps/sdp-api/src/services/payments/recurring-payments/create.ts b/apps/sdp-api/src/services/payments/recurring-payments/create.ts index 6d7b338837..b6865bbbfa 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/create.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/create.ts @@ -6,7 +6,7 @@ import { createTenantScope } from "@/lib/tenant-scope"; import { resolveSolanaCounterpartyAccount } from "@/services/payments/counterparty-account-resolution"; import type { CustodyWallet } from "@/services/stores/custody-config.store"; import type { Env } from "@/types/env"; -import { assertRecurringPaymentTokenMint } from "./shared"; +import { assertRecurringPaymentTerms } from "./shared"; /** A create request's Idempotency-Key and the fingerprint of the schedule it creates. */ export interface RecurringPaymentIdempotency { @@ -31,7 +31,13 @@ export async function createRecurringPayment(input: { idempotency: RecurringPaymentIdempotency | null; }): Promise { const [tokenMint, destination] = await Promise.all([ - assertRecurringPaymentTokenMint(input.token, input.organizationId, input.projectId, input.env), + assertRecurringPaymentTerms({ + token: input.token, + amount: input.amount, + organizationId: input.organizationId, + projectId: input.projectId, + env: input.env, + }), resolveSolanaCounterpartyAccount({ env: input.env, organizationId: input.organizationId, diff --git a/apps/sdp-api/src/services/payments/recurring-payments/lifecycle.ts b/apps/sdp-api/src/services/payments/recurring-payments/lifecycle.ts index 291a2fb04d..bd7f5f61ff 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/lifecycle.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/lifecycle.ts @@ -41,7 +41,7 @@ import type { Env } from "@/types/env"; import { recoverOrBlockLifecycleCollection } from "./collection"; import { assertRecurringPaymentSourceWallet, - assertRecurringPaymentTokenMint, + assertRecurringPaymentTerms, confirmSubscriptionSignature, createSubscriptionStepStore, isSubscriptionDelegationCanceled, @@ -594,18 +594,7 @@ async function runRecurringPaymentLifecycle( if (!plan) { throw notFound("Subscription plan"); } - // Cancel is an exit and never asks the token admission check; only resume restarts collection. - const tokenMint = assertValidAddress( - input.operation === "resume" - ? await assertRecurringPaymentTokenMint( - plan.token, - input.organizationId, - input.projectId, - input.env - ) - : plan.token, - "tokenMint" - ); + const tokenMint = assertValidAddress(plan.token, "tokenMint"); const expectedSubscriptionStatus = input.operation === "cancel" ? "active" : "canceled"; const finalSubscriptionStatus = input.operation === "cancel" ? "canceled" : "active"; @@ -669,8 +658,19 @@ async function runRecurringPaymentLifecycle( // chain shows landed has already returned, so a refusal never strands an // operation that is on chain. A refused resume goes back to `canceled` // through the failure reset, which matches the subscription on chain. - if (recoveringStaleLifecycle && input.operation === "resume") { - await input.admitStart(); + // Token terms are asked at the same point for every resume; cancel is an + // exit and never asks them. + if (input.operation === "resume") { + await assertRecurringPaymentTerms({ + token: plan.token, + amount: claimed.amount, + organizationId: input.organizationId, + projectId: input.projectId, + env: input.env, + }); + if (recoveringStaleLifecycle) { + await input.admitStart(); + } } const sourceSigner = await createOrgSignerForCustodyWallet( input.env, diff --git a/apps/sdp-api/src/services/payments/recurring-payments/shared.ts b/apps/sdp-api/src/services/payments/recurring-payments/shared.ts index c1c3b80694..146e23b843 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/shared.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/shared.ts @@ -56,7 +56,7 @@ import { normalizePaymentToken, parseU64String, } from "@/services/payment-operation.service"; -import { loadPaymentMint, readStaticAmountMintDecimals } from "@/services/payments/token-amount"; +import { loadPaymentMint, parseStaticPaymentAmount } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -73,37 +73,74 @@ const RECURRING_PAYMENT_TOKEN_ERROR = "Recurring payments support USD stablecoins and tokens issued in this project; native SOL is not supported"; /** - * Resolves a recurring-payment token to its mint, allowing only well-known USD - * stablecoins on the open scope's cluster or active tokens issued in the project. + * Resolves a recurring-payment token to its mint, refusing native SOL. + * + * @param token - The requested token, a mint address or well-known symbol. + * @returns The token's mint. */ -export async function assertRecurringPaymentTokenMint( - token: string, - organizationId: string, - projectId: string, - env: Env -): Promise { +export function resolveRecurringPaymentTokenMint(token: string): Address { if (isNativePaymentToken(token)) { throw badRequest(RECURRING_PAYMENT_TOKEN_ERROR); } + return assertValidAddress(normalizePaymentToken(token), "token"); +} - const mint = assertValidAddress(normalizePaymentToken(token), "token"); - const wellKnown = WELL_KNOWN_TOKEN_BY_MINT.get(mint); +/** + * Admits a mint for new recurring work: a well-known USD stablecoin on the open + * scope's cluster or an active token issued in the project. It is start + * admission, so recovering work already sent does not ask it. + * + * @param params - The mint and the tenant it is admitted for. + * @param params.mint - The schedule's mint. + * @param params.organizationId - The schedule's organization. + * @param params.projectId - The schedule's project, where issued tokens are looked up. + * @param params.env - The API environment. + */ +export async function assertRecurringPaymentTokenAdmitted(params: { + mint: Address; + organizationId: string; + projectId: string; + env: Env; +}): Promise { + const wellKnown = WELL_KNOWN_TOKEN_BY_MINT.get(params.mint); if (wellKnown) { - if (wellKnown.isUsdStable) { - return mint; + if (!wellKnown.isUsdStable) { + throw badRequest(RECURRING_PAYMENT_TOKEN_ERROR); } - throw badRequest(RECURRING_PAYMENT_TOKEN_ERROR); + return; } - const issuedTokenStatus = await createTokenRepository( - env, - createTenantScope({ organizationId, projectId }) - ).getStatusByMint(projectId, mint); + params.env, + createTenantScope({ organizationId: params.organizationId, projectId: params.projectId }) + ).getStatusByMint(params.projectId, params.mint); if (issuedTokenStatus !== "active") { throw badRequest(RECURRING_PAYMENT_TOKEN_ERROR); } - await readStaticAmountMintDecimals(mint); +} +/** + * Admits a recurring payment's token and amount for new work: the token must be + * admitted (`assertRecurringPaymentTokenAdmitted`), and the amount must convert to + * a fixed raw amount at its mint's decimals. + * + * @param params - The terms and the tenant they belong to. + * @param params.token - The requested token, a mint address or well-known symbol. + * @param params.amount - The per-period UI amount. + * @param params.organizationId - The schedule's organization. + * @param params.projectId - The schedule's project, where issued tokens are looked up. + * @param params.env - The API environment. + * @returns The token's mint. + */ +export async function assertRecurringPaymentTerms(params: { + token: string; + amount: string; + organizationId: string; + projectId: string; + env: Env; +}): Promise
{ + const mint = resolveRecurringPaymentTokenMint(params.token); + await assertRecurringPaymentTokenAdmitted({ ...params, mint }); + await parseStaticPaymentAmount(params.amount, mint); return mint; } diff --git a/apps/sdp-api/src/services/payments/recurring-payments/update.ts b/apps/sdp-api/src/services/payments/recurring-payments/update.ts index 2bda8c0958..2aa37deba3 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/update.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/update.ts @@ -66,7 +66,7 @@ import { getLogger } from "@/runtime/logger"; import { createSigningService } from "@/services/domain/signing.service"; import { parseU64String } from "@/services/payment-operation.service"; import { resolveSolanaCounterpartyAccount } from "@/services/payments/counterparty-account-resolution"; -import { loadPaymentMint } from "@/services/payments/token-amount"; +import { loadPaymentMint, parseStaticPaymentAmount } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -77,7 +77,7 @@ import type { Env } from "@/types/env"; import { recoverOrBlockLifecycleCollection } from "./collection"; import { assertRecurringPaymentSourceWallet, - assertRecurringPaymentTokenMint, + assertRecurringPaymentTokenAdmitted, confirmSubscriptionSignature, createSubscriptionStepStore, isSubscriptionDelegationCanceled, @@ -91,6 +91,7 @@ import { requireUpdatedAttempt, requireUpdatedPlan, requireUpdatedSubscription, + resolveRecurringPaymentTokenMint, type SubscriptionStep, savedStepSend, sendSubscriptionInstructions, @@ -103,6 +104,19 @@ export interface RecurringPaymentUpdateIdempotency { fingerprint: string; } +async function sendRecurringPaymentUpdateInstructions( + input: Parameters[0] & { mint: Address } +): Promise { + const { mint, ...submission } = input; + await assertRecurringPaymentTokenAdmitted({ + env: submission.env, + organizationId: submission.organizationId, + projectId: submission.projectId, + mint, + }); + return sendSubscriptionInstructions(submission); +} + const recurringPaymentUpdateSnapshotSchema = z.object({ sourceCustodyWalletId: z.string().nullable(), counterpartyId: z.string(), @@ -142,7 +156,7 @@ type ResolvedRecurringPaymentUpdate = { counterpartyId: string; counterpartyAccountId: string; destinationAddress: string; - token: string; + token: Address; amount: string; periodHours: number; firstCollectionAt: string | null; @@ -303,6 +317,7 @@ async function resolveRecurringPaymentUpdate(input: { sourceWallet: CustodyWallet; nextSourceWallet?: CustodyWallet; request: UpdatePaymentRecurringPaymentRequest; + recoveringStaleUpdate: boolean; }): Promise { const finalSourceWallet = input.nextSourceWallet === undefined ? input.sourceWallet : input.nextSourceWallet; @@ -325,15 +340,14 @@ async function resolveRecurringPaymentUpdate(input: { const accountChanged = counterpartyId !== input.recurringPayment.counterparty_id || counterpartyAccountId !== input.recurringPayment.counterparty_account_id; - const [token, destination] = await Promise.all([ - input.request.token !== undefined - ? assertRecurringPaymentTokenMint( - input.request.token, - input.organizationId, - input.projectId, - input.env - ) - : input.recurringPayment.token, + const amount = + input.request.amount === undefined ? input.recurringPayment.amount : input.request.amount; + const token = resolveRecurringPaymentTokenMint( + input.request.token === undefined ? input.recurringPayment.token : input.request.token + ); + // Recovery settles saved work first; each remaining new send checks token admission below. + const admitsToken = !input.recoveringStaleUpdate; + const [destination] = await Promise.all([ accountChanged ? resolveSolanaCounterpartyAccount({ env: input.env, @@ -343,9 +357,16 @@ async function resolveRecurringPaymentUpdate(input: { counterpartyAccountId, }) : { destinationAddress: input.recurringPayment.destination_address }, + admitsToken + ? assertRecurringPaymentTokenAdmitted({ + mint: token, + organizationId: input.organizationId, + projectId: input.projectId, + env: input.env, + }) + : null, + parseStaticPaymentAmount(amount, token), ]); - const amount = - input.request.amount === undefined ? input.recurringPayment.amount : input.request.amount; const periodHours = input.request.periodHours === undefined ? input.recurringPayment.period_hours @@ -978,7 +999,8 @@ async function runMetadataScheduleUpdate(input: { pullers: onChainPlan.data.data.pullers, status: parsePlanStatus(onChainPlan.data.status), }); - planUpdateSignature = await sendSubscriptionInstructions({ + planUpdateSignature = await sendRecurringPaymentUpdateInstructions({ + mint: input.resolved.token, movement: "recurring.update", env: input.env, organizationId: input.organizationId, @@ -1065,7 +1087,8 @@ async function prepareSubscriptionAuthorityForUpdate(input: { mint: input.mint, tokenProgram: input.tokenProgram, }); - const initSignature = await sendSubscriptionInstructions({ + const initSignature = await sendRecurringPaymentUpdateInstructions({ + mint: input.mint, movement: "recurring.update", env: input.env, organizationId: input.organizationId, @@ -1457,10 +1480,10 @@ async function runReplacementUpdate(input: { } const owner = assertValidAddress(input.resolved.sourceWallet.publicKey, "sourceAddress"); const destination = assertValidAddress(input.resolved.destinationAddress, "destinationAddress"); - const mint = assertValidAddress(input.resolved.token, "token"); + const mint = input.resolved.token; const paymentMint = await loadPaymentMint(mint); const { tokenProgram } = paymentMint; - const sourceTokenAccount = await resolveSourceTokenAccountOrAta(rpc, owner, mint, paymentMint); + const sourceTokenAccount = await resolveSourceTokenAccountOrAta(owner, mint, paymentMint); const amountBaseUnits = paymentMint.toBaseUnits(input.resolved.amount); if (amountBaseUnits <= 0n) { throw badRequest("Subscription amount must be greater than zero"); @@ -1540,7 +1563,8 @@ async function runReplacementUpdate(input: { pullers: [owner], tokenProgram, }); - planCreationSignature = await sendSubscriptionInstructions({ + planCreationSignature = await sendRecurringPaymentUpdateInstructions({ + mint, movement: "recurring.update", env: input.env, organizationId: input.organizationId, @@ -1699,7 +1723,8 @@ async function runReplacementUpdate(input: { subscriber: sourceSigner, tokenMint: mint, }); - authorizationSignature = await sendSubscriptionInstructions({ + authorizationSignature = await sendRecurringPaymentUpdateInstructions({ + mint, movement: "recurring.update", env: input.env, organizationId: input.organizationId, @@ -2109,7 +2134,10 @@ export async function updateRecurringPayment(input: { if (input.request.nextCollectionDueAt !== undefined) { throw badRequest("nextCollectionDueAt can only be updated after activation"); } - const resolved = await resolveRecurringPaymentUpdate(input); + const resolved = await resolveRecurringPaymentUpdate({ + ...input, + recoveringStaleUpdate: false, + }); return updatePendingRecurringPayment({ env: input.env, organizationId: input.organizationId, @@ -2146,15 +2174,16 @@ export async function updateRecurringPayment(input: { assertRecurringPaymentUpdateStatus(settled.recurringPayment, new Date().toISOString()); assertRecurringPaymentSourceWallet(settled.recurringPayment, input.sourceWallet); + const recoveringStaleUpdate = isUpdatingRecurringPaymentStatus(settled.recurringPayment.status); const resolved = await resolveRecurringPaymentUpdate({ ...input, recurringPayment: settled.recurringPayment, + recoveringStaleUpdate, }); const sourceChanged = resolved.changedFields.includes("sourceCustodyWalletId"); const mode = requestedActiveUpdateMode(resolved.changedFields); const requiresRuntimeExecution = mode === "replacement" || resolved.metadataUri !== settled.recurringPayment.metadata_uri; - const recoveringStaleUpdate = isUpdatingRecurringPaymentStatus(settled.recurringPayment.status); if (requiresRuntimeExecution) { await admitUpdateExecution({ env: input.env, diff --git a/apps/sdp-api/src/services/payments/token-amount.test.ts b/apps/sdp-api/src/services/payments/token-amount.test.ts index e5aead86a2..48b4e4934d 100644 --- a/apps/sdp-api/src/services/payments/token-amount.test.ts +++ b/apps/sdp-api/src/services/payments/token-amount.test.ts @@ -14,7 +14,12 @@ import { type StubMint, scaledUiAmountConfig, } from "@/test/helpers/token-mints"; -import { loadPaymentAmountConverter, readStaticAmountMintDecimals } from "./token-amount"; +import { + loadPaymentAmountConverter, + loadPaymentMint, + loadPaymentMintFacts, + parseStaticPaymentAmount, +} from "./token-amount"; const MINT = syntheticAddress(1); const CLOCK_UNIX_TIMESTAMP = 1_750_000_000n; @@ -96,6 +101,17 @@ describe("loadPaymentAmountConverter", () => { expect(rpc.requests).toEqual([]); }); + it.each([WELL_KNOWN_TOKENS.USDG, WELL_KNOWN_TOKENS.PYUSD])( + "keeps $symbol supported while fee handling is deferred", + async (token) => { + const mint = token.mints.devnet; + if (mint === undefined) throw new Error("Fixture requires a devnet mint"); + const convert = await inSandboxScope(() => loadPaymentAmountConverter(address(mint.address))); + expect(convert("1.5")).toBe(1500000n); + expect(rpc.requests).toEqual([]); + } + ); + it("reads a well-known mint the scope's cluster does not declare from the chain", async () => { const usdsMainnetMint = address(WELL_KNOWN_TOKENS.USDS.mints["mainnet-beta"].address); respondMintAccounts(rpc, { @@ -125,6 +141,18 @@ describe("loadPaymentAmountConverter", () => { } ); + it.each([SCALED_BY_TWO_UNTIL_LATER, INTEREST_BEARING_CONFIG])( + "reads facts for recovery without admitting a new payment ($__kind)", + async (extension) => { + serveMint({ decimals: 6, extensions: [extension] }); + await inSandboxScope(async () => { + expect((await loadPaymentMintFacts(MINT)).toBaseUnits("1.5")).toBe(1500000n); + await expect(loadPaymentMint(MINT)).rejects.toMatchObject({ statusCode: 400 }); + }); + expect(accountInfoReads()).toEqual([MINT]); + } + ); + it("keeps raw amounts above 2^53 exact and accepts the full u64 range", async () => { serveMint({ decimals: 6, extensions: null }); const convert = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); @@ -175,42 +203,38 @@ describe("loadPaymentAmountConverter", () => { } ); - it("answers 400 for a mint with no account", async () => { - respondMintAccounts(rpc, { mints: new Map(), clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP }); - - await expect(inSandboxScope(() => loadPaymentAmountConverter(MINT))).rejects.toMatchObject({ - code: "BAD_REQUEST", - statusCode: 400, - message: "Token mint account does not exist", + it("shares the mint read between concurrent admission and execution in one scope", async () => { + serveMint({ decimals: 6, extensions: null }); + await inSandboxScope(async () => { + const [parsed, mint] = await Promise.all([ + parseStaticPaymentAmount("1.5", MINT), + loadPaymentMint(MINT), + ]); + expect(parsed).toBe(1500000n); + expect(mint.toBaseUnits("1.5")).toBe(1500000n); }); + expect(accountInfoReads()).toEqual([MINT]); }); -}); - -describe("readStaticAmountMintDecimals", () => { - const rpc = installClusterRpcResponder(); - function serveMint(mint: StubMint): void { - respondMintAccounts(rpc, { - mints: new Map([[MINT, mint]]), - clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP, + it("does not remember failed lookups or reuse facts across scopes", async () => { + respondMintAccounts(rpc, { mints: new Map(), clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP }); + await inSandboxScope(async () => { + await expect(loadPaymentMint(MINT)).rejects.toMatchObject({ statusCode: 400 }); + serveMint({ decimals: 6, extensions: null }); + expect((await loadPaymentMint(MINT)).decimals).toBe(6); }); - } - - it("returns a plain mint's decimals", async () => { serveMint({ decimals: 9, extensions: null }); - - await expect(inSandboxScope(() => readStaticAmountMintDecimals(MINT))).resolves.toBe(9); + expect((await inSandboxScope(() => loadPaymentMint(MINT))).decimals).toBe(9); + expect(accountInfoReads()).toEqual([MINT, MINT, MINT]); }); - it.each([ - { label: "scaled UI amount", extensions: [SCALED_BY_TWO_UNTIL_LATER] }, - { label: "interest-bearing", extensions: [INTEREST_BEARING_CONFIG] }, - ])("refuses a $label mint with 400", async ({ extensions }) => { - serveMint({ decimals: 6, extensions }); + it("answers 400 for a mint with no account", async () => { + respondMintAccounts(rpc, { mints: new Map(), clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP }); - await expect(inSandboxScope(() => readStaticAmountMintDecimals(MINT))).rejects.toMatchObject({ + await expect(inSandboxScope(() => loadPaymentAmountConverter(MINT))).rejects.toMatchObject({ code: "BAD_REQUEST", statusCode: 400, + message: "Token mint account does not exist", }); }); }); diff --git a/apps/sdp-api/src/services/payments/token-amount.ts b/apps/sdp-api/src/services/payments/token-amount.ts index 5bcb246ed7..6148211230 100644 --- a/apps/sdp-api/src/services/payments/token-amount.ts +++ b/apps/sdp-api/src/services/payments/token-amount.ts @@ -1,5 +1,5 @@ import { createClusterRpc } from "@sdp/rpc/factory"; -import { currentClusterScope } from "@sdp/rpc/scope"; +import { type ClusterScope, currentClusterScope } from "@sdp/rpc/scope"; import { AmountError, parseTokenAmount } from "@sdp/solana/amount"; import { SPL_TOKEN_PROGRAMS, WELL_KNOWN_TOKEN_BY_MINT } from "@sdp/types/well-known-tokens"; import { @@ -21,6 +21,10 @@ export interface PaymentMint { toBaseUnits: PaymentAmountConverter; } +export interface PaymentMintFacts extends PaymentMint { + refusedExtension: string | null; +} + const UNSUPPORTED_PAYMENT_EXTENSIONS: ReadonlySet = new Set([ "InterestBearingConfig", // biome-ignore lint/security/noSecrets: Token-2022 extension name, not a secret. @@ -40,15 +44,16 @@ function parsePaymentAmount(amount: string, decimals: number): bigint { /** * Resolves the mint's actual decimals and owning token program in one read. - * Refuses extensions that change the requested amount or prevent transfer. + * Records extensions the new-payment admission gate refuses. * Catalogue facts are verified by check:well-known-mints for the named cluster; * other mints are decoded live. The returned facts and converter are reused * through funding checks and builds, without another mint or clock read. */ -export async function loadPaymentMint(mint: Address): Promise { +async function readPaymentMint(mint: Address): Promise { const wellKnown = WELL_KNOWN_TOKEN_BY_MINT.get(mint); if (wellKnown?.clusters.includes(currentClusterScope().cluster)) { return { + refusedExtension: null, decimals: wellKnown.decimals, tokenProgram: address(SPL_TOKEN_PROGRAMS[wellKnown.tokenProgram]), toBaseUnits: (amount) => parsePaymentAmount(amount, wellKnown.decimals), @@ -72,27 +77,65 @@ export async function loadPaymentMint(mint: Address): Promise { } if (!decoded.data.isInitialized) throw badRequest("Token mint is not initialized"); const extensions = unwrapOption(decoded.data.extensions); - if (extensions !== null) { - for (const extension of extensions) { - if (UNSUPPORTED_PAYMENT_EXTENSIONS.has(extension.__kind)) { - throw badRequest(`Payments do not support the ${extension.__kind} mint extension`); - } - } - } + const refusedExtension = + extensions === null + ? null + : (extensions.find((extension) => UNSUPPORTED_PAYMENT_EXTENSIONS.has(extension.__kind)) + ?.__kind ?? null); const decimals = decoded.data.decimals; return { + refusedExtension, decimals, tokenProgram: account.programAddress, toBaseUnits: (amount) => parsePaymentAmount(amount, decimals), }; } +// Share immutable mint facts only within this request or cron scope. A failed +// lookup is removed, and a later scope always reads custom mints afresh. +const scopedMints = new WeakMap>>(); + +/** Reads mint facts for proof/recovery without admitting a new payment. */ +export function loadPaymentMintFacts(mint: Address): Promise { + const scope = currentClusterScope(); + let mints = scopedMints.get(scope); + if (mints === undefined) { + mints = new Map(); + scopedMints.set(scope, mints); + } + const pending = mints.get(mint); + if (pending !== undefined) return pending; + const entries = mints; + const lookup = readPaymentMint(mint).catch((error: unknown) => { + entries.delete(mint); + throw error; + }); + entries.set(mint, lookup); + return lookup; +} + +/** Admits new money movement after resolving the mint facts. */ +export async function loadPaymentMint(mint: Address): Promise { + const facts = await loadPaymentMintFacts(mint); + if (facts.refusedExtension !== null) + throw badRequest(`Payments do not support the ${facts.refusedExtension} mint extension`); + return facts; +} + /** Resolves a mint once for a batch of exact conversions. */ export async function loadPaymentAmountConverter(mint: Address): Promise { return (await loadPaymentMint(mint)).toBaseUnits; } -/** Recurring and subscription amounts use the same admissible mints as single payments. */ -export async function readStaticAmountMintDecimals(mint: Address): Promise { - return (await loadPaymentMint(mint)).decimals; +/** + * Parses a payment amount against an admissible mint's precision and the u64 range, so a create + * path refuses an amount it could never send before it persists anything. + * + * @param amount - The decimal UI amount from the request. + * @param mint - The payment's SPL mint, or the SOL mint. + * @returns The amount in the mint's base units. + * @throws AppError `BAD_REQUEST` for a refused mint, excess precision or a value outside u64. + */ +export async function parseStaticPaymentAmount(amount: string, mint: Address): Promise { + return (await loadPaymentMint(mint)).toBaseUnits(amount); } diff --git a/apps/sdp-api/src/test/helpers/cluster-rpc.ts b/apps/sdp-api/src/test/helpers/cluster-rpc.ts index 524a1b0681..4136ca50e7 100644 --- a/apps/sdp-api/src/test/helpers/cluster-rpc.ts +++ b/apps/sdp-api/src/test/helpers/cluster-rpc.ts @@ -432,20 +432,6 @@ export function systemAccountInfoResult(slot: number, lamports: number): JsonRpc }; } -/** - * A `getTokenSupply` result for a mint with no supply yet, which is all a decimals read needs. - * - * @param slot - The context slot the answer claims. - * @param decimals - The mint's decimals. - * @returns The JSON-RPC `result` to pass to {@link ClusterRpcStub.answer}. - */ -export function tokenSupplyResult(slot: number, decimals: number): JsonRpcResult { - return { - context: { slot }, - value: { amount: "0", decimals, uiAmount: 0, uiAmountString: "0" }, - }; -} - /** * The cluster whose `createClusterRpc()` client `client` is, so a test can tell which cluster a * spied chain helper was handed. The factory memoizes one client per cluster and pool, so the diff --git a/apps/sdp-api/src/test/helpers/recurring-payments.ts b/apps/sdp-api/src/test/helpers/recurring-payments.ts index 588fd4ce94..34d18909dc 100644 --- a/apps/sdp-api/src/test/helpers/recurring-payments.ts +++ b/apps/sdp-api/src/test/helpers/recurring-payments.ts @@ -16,7 +16,7 @@ import { paymentRecurringPaymentResponseSchema, } from "@/openapi/schemas/payments"; import { TEST_SOLANA_ADDRESSES } from "@/test/fixtures/tokens"; -import { installClusterRpcResponder, tokenSupplyResult } from "@/test/helpers/cluster-rpc"; +import { installClusterRpcResponder } from "@/test/helpers/cluster-rpc"; import { env } from "@/test/helpers/env"; import { createKoraAdapterMock, @@ -63,7 +63,6 @@ export function installRecurringExecutionHooks() { sourceSigner = await generateKeyPairSigner(); await updateSeededWalletPublicKey(sourceSigner.address); createOrgSignerForCustodyWalletMock.mockResolvedValue(sourceSigner); - rpc.answer("getTokenSupply", tokenSupplyResult(1, 6)); rpc.answer("getAccountInfo", mintAccountInfoResult({ decimals: 6, extensions: null })); signAsFeePayerMock = vi .fn<(transaction: Uint8Array) => Promise>()