From a01ae01274c0ccdcc9dd2039b71a277d072dd430 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Wed, 21 Oct 2026 00:55:07 +0800 Subject: [PATCH 01/11] test(payments): cover Token-2022 UI-amount conversion for transfers, batches and recurring --- .../src/routes/payments.recurring.test.ts | 32 +++ .../src/routes/payments.subscriptions.test.ts | 5 +- .../routes/payments.transfers.onchain.test.ts | 2 + ...nts.transfers.ramp-deposit-funding.test.ts | 13 +- .../routes/payments/token-accounts.test.ts | 73 ++++++- .../transfer-batches/handlers.test.ts | 110 ++++++++++- .../services/payments/token-amount.test.ts | 182 ++++++++++++++++++ .../src/test/helpers/recurring-payments.ts | 2 + apps/sdp-api/src/test/helpers/token-mints.ts | 141 ++++++++++++++ 9 files changed, 553 insertions(+), 7 deletions(-) create mode 100644 apps/sdp-api/src/services/payments/token-amount.test.ts create mode 100644 apps/sdp-api/src/test/helpers/token-mints.ts diff --git a/apps/sdp-api/src/routes/payments.recurring.test.ts b/apps/sdp-api/src/routes/payments.recurring.test.ts index 638593f5a3..17a7bc6f9e 100644 --- a/apps/sdp-api/src/routes/payments.recurring.test.ts +++ b/apps/sdp-api/src/routes/payments.recurring.test.ts @@ -97,6 +97,7 @@ import { } from "@/test/helpers/recurring-payments"; import { required } from "@/test/helpers/required"; import { sendTransactionPreflightError } from "@/test/helpers/rpc-errors"; +import { respondMintAccounts, scaledUiAmountConfig } from "@/test/helpers/token-mints"; // Every mutation carries a fresh key unless a test sets its own (HOO-1918). const app = withIdempotencyKeys(baseApp); @@ -838,6 +839,7 @@ describe("Payments routes — recurring", () => { const issuedMint = (await generateKeyPairSigner()).address; const pausedMint = (await generateKeyPairSigner()).address; + const scaledMint = (await generateKeyPairSigner()).address; await seedIssuedTokenMint({ projectId: TEST_PROJECT.id, mintAddress: issuedMint, @@ -848,8 +850,38 @@ describe("Payments routes — recurring", () => { mintAddress: pausedMint, status: "paused", }); + await seedIssuedTokenMint({ + projectId: TEST_PROJECT.id, + mintAddress: scaledMint, + status: "active", + }); + respondMintAccounts(recurringExecution.rpc, { + mints: new Map([ + [issuedMint, { decimals: 6, extensions: null }], + [ + scaledMint, + { + decimals: 6, + extensions: [ + scaledUiAmountConfig({ + multiplier: 2, + newMultiplier: 2, + newMultiplierEffectiveTimestamp: 0n, + }), + ], + }, + ], + ]), + clockUnixTimestamp: 1_750_000_000n, + }); await expectTokenRejected(pausedMint); + const scaledRes = await createRecurring(scaledMint); + expect(scaledRes.status).toBe(400); + expect(errorResponseSchema.parse(await scaledRes.json()).error.message).toBe( + "Recurring payments do not support interest-bearing or scaled UI amount tokens" + ); + const issuedRes = await createRecurring(issuedMint); expect(issuedRes.status).toBe(201); const issuedBody = await parseRecurringResponse(issuedRes); diff --git a/apps/sdp-api/src/routes/payments.subscriptions.test.ts b/apps/sdp-api/src/routes/payments.subscriptions.test.ts index d37d8ebc3d..4266c0d7e2 100644 --- a/apps/sdp-api/src/routes/payments.subscriptions.test.ts +++ b/apps/sdp-api/src/routes/payments.subscriptions.test.ts @@ -16,7 +16,7 @@ import { preparePaymentSubscriptionPlanResponseSchema, } from "@/openapi/schemas/payments"; import { TEST_SOLANA_ADDRESSES } from "@/test/fixtures/tokens"; -import { installClusterRpcStub, tokenSupplyResult } from "@/test/helpers/cluster-rpc"; +import { installClusterRpcStub } from "@/test/helpers/cluster-rpc"; import { env } from "@/test/helpers/env"; import { DEVNET_USDC_MINT, @@ -31,6 +31,7 @@ import { } from "@/test/helpers/payments-routes"; import { required } from "@/test/helpers/required"; import { TEST_MOCK_FEE_PAYER } from "@/test/helpers/sponsor-signing"; +import { mintAccountInfoResult } from "@/test/helpers/token-mints"; const SUBSCRIPTION_HEADERS = { Authorization: "Bearer sk_test_payments_policy", @@ -81,7 +82,7 @@ describe("Payments routes — subscriptions", () => { const rpc = installClusterRpcStub(); beforeEach(() => { - rpc.answer("getTokenSupply", tokenSupplyResult(1, 6)); + rpc.answer("getAccountInfo", mintAccountInfoResult({ decimals: 6, extensions: null })); }); it("stores a Production plan's USDC as the mainnet mint", 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..ea1bdb6464 100644 --- a/apps/sdp-api/src/routes/payments.transfers.onchain.test.ts +++ b/apps/sdp-api/src/routes/payments.transfers.onchain.test.ts @@ -43,6 +43,7 @@ import { } from "@/test/helpers/payments-transfers"; import { required } from "@/test/helpers/required"; import { fullySignTestTransaction } from "@/test/helpers/sponsor-signing"; +import { mintAccountInfoResult } from "@/test/helpers/token-mints"; function testProjectPaymentsRepository() { return createPostgresPaymentsRepository( @@ -103,6 +104,7 @@ describe("Payments routes — on-chain transfers", () => { const rpc = installClusterRpcStub(); function answerSourceUsdcTokenAccount(mint: string) { + rpc.answer("getAccountInfo", mintAccountInfoResult({ decimals: 6, extensions: null })); rpc.answer( "getTokenAccountsByOwner", tokenAccountsByOwnerResult(1, [ diff --git a/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts b/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts index 95643b71cf..006702969d 100644 --- a/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts +++ b/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts @@ -1,5 +1,5 @@ import { formatDecimalAmount } from "@sdp/solana/amount"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { getDb } from "@/db/client"; import { runWithSystemDatabaseIdentity } from "@/db/identity"; import { generatePaymentTransferId } from "@/db/repositories/payments.repository"; @@ -32,6 +32,7 @@ import { readTransferResponse, readTransferRow, } from "@/test/helpers/payments-transfers"; +import { mintAccountInfoResult } from "@/test/helpers/token-mints"; /** An off-ramp awaiting its 25 USDC deposit to wallet2, as a MoonPay sell leaves it. */ async function seedUsdcOfframpDeposit(transferId: string) { @@ -96,6 +97,10 @@ describe("Payments routes — funding an off-ramp token deposit", () => { installPaymentsRouteTestHooks(); const rpc = installClusterRpcStub(); + beforeEach(() => { + rpc.answer("getAccountInfo", mintAccountInfoResult({ decimals: 6, extensions: null })); + }); + function answerSourceUsdc(baseUnits: string, state: StubTokenAccount["state"]) { rpc.answer( "getTokenAccountsByOwner", @@ -116,6 +121,10 @@ describe("Payments routes — funding an off-ramp token deposit", () => { return rpc.calls.filter((call) => call.method === "getTokenAccountsByOwner").length; } + function mintAccountReads(): number { + return rpc.calls.filter((call) => call.method === "getAccountInfo").length; + } + it("refuses a deposit the wallet cannot cover, leaving the sale open for the same key", async () => { const transferId = generatePaymentTransferId(); await seedUsdcOfframpDeposit(transferId); @@ -139,6 +148,7 @@ describe("Payments routes — funding an off-ramp token deposit", () => { answerSourceUsdc("25000000", "initialized"); const tokenAccountReadsBefore = tokenAccountReads(); const mintReadsBefore = getAccountInfoMock.mock.calls.length; + const mintAccountReadsBefore = mintAccountReads(); const funded = await postTransfer(usdcDepositRequest(transferId), { idempotencyKey }); const fundedText = await funded.text(); @@ -151,6 +161,7 @@ describe("Payments routes — funding an off-ramp token deposit", () => { // The send reuses the source the check read: one read of each, as before the check. expect(tokenAccountReads() - tokenAccountReadsBefore).toBe(1); expect(getAccountInfoMock.mock.calls.length - mintReadsBefore).toBe(1); + expect(mintAccountReads() - mintAccountReadsBefore).toBe(1); }); it("refuses a deposit from a frozen token account before claiming it", async () => { 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 12e2cc3ba6..2acef1981f 100644 --- a/apps/sdp-api/src/routes/payments/token-accounts.test.ts +++ b/apps/sdp-api/src/routes/payments/token-accounts.test.ts @@ -1,8 +1,22 @@ +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"; -import type { Address } from "@solana/kit"; +import { type Address, createNoopSigner } from "@solana/kit"; +import { + getTransferCheckedInstructionDataDecoder, + TOKEN_2022_PROGRAM_ADDRESS, +} from "@solana-program/token-2022"; import { describe, expect, it } from "vitest"; -import { getSplTokenBalances, resolveTokenLabel, withIssuedTokenLabels } from "./token-accounts"; +import { installClusterRpcResponder, tokenAccountsByOwnerResult } from "@/test/helpers/cluster-rpc"; +import { syntheticAddress } from "@/test/helpers/parsed-transactions"; +import { respondMintAccounts, scaledUiAmountConfig } from "@/test/helpers/token-mints"; +import { + buildSplTransferInstructions, + getSplTokenBalances, + resolveTokenLabel, + withIssuedTokenLabels, +} from "./token-accounts"; describe("resolveTokenLabel", () => { it("uses the issued token symbol for a known mint", () => { @@ -61,3 +75,58 @@ describe("getSplTokenBalances", () => { await expect(balances).resolves.toEqual([]); }); }); + +describe("buildSplTransferInstructions", () => { + const rpc = installClusterRpcResponder(); + + it("debits a scaled UI amount mint's requested amount, not its static base units", async () => { + const mint = syntheticAddress(11); + respondMintAccounts(rpc, { + mints: new Map([ + [ + mint, + { + decimals: 6, + extensions: [ + scaledUiAmountConfig({ + multiplier: 2, + newMultiplier: 4, + newMultiplierEffectiveTimestamp: 1_800_000_000n, + }), + ], + }, + ], + ]), + clockUnixTimestamp: 1_750_000_000n, + }); + rpc.answer( + "getTokenAccountsByOwner", + tokenAccountsByOwnerResult(1, [ + { + pubkey: syntheticAddress(12), + mint, + amount: "20000000", + decimals: 6, + uiAmountString: "40", + state: "initialized", + }, + ]) + ); + + const { transferInstruction } = await buildSplTransferInstructions( + runInClusterScope({ projectId: null, environment: "sandbox" }, () => createClusterRpc()), + { + authority: createNoopSigner(syntheticAddress(13)), + destination: syntheticAddress(14), + mint, + amount: "10", + ataRentPayer: syntheticAddress(15), + } + ); + + expect(transferInstruction.programAddress).toBe(TOKEN_2022_PROGRAM_ADDRESS); + expect( + getTransferCheckedInstructionDataDecoder().decode(transferInstruction.data) + ).toMatchObject({ amount: 5_000_000n, decimals: 6 }); + }); +}); 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 a93c20892c..62df27f409 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 @@ -11,6 +11,11 @@ import { getTransactionDecoder, type Signature, } from "@solana/kit"; +import { + getTransferCheckedInstructionDataDecoder, + TOKEN_2022_PROGRAM_ADDRESS, + TRANSFER_CHECKED_DISCRIMINATOR, +} from "@solana-program/token-2022"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getDb } from "@/db/client"; import * as batchesRepositoryPostgres from "@/db/repositories/payment-transfer-batches.repository.postgres"; @@ -30,9 +35,10 @@ import * as solanaSigner from "@/services/solana/signer"; import { ALL_API_KEY_GRANTS } from "@/test/fixtures/api-keys"; import { TEST_SOLANA_ADDRESSES } from "@/test/fixtures/tokens"; import { signSeededClerkMember } from "@/test/helpers/clerk-member"; -import { installClusterRpcStub, tokenAccountsByOwnerResult } from "@/test/helpers/cluster-rpc"; +import { installClusterRpcResponder, tokenAccountsByOwnerResult } from "@/test/helpers/cluster-rpc"; import { seedTestCustodyRows } from "@/test/helpers/custody"; import { env } from "@/test/helpers/env"; +import { syntheticAddress } from "@/test/helpers/parsed-transactions"; import { createKoraAdapterMock, enableMainnetSponsorship, @@ -48,6 +54,11 @@ import { seedDefaultProjects } from "@/test/helpers/projects"; import { required } from "@/test/helpers/required"; import { sendTransactionPreflightError } from "@/test/helpers/rpc-errors"; import { fullySignTestTransaction, TEST_MOCK_FEE_PAYER } from "@/test/helpers/sponsor-signing"; +import { + mintAccountInfoResult, + respondMintAccounts, + scaledUiAmountConfig, +} from "@/test/helpers/token-mints"; import { type SeededApiKey, seedApiKeyRow, seedTestDatabase } from "@/test/mocks/db"; import { clearKVStores } from "@/test/mocks/kv"; @@ -384,9 +395,10 @@ async function _seedBatchApproverClerk(): Promise> { } describe("payment transfer batches", () => { - const rpc = installClusterRpcStub(); + const rpc = installClusterRpcResponder(); function answerSourceTokenAccount(mint: string) { + rpc.answer("getAccountInfo", mintAccountInfoResult({ decimals: 6, extensions: null })); rpc.answer( "getTokenAccountsByOwner", tokenAccountsByOwnerResult(1, [ @@ -1940,6 +1952,100 @@ describe("payment transfer batches", () => { } ); + it("moves a scaled UI amount mint's converted base units while recipients keep their requested amount", async () => { + const sourceSigner = await generateKeyPairSigner(); + await updateSeededWalletPublicKey(sourceSigner.address); + createOrgSignerForCustodyWalletMock.mockResolvedValueOnce(sourceSigner); + const mint = syntheticAddress(21); + getAccountInfoMock.mockImplementationOnce( + async (client, account) => + ( + await client + .getAccountInfo(account, { encoding: "base64", commitment: "confirmed" }) + .send() + ).value + ); + answerSourceTokenAccount(mint); + respondMintAccounts(rpc, { + mints: new Map([ + [ + mint, + { + decimals: 6, + extensions: [ + scaledUiAmountConfig({ + multiplier: 2, + newMultiplier: 4, + newMultiplierEffectiveTimestamp: 1_800_000_000n, + }), + ], + }, + ], + ]), + clockUnixTimestamp: 1_750_000_000n, + }); + const signingOutcomeMock = vi + .fn<(transactionBytes: Uint8Array) => Promise>() + .mockResolvedValueOnce(FIRST_SIGNATURE); + createKoraAdapterMock.mockReturnValueOnce(ownedSubmissionAdapter(signingOutcomeMock)); + const counterpartyId = await seedCounterparty("batch_scaled_ui_amount_counterparty"); + const counterpartyAccountIds = await seedCryptoWalletCounterpartyAccounts(counterpartyId, [ + TEST_SOLANA_ADDRESSES.wallet2, + TEST_SOLANA_ADDRESSES.wallet3, + ]); + + const res = await app.request( + "/v1/payments/transfer-batches", + { + method: "POST", + headers: { + "Idempotency-Key": crypto.randomUUID(), + "Content-Type": "application/json", + Authorization: `Bearer ${TEST_API_KEY.raw}`, + }, + body: JSON.stringify({ + sourceCustodyWalletId: TEST_CUSTODY_WALLET_ID, + token: mint, + recipients: [ + { counterpartyId, counterpartyAccountId: counterpartyAccountIds[0], amount: "10" }, + { counterpartyId, counterpartyAccountId: counterpartyAccountIds[1], amount: "2.5" }, + ], + options: { preflight: false }, + }), + }, + env + ); + + expect(res.status).toBe(200); + const [transactionBytes] = required(signingOutcomeMock.mock.calls.at(0)); + const message = getCompiledTransactionMessageDecoder().decode( + getTransactionDecoder().decode(transactionBytes).messageBytes + ); + assert(message.version === 0); + const transferCheckedAmounts = message.instructions.flatMap((instruction) => + message.staticAccounts[instruction.programAddressIndex] === TOKEN_2022_PROGRAM_ADDRESS && + instruction.data !== undefined && + instruction.data[0] === TRANSFER_CHECKED_DISCRIMINATOR + ? [getTransferCheckedInstructionDataDecoder().decode(instruction.data).amount] + : [] + ); + expect(transferCheckedAmounts).toEqual([5_000_000n, 1_250_000n]); + const stored = await getDb(env) + .prepare( + `SELECT r.destination_address, r.amount, b.total_amount + FROM payment_transfer_recipients r + JOIN payment_transfer_batches b ON b.id = r.batch_id + WHERE b.organization_id = ? + ORDER BY r.destination_address` + ) + .bind(TEST_ORG.id) + .all<{ destination_address: string; amount: string; total_amount: string }>(); + expect(stored.results).toEqual([ + { destination_address: TEST_SOLANA_ADDRESSES.wallet2, amount: "10", total_amount: "12.5" }, + { destination_address: TEST_SOLANA_ADDRESSES.wallet3, amount: "2.5", total_amount: "12.5" }, + ]); + }); + it("builds a Production project's USDC batch on mainnet-beta with the mainnet mint", async () => { const productionProjectId = `${TEST_PROJECT.id}_production`; const sourceSigner = await generateKeyPairSigner(); diff --git a/apps/sdp-api/src/services/payments/token-amount.test.ts b/apps/sdp-api/src/services/payments/token-amount.test.ts new file mode 100644 index 0000000000..c05751a710 --- /dev/null +++ b/apps/sdp-api/src/services/payments/token-amount.test.ts @@ -0,0 +1,182 @@ +import { createClusterRpc } from "@sdp/rpc/factory"; +import { runInClusterScope } from "@sdp/rpc/scope"; +import type { SolanaRpc } from "@sdp/rpc/solana"; +import { AmountError, parseDecimalAmount } from "@sdp/solana/amount"; +import { SYSVAR_CLOCK_ADDRESS } from "@solana/sysvars"; +import { + type ExtensionArgs, + extension, + uiAmountToAmountForInterestBearingMintWithoutSimulation, +} from "@solana-program/token-2022"; +import { describe, expect, it } from "vitest"; +import { installClusterRpcResponder } from "@/test/helpers/cluster-rpc"; +import { syntheticAddress } from "@/test/helpers/parsed-transactions"; +import { + respondMintAccounts, + type StubMint, + scaledUiAmountConfig, +} from "@/test/helpers/token-mints"; +import { loadPaymentAmountConverter, readStaticAmountMintDecimals } from "./token-amount"; + +const MINT = syntheticAddress(1); +const CLOCK_UNIX_TIMESTAMP = 1_750_000_000n; +const ONE_YEAR_SECONDS = 31_536_000n; +const INTEREST_INITIALIZED_AT = CLOCK_UNIX_TIMESTAMP - 2n * ONE_YEAR_SECONDS; +const INTEREST_RATE_UPDATED_AT = CLOCK_UNIX_TIMESTAMP - ONE_YEAR_SECONDS; +const PRE_UPDATE_AVERAGE_RATE_BPS = 300; +const CURRENT_RATE_BPS = 500; + +const INTEREST_BEARING_CONFIG: ExtensionArgs = extension("InterestBearingConfig", { + rateAuthority: syntheticAddress(2), + initializationTimestamp: INTEREST_INITIALIZED_AT, + preUpdateAverageRate: PRE_UPDATE_AVERAGE_RATE_BPS, + lastUpdateTimestamp: INTEREST_RATE_UPDATED_AT, + currentRate: CURRENT_RATE_BPS, +}); + +const SCALED_BY_TWO_UNTIL_LATER: ExtensionArgs = scaledUiAmountConfig({ + multiplier: 2, + newMultiplier: 4, + newMultiplierEffectiveTimestamp: CLOCK_UNIX_TIMESTAMP + ONE_YEAR_SECONDS, +}); + +function sandboxRpc(): SolanaRpc { + return runInClusterScope({ projectId: null, environment: "sandbox" }, () => createClusterRpc()); +} + +describe("loadPaymentAmountConverter", () => { + const rpc = installClusterRpcResponder(); + + function serveMint(mint: StubMint): void { + respondMintAccounts(rpc, { + mints: new Map([[MINT, mint]]), + clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP, + }); + } + + function accountInfoReads(): unknown[] { + return rpc.requests + .filter((request) => request.method === "getAccountInfo") + .map((request) => request.params[0]); + } + + it.each([ + { label: "no extensions", extensions: null }, + { + label: "only extensions that leave UI amounts alone", + extensions: [extension("PermanentDelegate", { delegate: syntheticAddress(3) })], + }, + ])( + "converts a Token-2022 mint with $label by its decimals, without reading the clock", + async ({ extensions }) => { + serveMint({ decimals: 6, extensions }); + + const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + + expect(toBaseUnits("12.345678")).toBe(parseDecimalAmount("12.345678", 6)); + expect(accountInfoReads()).toEqual([MINT]); + } + ); + + it("divides by the current multiplier before the new one takes effect", async () => { + serveMint({ decimals: 6, extensions: [SCALED_BY_TWO_UNTIL_LATER] }); + + const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + + expect(toBaseUnits("10")).toBe(5_000_000n); + expect(accountInfoReads()).toEqual([MINT, SYSVAR_CLOCK_ADDRESS]); + }); + + it("divides by the new multiplier from its effective timestamp on", async () => { + serveMint({ + decimals: 6, + extensions: [ + scaledUiAmountConfig({ + multiplier: 2, + newMultiplier: 4, + newMultiplierEffectiveTimestamp: CLOCK_UNIX_TIMESTAMP, + }), + ], + }); + + const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + + expect(toBaseUnits("10")).toBe(2_500_000n); + }); + + it("discounts an interest-bearing mint's accrued interest as of the cluster clock", async () => { + serveMint({ decimals: 6, extensions: [INTEREST_BEARING_CONFIG] }); + + const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + + const baseUnits = toBaseUnits("100"); + expect(baseUnits).toBeLessThan(parseDecimalAmount("100", 6)); + expect(baseUnits).toBe( + uiAmountToAmountForInterestBearingMintWithoutSimulation( + "100", + 6, + Number(CLOCK_UNIX_TIMESTAMP), + Number(INTEREST_RATE_UPDATED_AT), + Number(INTEREST_INITIALIZED_AT), + PRE_UPDATE_AVERAGE_RATE_BPS, + CURRENT_RATE_BPS + ) + ); + }); + + it.each([ + { label: "scaled UI amount", extensions: [SCALED_BY_TWO_UNTIL_LATER] }, + { label: "interest-bearing", extensions: [INTEREST_BEARING_CONFIG] }, + ])( + "refuses a $label amount finer than the mint's decimals as a plain mint does", + async ({ extensions }) => { + serveMint({ decimals: 6, extensions }); + + const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + + expect(() => toBaseUnits("1.0000001")).toThrow( + new AmountError("Amount has too many decimal places") + ); + } + ); + + it("answers 400 for a mint with no account", async () => { + respondMintAccounts(rpc, { mints: new Map(), clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP }); + + await expect(loadPaymentAmountConverter(sandboxRpc(), MINT)).rejects.toMatchObject({ + code: "BAD_REQUEST", + statusCode: 400, + message: "Token mint account does not exist", + }); + }); +}); + +describe("readStaticAmountMintDecimals", () => { + const rpc = installClusterRpcResponder(); + + function serveMint(mint: StubMint): void { + respondMintAccounts(rpc, { + mints: new Map([[MINT, mint]]), + clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP, + }); + } + + it("returns a plain mint's decimals", async () => { + serveMint({ decimals: 9, extensions: null }); + + await expect(readStaticAmountMintDecimals(sandboxRpc(), MINT)).resolves.toBe(9); + }); + + 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 }); + + await expect(readStaticAmountMintDecimals(sandboxRpc(), MINT)).rejects.toMatchObject({ + code: "BAD_REQUEST", + statusCode: 400, + message: "Recurring payments do not support interest-bearing or scaled UI amount tokens", + }); + }); +}); diff --git a/apps/sdp-api/src/test/helpers/recurring-payments.ts b/apps/sdp-api/src/test/helpers/recurring-payments.ts index 2b1d665965..588fd4ce94 100644 --- a/apps/sdp-api/src/test/helpers/recurring-payments.ts +++ b/apps/sdp-api/src/test/helpers/recurring-payments.ts @@ -32,6 +32,7 @@ import { import { seedDefaultProjects } from "@/test/helpers/projects"; import { required } from "@/test/helpers/required"; import { fullySignTestTransaction, TEST_MOCK_FEE_PAYER } from "@/test/helpers/sponsor-signing"; +import { mintAccountInfoResult } from "@/test/helpers/token-mints"; import { withIdempotencyKeys } from "./idempotency-keys"; // Every mutation carries a fresh key unless a test sets its own (HOO-1918). @@ -63,6 +64,7 @@ export function installRecurringExecutionHooks() { 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>() .mockImplementation(async (transaction) => fullySignTestTransaction(transaction)); diff --git a/apps/sdp-api/src/test/helpers/token-mints.ts b/apps/sdp-api/src/test/helpers/token-mints.ts new file mode 100644 index 0000000000..25af80d73f --- /dev/null +++ b/apps/sdp-api/src/test/helpers/token-mints.ts @@ -0,0 +1,141 @@ +import { + type Address, + address, + getBase64Decoder, + none, + type ReadonlyUint8Array, + some, + unixTimestamp, +} from "@solana/kit"; +import { getSysvarClockEncoder, SYSVAR_CLOCK_ADDRESS } from "@solana/sysvars"; +import { + type ExtensionArgs, + extension, + getMintEncoder, + TOKEN_2022_PROGRAM_ADDRESS, +} from "@solana-program/token-2022"; +import { z } from "zod"; +import type { ClusterRpcResponder, JsonRpcResult } from "@/test/helpers/cluster-rpc"; +import { syntheticAddress } from "@/test/helpers/parsed-transactions"; + +const SYSVAR_OWNER_ADDRESS = address("Sysvar1111111111111111111111111111111111111"); + +const SCALED_UI_AMOUNT_AUTHORITY = syntheticAddress(9_001); + +/** A Token-2022 mint as a stubbed `getAccountInfo` serves it; `extensions` is null for none. */ +export interface StubMint { + decimals: number; + extensions: ExtensionArgs[] | null; +} + +/** + * A base64 `getAccountInfo` result for an account holding `data`. + * + * @param params.owner - The program that owns the account. + * @param params.data - The account data. + * @returns The JSON-RPC `result`. + */ +function base64AccountInfoResult(params: { + owner: Address; + data: ReadonlyUint8Array; +}): JsonRpcResult { + return { + context: { slot: 1 }, + value: { + data: [getBase64Decoder().decode(params.data), "base64"], + executable: false, + lamports: 1_461_600, + owner: params.owner, + rentEpoch: 0, + space: params.data.length, + }, + }; +} + +/** + * A `ScaledUiAmountConfig` extension: UI amounts are base units times `multiplier`, or times + * `newMultiplier` once the cluster clock reaches `newMultiplierEffectiveTimestamp`. + * + * @param scale.multiplier - The multiplier in force before the effective timestamp. + * @param scale.newMultiplier - The multiplier in force from the effective timestamp on. + * @param scale.newMultiplierEffectiveTimestamp - The Unix timestamp `newMultiplier` takes over at. + * @returns The extension, for {@link StubMint.extensions}. + */ +export function scaledUiAmountConfig(scale: { + multiplier: number; + newMultiplier: number; + newMultiplierEffectiveTimestamp: bigint; +}): ExtensionArgs { + return extension("ScaledUiAmountConfig", { + authority: SCALED_UI_AMOUNT_AUTHORITY, + multiplier: scale.multiplier, + newMultiplier: scale.newMultiplier, + newMultiplierEffectiveTimestamp: scale.newMultiplierEffectiveTimestamp, + }); +} + +/** + * A base64 `getAccountInfo` result for a Token-2022 mint, encoded by the token program's own + * mint encoder. + * + * @param mint - The mint's decimals and extensions. + * @returns The JSON-RPC `result` to pass to `answer("getAccountInfo", …)`. + */ +export function mintAccountInfoResult(mint: StubMint): JsonRpcResult { + return base64AccountInfoResult({ + owner: TOKEN_2022_PROGRAM_ADDRESS, + data: getMintEncoder().encode({ + mintAuthority: none(), + supply: 0n, + decimals: mint.decimals, + isInitialized: true, + freezeAuthority: none(), + extensions: mint.extensions === null ? none() : some(mint.extensions), + }), + }); +} + +/** + * A base64 `getAccountInfo` result for the Clock sysvar at `clockUnixTimestamp`. + * + * @param clockUnixTimestamp - The cluster's Unix timestamp. + * @returns The JSON-RPC `result`. + */ +function clockAccountInfoResult(clockUnixTimestamp: bigint): JsonRpcResult { + return base64AccountInfoResult({ + owner: SYSVAR_OWNER_ADDRESS, + data: getSysvarClockEncoder().encode({ + slot: 1n, + epochStartTimestamp: unixTimestamp(clockUnixTimestamp), + epoch: 0n, + leaderScheduleEpoch: 1n, + unixTimestamp: unixTimestamp(clockUnixTimestamp), + }), + }); +} + +/** + * Answer every `getAccountInfo` of the current test by the address it asks for: each of `mints` + * as a Token-2022 mint, the Clock sysvar at `clockUnixTimestamp`, and any other address as no + * account. + * + * @param rpc - The suite's cluster RPC responder. + * @param params.mints - The mints the cluster holds, by address. + * @param params.clockUnixTimestamp - The Unix timestamp the Clock sysvar reads. + */ +export function respondMintAccounts( + rpc: ClusterRpcResponder, + params: { mints: ReadonlyMap; clockUnixTimestamp: bigint } +): void { + rpc.respond("getAccountInfo", (request) => { + const accountAddress = z.string().parse(request.params[0]); + if (accountAddress === SYSVAR_CLOCK_ADDRESS) { + return { result: clockAccountInfoResult(params.clockUnixTimestamp) }; + } + const mint = params.mints.get(accountAddress); + return { + result: + mint === undefined ? { context: { slot: 1 }, value: null } : mintAccountInfoResult(mint), + }; + }); +} From c6a0cbe5e2333b2c85ac03e728ae90451fa58e64 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Wed, 21 Oct 2026 00:56:56 +0800 Subject: [PATCH 02/11] test(payments): refuse a batch recipient that converts to zero base units --- .../transfer-batches/handlers.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) 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 62df27f409..8125d28e71 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 @@ -1952,6 +1952,77 @@ describe("payment transfer batches", () => { } ); + it("refuses a scaled UI amount recipient that converts to zero base units before signing anything", async () => { + const mint = syntheticAddress(22); + getAccountInfoMock.mockImplementationOnce( + async (client, account) => + ( + await client + .getAccountInfo(account, { encoding: "base64", commitment: "confirmed" }) + .send() + ).value + ); + answerSourceTokenAccount(mint); + respondMintAccounts(rpc, { + mints: new Map([ + [ + mint, + { + decimals: 6, + extensions: [ + scaledUiAmountConfig({ + multiplier: 2, + newMultiplier: 4, + newMultiplierEffectiveTimestamp: 1_800_000_000n, + }), + ], + }, + ], + ]), + clockUnixTimestamp: 1_750_000_000n, + }); + const counterpartyId = await seedCounterparty("batch_scaled_zero_counterparty"); + const counterpartyAccountId = await seedCryptoWalletCounterpartyAccount({ + counterpartyId, + walletAddress: TEST_SOLANA_ADDRESSES.wallet2, + }); + + const res = await app.request( + "/v1/payments/transfer-batches", + { + method: "POST", + headers: { + "Idempotency-Key": crypto.randomUUID(), + "Content-Type": "application/json", + Authorization: `Bearer ${TEST_API_KEY.raw}`, + }, + body: JSON.stringify({ + sourceCustodyWalletId: TEST_CUSTODY_WALLET_ID, + token: mint, + recipients: [{ counterpartyId, counterpartyAccountId, amount: "0.000001" }], + options: { preflight: false }, + }), + }, + env + ); + + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: { code: "BAD_REQUEST", message: "Recipient amount must be greater than zero" }, + }); + expect(createOrgSignerForCustodyWalletMock).not.toHaveBeenCalled(); + expect(sendTransactionMock).not.toHaveBeenCalled(); + const count = 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 }>(); + expect(count).toEqual({ count: 0 }); + }); + it("moves a scaled UI amount mint's converted base units while recipients keep their requested amount", async () => { const sourceSigner = await generateKeyPairSigner(); await updateSeededWalletPublicKey(sourceSigner.address); From df2ad4aa0117463de6da52a65cdfdfc2baff04c1 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Wed, 21 Oct 2026 00:59:26 +0800 Subject: [PATCH 03/11] fix(payments): convert Token-2022 UI amounts through the mint's interest or scale Single transfers, batches and payment requests convert through one loadPaymentAmountConverter per mint, so TransferChecked debits the requested UI amount on InterestBearing and ScaledUiAmount mints (APE-355, APE-399). Recurring payments and subscription plans refuse those mints, since their per-period cap is fixed in base units. A batch recipient that converts to zero base units is refused. --- .../src/routes/payments/token-accounts.ts | 11 +- .../payments/transfer-batches/resolve.ts | 49 ++++-- .../routes/payments/transfer-batches/types.ts | 4 + .../src/routes/payments/transfers/handlers.ts | 15 +- .../payments/recurring-payments/shared.ts | 6 +- .../src/services/payments/token-amount.ts | 147 ++++++++++++++++++ 6 files changed, 204 insertions(+), 28 deletions(-) create mode 100644 apps/sdp-api/src/services/payments/token-amount.ts diff --git a/apps/sdp-api/src/routes/payments/token-accounts.ts b/apps/sdp-api/src/routes/payments/token-accounts.ts index a2e2799039..07ca6458f1 100644 --- a/apps/sdp-api/src/routes/payments/token-accounts.ts +++ b/apps/sdp-api/src/routes/payments/token-accounts.ts @@ -1,6 +1,5 @@ import { getAccountInfo, type SolanaRpc } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; -import { parseDecimalAmount } from "@sdp/solana/amount"; import { SPL_TOKEN_PROGRAMS, WELL_KNOWN_TOKEN_BY_MINT } from "@sdp/types/well-known-tokens"; import { type Address, @@ -15,6 +14,10 @@ import { getTransferCheckedInstruction, } from "@solana-program/token-2022"; import { badRequest } from "@/lib/errors"; +import { + loadPaymentAmountConverter, + type PaymentAmountConverter, +} from "@/services/payments/token-amount"; const SPL_TOKEN_PROGRAM_ID = address(SPL_TOKEN_PROGRAMS["spl-token"]); const SPL_TOKEN_2022_PROGRAM_ID = address(SPL_TOKEN_PROGRAMS["token-2022"]); @@ -41,6 +44,8 @@ export interface SourceTokenAccount { /** A source token account with its mint's token program, read once for a send to reuse. */ export interface SplTransferSource extends SourceTokenAccount { tokenProgram: Address; + /** The funding check's converter; the build reuses it so both see one raw amount. */ + toBaseUnits: PaymentAmountConverter; } function issuedTokenLabel(mint: string, tokenLabelsByMint?: TokenLabelsByMint): string | null { @@ -259,7 +264,9 @@ export async function buildSplTransferInstructions( const sourceTokenAccount = input.source ?? (await resolveSourceTokenAccount(rpc, input.authority.address, input.mint, tokenProgram)); - const transferAmount = parseDecimalAmount(input.amount, sourceTokenAccount.decimals); + const toBaseUnits = + input.source?.toBaseUnits ?? (await loadPaymentAmountConverter(rpc, input.mint)); + const transferAmount = toBaseUnits(input.amount); if (transferAmount <= 0n) { throw badRequest("Transfer amount must be greater than zero"); } 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 80e9e3ca89..c58589fffc 100644 --- a/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts +++ b/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts @@ -23,6 +23,10 @@ import { isNativePaymentToken, normalizePaymentToken, } from "@/services/payment-operation.service"; +import { + loadPaymentAmountConverter, + type PaymentAmountConverter, +} from "@/services/payments/token-amount"; import type { CustodyWallet } from "@/services/stores/custody-config.store"; import type { CreateTransferBatchInput, @@ -35,15 +39,21 @@ import type { /** * Parses a recipient amount into token base units, mapping amount-format - * violations to a 400 response. + * violations to a 400 response. A positive UI amount can still convert to zero + * base units on an interest-bearing or scaled mint; that is refused too, so no + * recipient becomes a sponsored transfer that moves nothing. * * @param amount - Decimal amount string from the request body. - * @param decimals - Decimal places of the batch token. + * @param toBaseUnits - The batch token's converter from UI amounts to base units. * @returns The amount in token base units. */ -function parseRecipientAmount(amount: string, decimals: number): bigint { +function parseRecipientAmount(amount: string, toBaseUnits: PaymentAmountConverter): bigint { try { - return parseDecimalAmount(assertPositivePaymentAmount(amount), decimals); + 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); @@ -67,17 +77,20 @@ async function resolveTokenContext( sourceAddress: Address ): Promise { if (isNativePaymentToken(token)) { - return { kind: "sol", token: SOL_MINT, decimals: SOL_DECIMALS }; + return { + kind: "sol", + token: SOL_MINT, + decimals: SOL_DECIMALS, + toBaseUnits: (amount) => parseDecimalAmount(amount, SOL_DECIMALS), + }; } const mintAddress = assertValidAddress(token, "token"); const tokenProgram = await resolveMintTokenProgram(rpc, mintAddress); - const sourceTokenAccount = await resolveSourceTokenAccount( - rpc, - sourceAddress, - mintAddress, - tokenProgram - ); + const [sourceTokenAccount, toBaseUnits] = await Promise.all([ + resolveSourceTokenAccount(rpc, sourceAddress, mintAddress, tokenProgram), + loadPaymentAmountConverter(rpc, mintAddress), + ]); return { kind: "spl", @@ -86,6 +99,7 @@ async function resolveTokenContext( mintAddress, tokenProgram, sourceTokenAccount: sourceTokenAccount.tokenAccount, + toBaseUnits, }; } @@ -120,28 +134,31 @@ function readCryptoWalletAddress(account: CounterpartyAccountRow, index: number) * * @param params.recipients - Recipient inputs from the request body. * @param params.accounts - The named accounts, by id, as authorize loaded them. - * @param params.decimals - Decimal places of the batch token. + * @param params.tokenContext - The batch token: its decimals and UI-amount converter. * @returns Recipients with resolved destination addresses and base-unit amounts. */ function resolveRecipients(params: { recipients: TransferBatchRecipientInput[]; accounts: ReadonlyMap; - decimals: number; + tokenContext: TokenContext; }): ResolvedRecipient[] { + const { decimals, toBaseUnits } = params.tokenContext; return params.recipients.map((recipient, index) => { const account = params.accounts.get(recipient.counterpartyAccountId); if (account?.status !== "active") { throw notFound(`Counterparty account ${recipient.counterpartyAccountId}`); } - const amountBaseUnits = parseRecipientAmount(recipient.amount, params.decimals); + // `amount` stays the requested UI amount in canonical form; an interest-bearing + // or scaled mint moves `amountBaseUnits` away from its static scaling. + const amountBaseUnits = parseRecipientAmount(recipient.amount, toBaseUnits); return { index, externalId: recipient.externalId ?? null, counterpartyId: account.counterparty_id, counterpartyAccountId: recipient.counterpartyAccountId, destinationAddress: readCryptoWalletAddress(account, index), - amount: formatDecimalAmount(amountBaseUnits, params.decimals), + amount: formatDecimalAmount(parseDecimalAmount(recipient.amount, decimals), decimals), amountBaseUnits, }; }); @@ -213,7 +230,7 @@ export async function resolveBatchMovement( const recipients = resolveRecipients({ recipients: input.recipients, accounts: authorized.accounts, - decimals: tokenContext.decimals, + tokenContext, }); return { scope: authorized.scope, diff --git a/apps/sdp-api/src/routes/payments/transfer-batches/types.ts b/apps/sdp-api/src/routes/payments/transfer-batches/types.ts index 396b1c4bc3..e482726883 100644 --- a/apps/sdp-api/src/routes/payments/transfer-batches/types.ts +++ b/apps/sdp-api/src/routes/payments/transfer-batches/types.ts @@ -2,6 +2,7 @@ import type { getRecentBlockhash, SolanaRpc } from "@sdp/rpc/solana"; import type { Address } from "@solana/kit"; import type { z } from "zod"; import type { ResolvedScope } from "@/routes/payments/wallets"; +import type { PaymentAmountConverter } from "@/services/payments/token-amount"; import type { CustodyWallet } from "@/services/stores/custody-config.store"; import type { createTransferBatchSchema } from "./schemas"; @@ -16,6 +17,7 @@ export type TokenContext = /** Canonical native token value: the wrapped SOL mint. */ token: string; decimals: 9; + toBaseUnits: PaymentAmountConverter; } | { kind: "spl"; @@ -24,6 +26,8 @@ export type TokenContext = mintAddress: Address; tokenProgram: Address; sourceTokenAccount: Address; + /** Loaded once per batch; every recipient amount converts through it. */ + toBaseUnits: PaymentAmountConverter; }; export interface ResolvedRecipient { diff --git a/apps/sdp-api/src/routes/payments/transfers/handlers.ts b/apps/sdp-api/src/routes/payments/transfers/handlers.ts index db2a302dc8..e3c5405c4c 100644 --- a/apps/sdp-api/src/routes/payments/transfers/handlers.ts +++ b/apps/sdp-api/src/routes/payments/transfers/handlers.ts @@ -104,6 +104,7 @@ import { resolveOutboundPaymentOperation, } from "@/services/payment-operation.service"; import { createTransferSignedSubmissionStore } from "@/services/payments/signed-submission"; +import { loadPaymentAmountConverter } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -259,14 +260,12 @@ async function assertRampDepositFunded( const rpc = createClusterRpc(); const mint = assertValidAddress(operation.token, "token"); const tokenProgram = await resolveMintTokenProgram(rpc, mint); - const source = await resolveSourceTokenAccount( - rpc, - address(operation.sourceWallet.publicKey), - mint, - tokenProgram - ); - const needed = parseDecimalAmount(operation.amount, source.decimals); - if (!source.frozen && source.amount >= needed) return { ...source, tokenProgram }; + const [source, toBaseUnits] = await Promise.all([ + resolveSourceTokenAccount(rpc, address(operation.sourceWallet.publicKey), mint, tokenProgram), + loadPaymentAmountConverter(rpc, mint), + ]); + const needed = toBaseUnits(operation.amount); + if (!source.frozen && source.amount >= needed) return { ...source, tokenProgram, toBaseUnits }; const token = resolveTokenLabel(mint); const held = formatDecimalAmount(source.amount, source.decimals); 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 1a51b83452..5fcfa848e9 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/shared.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/shared.ts @@ -53,7 +53,7 @@ import { import { assertMovementAdmitted, decidePrincipal } from "@/lib/money-admission"; import type { currentMandateActivator } from "@/lib/money-principal"; import { createTenantScope } from "@/lib/tenant-scope"; -import { resolveMintDecimals, resolveMintTokenProgram } from "@/routes/payments/token-accounts"; +import { resolveMintTokenProgram } from "@/routes/payments/token-accounts"; import { getLogger } from "@/runtime/logger"; import { parseGrantsColumn } from "@/services/api-key.service"; import { @@ -61,6 +61,7 @@ import { normalizePaymentToken, parseU64String, } from "@/services/payment-operation.service"; +import { readStaticAmountMintDecimals } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -106,6 +107,7 @@ export async function assertRecurringPaymentTokenMint( if (issuedTokenStatus !== "active") { throw badRequest(RECURRING_PAYMENT_TOKEN_ERROR); } + await readStaticAmountMintDecimals(createClusterRpc(), mint); return mint; } @@ -135,7 +137,7 @@ export async function resolvePlanRuntime( const rpc = createClusterRpc(); const [tokenProgram, decimals] = await Promise.all([ resolveMintTokenProgram(rpc, mint), - resolveMintDecimals(rpc, mint), + readStaticAmountMintDecimals(rpc, mint), ]); const amountBaseUnits = parseDecimalAmount(plan.amount, decimals); diff --git a/apps/sdp-api/src/services/payments/token-amount.ts b/apps/sdp-api/src/services/payments/token-amount.ts new file mode 100644 index 0000000000..3cb5d89862 --- /dev/null +++ b/apps/sdp-api/src/services/payments/token-amount.ts @@ -0,0 +1,147 @@ +import type { SolanaRpc } from "@sdp/rpc/solana"; +import { parseDecimalAmount } from "@sdp/solana/amount"; +import { type Address, unwrapOption } from "@solana/kit"; +import { fetchSysvarClock } from "@solana/sysvars"; +import { + type Extension, + fetchMaybeMint, + uiAmountToAmountForInterestBearingMintWithoutSimulation, + uiAmountToAmountForScaledUiAmountMintWithoutSimulation, +} from "@solana-program/token-2022"; +import { badRequest } from "@/lib/errors"; + +/** Converts a payment's UI decimal amount string into the base units its mint moves. */ +export type PaymentAmountConverter = (uiAmount: string) => bigint; + +type UiAmountExtension = Extract< + Extension, + { __kind: "InterestBearingConfig" } | { __kind: "ScaledUiAmountConfig" } +>; + +function isUiAmountExtension(extension: Extension): extension is UiAmountExtension { + return ( + extension.__kind === "InterestBearingConfig" || + // biome-ignore lint/security/noSecrets: Token-2022 extension name, not a secret. + extension.__kind === "ScaledUiAmountConfig" + ); +} + +/** Interest-bearing wins over scaled UI amount, the order Kit's own mint dispatch uses. */ +function findUiAmountExtension(extensions: readonly Extension[]): UiAmountExtension | null { + const uiAmountExtensions = extensions.filter(isUiAmountExtension); + const interestBearing = uiAmountExtensions.find( + (extension) => extension.__kind === "InterestBearingConfig" + ); + const selected = interestBearing !== undefined ? interestBearing : uiAmountExtensions.at(0); + return selected === undefined ? null : selected; +} + +/** + * Reads a mint's decimals and the extension, if any, that rescales its UI amounts. + * + * @param rpc - The cluster RPC the caller already holds. + * @param mint - The mint to read. + * @returns The mint's decimals and its UI-amount extension, or null when it has none. + */ +async function readUiAmountMint( + rpc: SolanaRpc, + mint: Address +): Promise<{ decimals: number; uiAmountExtension: UiAmountExtension | null }> { + const mintAccount = await fetchMaybeMint(rpc, mint, { commitment: "confirmed" }); + if (!mintAccount.exists) { + throw badRequest("Token mint account does not exist"); + } + const extensions = unwrapOption(mintAccount.data.extensions); + return { + decimals: mintAccount.data.decimals, + uiAmountExtension: extensions === null ? null : findUiAmountExtension(extensions), + }; +} + +/** + * Reads a mint's decimals for a flow that fixes a raw amount once and reuses it + * (recurring payments and subscription plans pin a per-period cap in base units), + * refusing interest-bearing and scaled-UI-amount mints, whose raw amount for a + * fixed UI amount drifts with the cluster clock. Token-2022 extensions are fixed + * at mint initialization, so a mint admitted here stays admissible. + * + * @param rpc - The cluster RPC the caller already holds. + * @param mint - The mint the flow is denominated in. + * @returns The mint's decimals. + */ +export async function readStaticAmountMintDecimals(rpc: SolanaRpc, mint: Address): Promise { + const { decimals, uiAmountExtension } = await readUiAmountMint(rpc, mint); + if (uiAmountExtension !== null) { + throw badRequest( + "Recurring payments do not support interest-bearing or scaled UI amount tokens" + ); + } + return decimals; +} + +/** + * Reads a mint once and returns the converter every payment path uses to turn its + * UI amount into base units. A plain SPL Token or Token-2022 mint converts by its + * decimals alone; a mint with `InterestBearingConfig` or `ScaledUiAmountConfig` + * reverses the token program's UI scaling as of the cluster clock read here, so + * `TransferChecked` debits what the caller asked for rather than more. Every + * conversion validates precision with `parseDecimalAmount` first, so the input + * grammar and error contract are the same on every mint. + * + * The converter is synchronous: callers converting many amounts for one mint + * load it once and reuse it, and a funding check and the build it precedes + * must share one instance so both see the same raw amount. + * + * @param rpc - The cluster RPC the caller already holds. + * @param mint - The mint the amounts are denominated in. + * @returns A converter from UI decimal strings to base units for that mint. + */ +export async function loadPaymentAmountConverter( + rpc: SolanaRpc, + mint: Address +): Promise { + const { decimals, uiAmountExtension } = await readUiAmountMint(rpc, mint); + const toStaticBaseUnits: PaymentAmountConverter = (uiAmount) => + parseDecimalAmount(uiAmount, decimals); + if (uiAmountExtension === null) { + return toStaticBaseUnits; + } + + const clock = await fetchSysvarClock(rpc, { commitment: "confirmed" }); + switch (uiAmountExtension.__kind) { + case "InterestBearingConfig": { + const config = uiAmountExtension; + return (uiAmount) => { + toStaticBaseUnits(uiAmount); + return uiAmountToAmountForInterestBearingMintWithoutSimulation( + uiAmount.trim(), + decimals, + Number(clock.unixTimestamp), + Number(config.lastUpdateTimestamp), + Number(config.initializationTimestamp), + config.preUpdateAverageRate, + config.currentRate + ); + }; + } + // biome-ignore lint/security/noSecrets: Token-2022 extension name, not a secret. + case "ScaledUiAmountConfig": { + const multiplier = + clock.unixTimestamp >= uiAmountExtension.newMultiplierEffectiveTimestamp + ? uiAmountExtension.newMultiplier + : uiAmountExtension.multiplier; + return (uiAmount) => { + toStaticBaseUnits(uiAmount); + return uiAmountToAmountForScaledUiAmountMintWithoutSimulation( + uiAmount.trim(), + decimals, + multiplier + ); + }; + } + default: { + const exhaustive: never = uiAmountExtension; + return exhaustive; + } + } +} From e025f0ea3a5f9ebd41fda6d0003fe711c3e1db09 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Wed, 21 Oct 2026 18:09:23 +0800 Subject: [PATCH 04/11] test(payments): cancel a recurring payment whose issued token was paused --- .../src/routes/payments.recurring.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/apps/sdp-api/src/routes/payments.recurring.test.ts b/apps/sdp-api/src/routes/payments.recurring.test.ts index 17a7bc6f9e..23af78a336 100644 --- a/apps/sdp-api/src/routes/payments.recurring.test.ts +++ b/apps/sdp-api/src/routes/payments.recurring.test.ts @@ -1994,6 +1994,46 @@ describe("Payments routes — recurring", () => { expect(sendTransactionMock).toHaveBeenCalledTimes(3); }); + it("cancels an active recurring payment whose issued token was paused after activation", async () => { + 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, + }); + await getDb(env) + .prepare("UPDATE issued_tokens SET status = 'paused' WHERE id = ?") + .bind(issuedTokenId) + .run(); + + const cancelRes = await app.request( + `/v1/payments/recurring-payments/${activated.id}/cancel`, + { method: "POST", headers: RECURRING_HEADERS, body: "{}" }, + env + ); + + expect(cancelRes.status).toBe(200); + expect((await parseRecurringResponse(cancelRes)).data.recurringPayment).toMatchObject({ + id: activated.id, + status: "canceled", + }); + expect( + await getDb(env) + .prepare( + `SELECT status, signature + FROM payment_recurring_payment_lifecycle_attempts + WHERE recurring_payment_id = ? AND operation = 'cancel'` + ) + .bind(activated.id) + .first<{ status: string; signature: string | null }>() + ).toEqual({ status: "confirmed", signature: poolSentSignatures()[2] }); + expect(sendTransactionMock).toHaveBeenCalledTimes(3); + }); + it("keeps a cancel whose send outcome is unknown in flight with its signature saved", async () => { const activated = await activateRecurringPaymentFixture(DEFAULT_RECURRING_FIXTURE); sendTransactionMock.mockRejectedValueOnce(new Error("RPC response lost")); From df331aadd6cca3217a6d5b89a57344cc76c7f51c Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Wed, 21 Oct 2026 18:09:53 +0800 Subject: [PATCH 05/11] fix(payments): let cancel skip the recurring token admission check Cancel is an exit: run the start-admission token check only on resume, so a schedule on a paused, archived, or scaled/interest-bearing issued token can still be stopped. --- .../payments/recurring-payments/lifecycle.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 564b6417ed..d0fb6a3a4b 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/lifecycle.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/lifecycle.ts @@ -584,13 +584,16 @@ 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( - await assertRecurringPaymentTokenMint( - plan.token, - input.organizationId, - input.projectId, - input.env - ), + input.operation === "resume" + ? await assertRecurringPaymentTokenMint( + plan.token, + input.organizationId, + input.projectId, + input.env + ) + : plan.token, "tokenMint" ); From 151b0b842c22080e36034f7050c37878c82faaf3 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Wed, 21 Oct 2026 18:11:47 +0800 Subject: [PATCH 06/11] fix(payments): read well-known mint decimals from the catalogue, and the cluster RPC from scope The UI-amount mint read answers a well-known mint declared on the open scope's cluster from the catalogue, skipping the RPC read; check:well-known-mints now also pins that no catalogue mint carries an interest-bearing or scaled UI amount extension. The converter and static-decimals read take the cluster RPC from scope instead of a caller-passed client. --- .../src/routes/payments/token-accounts.ts | 3 +- .../payments/transfer-batches/resolve.ts | 2 +- .../src/routes/payments/transfers/handlers.ts | 2 +- .../payments/recurring-payments/shared.ts | 4 +-- .../src/services/payments/token-amount.ts | 30 ++++++++++--------- scripts/check-well-known-mints.mjs | 17 ++++++++++- 6 files changed, 37 insertions(+), 21 deletions(-) diff --git a/apps/sdp-api/src/routes/payments/token-accounts.ts b/apps/sdp-api/src/routes/payments/token-accounts.ts index 07ca6458f1..353361e00f 100644 --- a/apps/sdp-api/src/routes/payments/token-accounts.ts +++ b/apps/sdp-api/src/routes/payments/token-accounts.ts @@ -264,8 +264,7 @@ export async function buildSplTransferInstructions( const sourceTokenAccount = input.source ?? (await resolveSourceTokenAccount(rpc, input.authority.address, input.mint, tokenProgram)); - const toBaseUnits = - input.source?.toBaseUnits ?? (await loadPaymentAmountConverter(rpc, input.mint)); + const toBaseUnits = input.source?.toBaseUnits ?? (await loadPaymentAmountConverter(input.mint)); const transferAmount = toBaseUnits(input.amount); if (transferAmount <= 0n) { throw badRequest("Transfer amount must be greater than zero"); 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 c58589fffc..e6e7865828 100644 --- a/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts +++ b/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts @@ -89,7 +89,7 @@ async function resolveTokenContext( const tokenProgram = await resolveMintTokenProgram(rpc, mintAddress); const [sourceTokenAccount, toBaseUnits] = await Promise.all([ resolveSourceTokenAccount(rpc, sourceAddress, mintAddress, tokenProgram), - loadPaymentAmountConverter(rpc, mintAddress), + loadPaymentAmountConverter(mintAddress), ]); return { diff --git a/apps/sdp-api/src/routes/payments/transfers/handlers.ts b/apps/sdp-api/src/routes/payments/transfers/handlers.ts index e3c5405c4c..bad2056877 100644 --- a/apps/sdp-api/src/routes/payments/transfers/handlers.ts +++ b/apps/sdp-api/src/routes/payments/transfers/handlers.ts @@ -262,7 +262,7 @@ async function assertRampDepositFunded( const tokenProgram = await resolveMintTokenProgram(rpc, mint); const [source, toBaseUnits] = await Promise.all([ resolveSourceTokenAccount(rpc, address(operation.sourceWallet.publicKey), mint, tokenProgram), - loadPaymentAmountConverter(rpc, mint), + loadPaymentAmountConverter(mint), ]); const needed = toBaseUnits(operation.amount); if (!source.frozen && source.amount >= needed) return { ...source, tokenProgram, toBaseUnits }; 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 5fcfa848e9..2eacdb25e5 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/shared.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/shared.ts @@ -107,7 +107,7 @@ export async function assertRecurringPaymentTokenMint( if (issuedTokenStatus !== "active") { throw badRequest(RECURRING_PAYMENT_TOKEN_ERROR); } - await readStaticAmountMintDecimals(createClusterRpc(), mint); + await readStaticAmountMintDecimals(mint); return mint; } @@ -137,7 +137,7 @@ export async function resolvePlanRuntime( const rpc = createClusterRpc(); const [tokenProgram, decimals] = await Promise.all([ resolveMintTokenProgram(rpc, mint), - readStaticAmountMintDecimals(rpc, mint), + readStaticAmountMintDecimals(mint), ]); const amountBaseUnits = parseDecimalAmount(plan.amount, decimals); diff --git a/apps/sdp-api/src/services/payments/token-amount.ts b/apps/sdp-api/src/services/payments/token-amount.ts index 3cb5d89862..223258b772 100644 --- a/apps/sdp-api/src/services/payments/token-amount.ts +++ b/apps/sdp-api/src/services/payments/token-amount.ts @@ -1,5 +1,7 @@ -import type { SolanaRpc } from "@sdp/rpc/solana"; +import { createClusterRpc } from "@sdp/rpc/factory"; +import { currentClusterScope } from "@sdp/rpc/scope"; import { parseDecimalAmount } from "@sdp/solana/amount"; +import { WELL_KNOWN_TOKEN_BY_MINT } from "@sdp/types/well-known-tokens"; import { type Address, unwrapOption } from "@solana/kit"; import { fetchSysvarClock } from "@solana/sysvars"; import { @@ -38,16 +40,21 @@ function findUiAmountExtension(extensions: readonly Extension[]): UiAmountExtens /** * Reads a mint's decimals and the extension, if any, that rescales its UI amounts. + * A well-known mint declared on the open scope's cluster answers from the catalogue + * without an RPC read: `pnpm check:well-known-mints` pins its decimals and that it + * carries no UI-amount extension. Any other mint is read from the cluster. * - * @param rpc - The cluster RPC the caller already holds. * @param mint - The mint to read. * @returns The mint's decimals and its UI-amount extension, or null when it has none. */ async function readUiAmountMint( - rpc: SolanaRpc, mint: Address ): Promise<{ decimals: number; uiAmountExtension: UiAmountExtension | null }> { - const mintAccount = await fetchMaybeMint(rpc, mint, { commitment: "confirmed" }); + const wellKnown = WELL_KNOWN_TOKEN_BY_MINT.get(mint); + if (wellKnown?.clusters.includes(currentClusterScope().cluster)) { + return { decimals: wellKnown.decimals, uiAmountExtension: null }; + } + const mintAccount = await fetchMaybeMint(createClusterRpc(), mint, { commitment: "confirmed" }); if (!mintAccount.exists) { throw badRequest("Token mint account does not exist"); } @@ -65,12 +72,11 @@ async function readUiAmountMint( * fixed UI amount drifts with the cluster clock. Token-2022 extensions are fixed * at mint initialization, so a mint admitted here stays admissible. * - * @param rpc - The cluster RPC the caller already holds. * @param mint - The mint the flow is denominated in. * @returns The mint's decimals. */ -export async function readStaticAmountMintDecimals(rpc: SolanaRpc, mint: Address): Promise { - const { decimals, uiAmountExtension } = await readUiAmountMint(rpc, mint); +export async function readStaticAmountMintDecimals(mint: Address): Promise { + const { decimals, uiAmountExtension } = await readUiAmountMint(mint); if (uiAmountExtension !== null) { throw badRequest( "Recurring payments do not support interest-bearing or scaled UI amount tokens" @@ -92,22 +98,18 @@ export async function readStaticAmountMintDecimals(rpc: SolanaRpc, mint: Address * load it once and reuse it, and a funding check and the build it precedes * must share one instance so both see the same raw amount. * - * @param rpc - The cluster RPC the caller already holds. * @param mint - The mint the amounts are denominated in. * @returns A converter from UI decimal strings to base units for that mint. */ -export async function loadPaymentAmountConverter( - rpc: SolanaRpc, - mint: Address -): Promise { - const { decimals, uiAmountExtension } = await readUiAmountMint(rpc, mint); +export async function loadPaymentAmountConverter(mint: Address): Promise { + const { decimals, uiAmountExtension } = await readUiAmountMint(mint); const toStaticBaseUnits: PaymentAmountConverter = (uiAmount) => parseDecimalAmount(uiAmount, decimals); if (uiAmountExtension === null) { return toStaticBaseUnits; } - const clock = await fetchSysvarClock(rpc, { commitment: "confirmed" }); + const clock = await fetchSysvarClock(createClusterRpc(), { commitment: "confirmed" }); switch (uiAmountExtension.__kind) { case "InterestBearingConfig": { const config = uiAmountExtension; diff --git a/scripts/check-well-known-mints.mjs b/scripts/check-well-known-mints.mjs index 2207af3ca7..e58cb58a2b 100644 --- a/scripts/check-well-known-mints.mjs +++ b/scripts/check-well-known-mints.mjs @@ -2,11 +2,16 @@ // Run: `pnpm check:well-known-mints` // // For each token, and each cluster it declares a mint on, this fetches the mint -// account and asserts three things: +// account and asserts four things: // // 1. the account parses as a mint at all (not a token account, not a wallet) // 2. its on-chain `decimals` matches the declared value // 3. its owning program matches the declared `tokenProgram` +// 4. it carries no Token-2022 extension that rescales UI amounts +// (`interestBearingConfig`, `scaledUiAmountConfig`): payment amount +// conversion takes a catalogue mint's decimals without reading the chain, +// which is exact only for a mint whose UI amount is its raw amount scaled +// by decimals. Extensions are fixed at mint initialization, so this holds. // // Those are exactly the properties that are unsafe to take on trust. A spoofed // token shares the real asset's name and symbol, and only the mint address @@ -28,6 +33,9 @@ import { WELL_KNOWN_TOKENS, } from "../packages/sdp-types/src/well-known-tokens.ts"; +// biome-ignore lint/security/noSecrets: Token-2022 extension names, not secrets. +const UI_AMOUNT_EXTENSIONS = ["interestBearingConfig", "scaledUiAmountConfig"]; + const RPC_URLS = { devnet: "https://api.devnet.solana.com", "mainnet-beta": "https://api.mainnet-beta.solana.com", @@ -77,6 +85,13 @@ function findProblems(account, mint, tokenProgram) { problems.push(`declared ${tokenProgram}, chain says owner ${account.owner}`); } + const extensions = account.data?.parsed?.info?.extensions ?? []; + for (const { extension } of extensions) { + if (UI_AMOUNT_EXTENSIONS.includes(extension)) { + problems.push(`carries ${extension}, which rescales UI amounts`); + } + } + return problems; } From beedcbefa31d467d660eaa53ee911880c4c36fb9 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Wed, 21 Oct 2026 18:16:55 +0800 Subject: [PATCH 07/11] test(payments): cover the catalogue decimals read and legacy SPL mints --- .../routes/payments.transfers.onchain.test.ts | 2 - ...nts.transfers.ramp-deposit-funding.test.ts | 13 +--- .../routes/payments/token-accounts.test.ts | 19 +++--- .../transfer-batches/handlers.test.ts | 7 +- .../services/payments/token-amount.test.ts | 66 ++++++++++++++----- apps/sdp-api/src/test/helpers/token-mints.ts | 36 ++++++++-- 6 files changed, 92 insertions(+), 51 deletions(-) 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 ea1bdb6464..b0a4508742 100644 --- a/apps/sdp-api/src/routes/payments.transfers.onchain.test.ts +++ b/apps/sdp-api/src/routes/payments.transfers.onchain.test.ts @@ -43,7 +43,6 @@ import { } from "@/test/helpers/payments-transfers"; import { required } from "@/test/helpers/required"; import { fullySignTestTransaction } from "@/test/helpers/sponsor-signing"; -import { mintAccountInfoResult } from "@/test/helpers/token-mints"; function testProjectPaymentsRepository() { return createPostgresPaymentsRepository( @@ -104,7 +103,6 @@ describe("Payments routes — on-chain transfers", () => { const rpc = installClusterRpcStub(); function answerSourceUsdcTokenAccount(mint: string) { - rpc.answer("getAccountInfo", mintAccountInfoResult({ decimals: 6, extensions: null })); rpc.answer( "getTokenAccountsByOwner", tokenAccountsByOwnerResult(1, [ diff --git a/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts b/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts index 006702969d..95643b71cf 100644 --- a/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts +++ b/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts @@ -1,5 +1,5 @@ import { formatDecimalAmount } from "@sdp/solana/amount"; -import { beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { getDb } from "@/db/client"; import { runWithSystemDatabaseIdentity } from "@/db/identity"; import { generatePaymentTransferId } from "@/db/repositories/payments.repository"; @@ -32,7 +32,6 @@ import { readTransferResponse, readTransferRow, } from "@/test/helpers/payments-transfers"; -import { mintAccountInfoResult } from "@/test/helpers/token-mints"; /** An off-ramp awaiting its 25 USDC deposit to wallet2, as a MoonPay sell leaves it. */ async function seedUsdcOfframpDeposit(transferId: string) { @@ -97,10 +96,6 @@ describe("Payments routes — funding an off-ramp token deposit", () => { installPaymentsRouteTestHooks(); const rpc = installClusterRpcStub(); - beforeEach(() => { - rpc.answer("getAccountInfo", mintAccountInfoResult({ decimals: 6, extensions: null })); - }); - function answerSourceUsdc(baseUnits: string, state: StubTokenAccount["state"]) { rpc.answer( "getTokenAccountsByOwner", @@ -121,10 +116,6 @@ describe("Payments routes — funding an off-ramp token deposit", () => { return rpc.calls.filter((call) => call.method === "getTokenAccountsByOwner").length; } - function mintAccountReads(): number { - return rpc.calls.filter((call) => call.method === "getAccountInfo").length; - } - it("refuses a deposit the wallet cannot cover, leaving the sale open for the same key", async () => { const transferId = generatePaymentTransferId(); await seedUsdcOfframpDeposit(transferId); @@ -148,7 +139,6 @@ describe("Payments routes — funding an off-ramp token deposit", () => { answerSourceUsdc("25000000", "initialized"); const tokenAccountReadsBefore = tokenAccountReads(); const mintReadsBefore = getAccountInfoMock.mock.calls.length; - const mintAccountReadsBefore = mintAccountReads(); const funded = await postTransfer(usdcDepositRequest(transferId), { idempotencyKey }); const fundedText = await funded.text(); @@ -161,7 +151,6 @@ describe("Payments routes — funding an off-ramp token deposit", () => { // The send reuses the source the check read: one read of each, as before the check. expect(tokenAccountReads() - tokenAccountReadsBefore).toBe(1); expect(getAccountInfoMock.mock.calls.length - mintReadsBefore).toBe(1); - expect(mintAccountReads() - mintAccountReadsBefore).toBe(1); }); it("refuses a deposit from a frozen token account before claiming it", async () => { 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 2acef1981f..309ccc7750 100644 --- a/apps/sdp-api/src/routes/payments/token-accounts.test.ts +++ b/apps/sdp-api/src/routes/payments/token-accounts.test.ts @@ -113,15 +113,16 @@ describe("buildSplTransferInstructions", () => { ]) ); - const { transferInstruction } = await buildSplTransferInstructions( - runInClusterScope({ projectId: null, environment: "sandbox" }, () => createClusterRpc()), - { - authority: createNoopSigner(syntheticAddress(13)), - destination: syntheticAddress(14), - mint, - amount: "10", - ataRentPayer: syntheticAddress(15), - } + const { transferInstruction } = await runInClusterScope( + { projectId: null, environment: "sandbox" }, + () => + buildSplTransferInstructions(createClusterRpc(), { + authority: createNoopSigner(syntheticAddress(13)), + destination: syntheticAddress(14), + mint, + amount: "10", + ataRentPayer: syntheticAddress(15), + }) ); expect(transferInstruction.programAddress).toBe(TOKEN_2022_PROGRAM_ADDRESS); 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 8125d28e71..bc139ba995 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 @@ -54,11 +54,7 @@ import { seedDefaultProjects } from "@/test/helpers/projects"; import { required } from "@/test/helpers/required"; import { sendTransactionPreflightError } from "@/test/helpers/rpc-errors"; import { fullySignTestTransaction, TEST_MOCK_FEE_PAYER } from "@/test/helpers/sponsor-signing"; -import { - mintAccountInfoResult, - respondMintAccounts, - scaledUiAmountConfig, -} from "@/test/helpers/token-mints"; +import { respondMintAccounts, scaledUiAmountConfig } from "@/test/helpers/token-mints"; import { type SeededApiKey, seedApiKeyRow, seedTestDatabase } from "@/test/mocks/db"; import { clearKVStores } from "@/test/mocks/kv"; @@ -398,7 +394,6 @@ describe("payment transfer batches", () => { const rpc = installClusterRpcResponder(); function answerSourceTokenAccount(mint: string) { - rpc.answer("getAccountInfo", mintAccountInfoResult({ decimals: 6, extensions: null })); rpc.answer( "getTokenAccountsByOwner", tokenAccountsByOwnerResult(1, [ 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 c05751a710..6517a80fa9 100644 --- a/apps/sdp-api/src/services/payments/token-amount.test.ts +++ b/apps/sdp-api/src/services/payments/token-amount.test.ts @@ -1,17 +1,20 @@ -import { createClusterRpc } from "@sdp/rpc/factory"; import { runInClusterScope } from "@sdp/rpc/scope"; -import type { SolanaRpc } from "@sdp/rpc/solana"; import { AmountError, parseDecimalAmount } from "@sdp/solana/amount"; +import { WELL_KNOWN_TOKENS } from "@sdp/types/well-known-tokens"; +import { address } from "@solana/kit"; import { SYSVAR_CLOCK_ADDRESS } from "@solana/sysvars"; +import { TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; import { type ExtensionArgs, extension, uiAmountToAmountForInterestBearingMintWithoutSimulation, } from "@solana-program/token-2022"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { installClusterRpcResponder } from "@/test/helpers/cluster-rpc"; import { syntheticAddress } from "@/test/helpers/parsed-transactions"; import { + type LegacyStubMint, respondMintAccounts, type StubMint, scaledUiAmountConfig, @@ -40,24 +43,24 @@ const SCALED_BY_TWO_UNTIL_LATER: ExtensionArgs = scaledUiAmountConfig({ newMultiplierEffectiveTimestamp: CLOCK_UNIX_TIMESTAMP + ONE_YEAR_SECONDS, }); -function sandboxRpc(): SolanaRpc { - return runInClusterScope({ projectId: null, environment: "sandbox" }, () => createClusterRpc()); +function inSandboxScope(operation: () => TResult): TResult { + return runInClusterScope({ projectId: null, environment: "sandbox" }, operation); } describe("loadPaymentAmountConverter", () => { const rpc = installClusterRpcResponder(); - function serveMint(mint: StubMint): void { + function serveMint(mint: StubMint | LegacyStubMint): void { respondMintAccounts(rpc, { mints: new Map([[MINT, mint]]), clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP, }); } - function accountInfoReads(): unknown[] { + function accountInfoReads(): string[] { return rpc.requests .filter((request) => request.method === "getAccountInfo") - .map((request) => request.params[0]); + .map((request) => z.string().parse(request.params[0])); } it.each([ @@ -71,17 +74,50 @@ describe("loadPaymentAmountConverter", () => { async ({ extensions }) => { serveMint({ decimals: 6, extensions }); - const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); expect(toBaseUnits("12.345678")).toBe(parseDecimalAmount("12.345678", 6)); expect(accountInfoReads()).toEqual([MINT]); } ); + it("converts a legacy SPL Token mint by its decimals, without reading the clock", async () => { + serveMint({ tokenProgram: TOKEN_PROGRAM_ADDRESS, decimals: 9 }); + + const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); + + expect(toBaseUnits("12.345678912")).toBe(parseDecimalAmount("12.345678912", 9)); + expect(accountInfoReads()).toEqual([MINT]); + }); + + it("converts a well-known mint declared on the scope's cluster by its catalogue decimals, reading nothing", async () => { + const usdc = WELL_KNOWN_TOKENS.USDC.mints.devnet; + + const toBaseUnits = await inSandboxScope(() => + loadPaymentAmountConverter(address(usdc.address)) + ); + + expect(toBaseUnits("12.345678")).toBe(parseDecimalAmount("12.345678", usdc.decimals)); + 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, { + mints: new Map([[usdsMainnetMint, { tokenProgram: TOKEN_PROGRAM_ADDRESS, decimals: 9 }]]), + clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP, + }); + + const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(usdsMainnetMint)); + + expect(toBaseUnits("1")).toBe(parseDecimalAmount("1", 9)); + expect(accountInfoReads()).toEqual([usdsMainnetMint]); + }); + it("divides by the current multiplier before the new one takes effect", async () => { serveMint({ decimals: 6, extensions: [SCALED_BY_TWO_UNTIL_LATER] }); - const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); expect(toBaseUnits("10")).toBe(5_000_000n); expect(accountInfoReads()).toEqual([MINT, SYSVAR_CLOCK_ADDRESS]); @@ -99,7 +135,7 @@ describe("loadPaymentAmountConverter", () => { ], }); - const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); expect(toBaseUnits("10")).toBe(2_500_000n); }); @@ -107,7 +143,7 @@ describe("loadPaymentAmountConverter", () => { it("discounts an interest-bearing mint's accrued interest as of the cluster clock", async () => { serveMint({ decimals: 6, extensions: [INTEREST_BEARING_CONFIG] }); - const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); const baseUnits = toBaseUnits("100"); expect(baseUnits).toBeLessThan(parseDecimalAmount("100", 6)); @@ -132,7 +168,7 @@ describe("loadPaymentAmountConverter", () => { async ({ extensions }) => { serveMint({ decimals: 6, extensions }); - const toBaseUnits = await loadPaymentAmountConverter(sandboxRpc(), MINT); + const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); expect(() => toBaseUnits("1.0000001")).toThrow( new AmountError("Amount has too many decimal places") @@ -143,7 +179,7 @@ describe("loadPaymentAmountConverter", () => { it("answers 400 for a mint with no account", async () => { respondMintAccounts(rpc, { mints: new Map(), clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP }); - await expect(loadPaymentAmountConverter(sandboxRpc(), MINT)).rejects.toMatchObject({ + await expect(inSandboxScope(() => loadPaymentAmountConverter(MINT))).rejects.toMatchObject({ code: "BAD_REQUEST", statusCode: 400, message: "Token mint account does not exist", @@ -164,7 +200,7 @@ describe("readStaticAmountMintDecimals", () => { it("returns a plain mint's decimals", async () => { serveMint({ decimals: 9, extensions: null }); - await expect(readStaticAmountMintDecimals(sandboxRpc(), MINT)).resolves.toBe(9); + await expect(inSandboxScope(() => readStaticAmountMintDecimals(MINT))).resolves.toBe(9); }); it.each([ @@ -173,7 +209,7 @@ describe("readStaticAmountMintDecimals", () => { ])("refuses a $label mint with 400", async ({ extensions }) => { serveMint({ decimals: 6, extensions }); - await expect(readStaticAmountMintDecimals(sandboxRpc(), MINT)).rejects.toMatchObject({ + await expect(inSandboxScope(() => readStaticAmountMintDecimals(MINT))).rejects.toMatchObject({ code: "BAD_REQUEST", statusCode: 400, message: "Recurring payments do not support interest-bearing or scaled UI amount tokens", diff --git a/apps/sdp-api/src/test/helpers/token-mints.ts b/apps/sdp-api/src/test/helpers/token-mints.ts index 25af80d73f..bc743fe17a 100644 --- a/apps/sdp-api/src/test/helpers/token-mints.ts +++ b/apps/sdp-api/src/test/helpers/token-mints.ts @@ -8,6 +8,10 @@ import { unixTimestamp, } from "@solana/kit"; import { getSysvarClockEncoder, SYSVAR_CLOCK_ADDRESS } from "@solana/sysvars"; +import { + getMintEncoder as getLegacyMintEncoder, + type TOKEN_PROGRAM_ADDRESS, +} from "@solana-program/token"; import { type ExtensionArgs, extension, @@ -28,6 +32,12 @@ export interface StubMint { extensions: ExtensionArgs[] | null; } +/** A legacy SPL Token mint as a stubbed `getAccountInfo` serves it: 82 bytes, no extensions. */ +export interface LegacyStubMint { + tokenProgram: typeof TOKEN_PROGRAM_ADDRESS; + decimals: number; +} + /** * A base64 `getAccountInfo` result for an account holding `data`. * @@ -75,13 +85,25 @@ export function scaledUiAmountConfig(scale: { } /** - * A base64 `getAccountInfo` result for a Token-2022 mint, encoded by the token program's own - * mint encoder. + * A base64 `getAccountInfo` result for a Token-2022 or legacy SPL Token mint, encoded by its + * owning program's own mint encoder. * - * @param mint - The mint's decimals and extensions. + * @param mint - The mint's decimals, and its extensions or legacy owner. * @returns The JSON-RPC `result` to pass to `answer("getAccountInfo", …)`. */ -export function mintAccountInfoResult(mint: StubMint): JsonRpcResult { +export function mintAccountInfoResult(mint: StubMint | LegacyStubMint): JsonRpcResult { + if ("tokenProgram" in mint) { + return base64AccountInfoResult({ + owner: mint.tokenProgram, + data: getLegacyMintEncoder().encode({ + mintAuthority: none(), + supply: 0n, + decimals: mint.decimals, + isInitialized: true, + freezeAuthority: none(), + }), + }); + } return base64AccountInfoResult({ owner: TOKEN_2022_PROGRAM_ADDRESS, data: getMintEncoder().encode({ @@ -116,8 +138,8 @@ function clockAccountInfoResult(clockUnixTimestamp: bigint): JsonRpcResult { /** * Answer every `getAccountInfo` of the current test by the address it asks for: each of `mints` - * as a Token-2022 mint, the Clock sysvar at `clockUnixTimestamp`, and any other address as no - * account. + * as its Token-2022 or legacy SPL Token mint, the Clock sysvar at `clockUnixTimestamp`, and any + * other address as no account. * * @param rpc - The suite's cluster RPC responder. * @param params.mints - The mints the cluster holds, by address. @@ -125,7 +147,7 @@ function clockAccountInfoResult(clockUnixTimestamp: bigint): JsonRpcResult { */ export function respondMintAccounts( rpc: ClusterRpcResponder, - params: { mints: ReadonlyMap; clockUnixTimestamp: bigint } + params: { mints: ReadonlyMap; clockUnixTimestamp: bigint } ): void { rpc.respond("getAccountInfo", (request) => { const accountAddress = z.string().parse(request.params[0]); From e68afdef39e71d75379727635a6b8fe0895f3831 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Wed, 21 Oct 2026 20:27:13 +0800 Subject: [PATCH 08/11] fix(payments): reject lossy mint amounts and enforce u64 precision --- .../src/routes/payments.recurring.test.ts | 42 ++-- .../routes/payments/token-accounts.test.ts | 91 ++++---- .../src/routes/payments/token-accounts.ts | 46 ++-- .../transfer-batches/handlers.test.ts | 19 +- .../payments/transfer-batches/resolve.ts | 34 ++- .../src/routes/payments/transfers/handlers.ts | 21 +- .../payments/payment-request-sponsorship.ts | 4 +- .../payments/recurring-payments/activation.ts | 14 +- .../payments/recurring-payments/collection.ts | 20 +- .../payments/recurring-payments/shared.ts | 12 +- .../payments/recurring-payments/update.ts | 14 +- .../services/payments/token-amount.test.ts | 116 +++++----- .../src/services/payments/token-amount.ts | 201 +++++++----------- packages/sdp-solana/src/amount.test.ts | 12 ++ packages/sdp-solana/src/amount.ts | 14 +- scripts/check-well-known-mints.mjs | 16 +- 16 files changed, 306 insertions(+), 370 deletions(-) diff --git a/apps/sdp-api/src/routes/payments.recurring.test.ts b/apps/sdp-api/src/routes/payments.recurring.test.ts index 23af78a336..22f477bcbd 100644 --- a/apps/sdp-api/src/routes/payments.recurring.test.ts +++ b/apps/sdp-api/src/routes/payments.recurring.test.ts @@ -48,6 +48,7 @@ import { collectDueRecurringPayments } from "@/services/jobs/collect-recurring-p import { cancelRecurringPaymentsActivatedByKey } from "@/services/payments/recurring-payments/cancel-activated-by-key"; import { skipRefusedRecurringCollectionPeriod } from "@/services/payments/recurring-payments/collection"; import * as recurringShared from "@/services/payments/recurring-payments/shared"; +import * as paymentAmounts from "@/services/payments/token-amount"; import { TEST_SOLANA_ADDRESSES } from "@/test/fixtures/tokens"; import { authenticateTestClerkUser } from "@/test/helpers/clerk"; import { clusterOfRpcClient } from "@/test/helpers/cluster-rpc"; @@ -879,7 +880,7 @@ describe("Payments routes — recurring", () => { const scaledRes = await createRecurring(scaledMint); expect(scaledRes.status).toBe(400); expect(errorResponseSchema.parse(await scaledRes.json()).error.message).toBe( - "Recurring payments do not support interest-bearing or scaled UI amount tokens" + "Payments do not support the ScaledUiAmountConfig mint extension" ); const issuedRes = await createRecurring(issuedMint); @@ -2979,17 +2980,14 @@ describe("Payments routes — recurring", () => { const mintLookupReleased = new Promise((resolve) => { releaseMintLookup = resolve; }); - const getAccountInfo = getAccountInfoMock.getMockImplementation(); - if (!getAccountInfo) { - throw new Error("Account info mock is not installed"); - } - getAccountInfoMock.mockImplementationOnce(async (...args) => { - if (signalMintLookupReached) { - signalMintLookupReached(); - } - await mintLookupReleased; - return getAccountInfo(...args); - }); + const loadPaymentMint = paymentAmounts.loadPaymentMint; + const mintLookup = vi + .spyOn(paymentAmounts, "loadPaymentMint") + .mockImplementationOnce(async (mint) => { + if (signalMintLookupReached) signalMintLookupReached(); + await mintLookupReleased; + return loadPaymentMint(mint); + }); const preparePromise = app.request( `/v1/payments/subscriptions/${subscriptionId}/prepare-authorization`, @@ -3021,6 +3019,7 @@ describe("Payments routes — recurring", () => { } const prepareRes = await preparePromise; + mintLookup.mockRestore(); expect(prepareRes.status).toBe(409); const persisted = await repo.getSubscriptionById({ subscriptionId, @@ -3986,13 +3985,9 @@ describe("Payments routes — recurring", () => { const activated = await activateProductionUsdcSchedule(); expect(activated.token).toBe(MAINNET_USDC_MINT); - expect(recurringExecution.rpc.calls).toContainEqual({ - cluster: "mainnet-beta", - method: "getTokenSupply", - }); - expect(new Set(recurringExecution.rpc.calls.map((call) => call.cluster))).toEqual( - new Set(["mainnet-beta"]) - ); + expect( + recurringExecution.rpc.calls.filter((call) => call.method === "getTokenSupply") + ).toEqual([]); expect( new Set( [ @@ -4026,14 +4021,7 @@ describe("Payments routes — recurring", () => { const tokenSupplyReads = recurringExecution.rpc.requests .slice(requestsBefore) .filter((request) => request.method === "getTokenSupply"); - const clustersReadingMint = (mint: string) => - new Set( - tokenSupplyReads - .filter((request) => request.params[0] === mint) - .map((request) => request.cluster) - ); - expect(clustersReadingMint(DEVNET_USDC_MINT)).toEqual(new Set(["devnet"])); - expect(clustersReadingMint(MAINNET_USDC_MINT)).toEqual(new Set(["mainnet-beta"])); + expect(tokenSupplyReads).toEqual([]); const clustersConfirming = (collectionSignature: string) => new Set( confirmTransactionMock.mock.calls 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 309ccc7750..f9cb3d16db 100644 --- a/apps/sdp-api/src/routes/payments/token-accounts.test.ts +++ b/apps/sdp-api/src/routes/payments/token-accounts.test.ts @@ -79,55 +79,64 @@ describe("getSplTokenBalances", () => { describe("buildSplTransferInstructions", () => { const rpc = installClusterRpcResponder(); - it("debits a scaled UI amount mint's requested amount, not its static base units", async () => { - const mint = syntheticAddress(11); - respondMintAccounts(rpc, { - mints: new Map([ - [ - mint, + it.each([false, true])( + "builds exact amounts or rejects scaled mints (scaled=%s)", + async (scaled) => { + const mint = syntheticAddress(11); + respondMintAccounts(rpc, { + mints: new Map([ + [ + mint, + { + decimals: 6, + extensions: scaled + ? [ + scaledUiAmountConfig({ + multiplier: 2, + newMultiplier: 4, + newMultiplierEffectiveTimestamp: 1_800_000_000n, + }), + ] + : null, + }, + ], + ]), + clockUnixTimestamp: 1_750_000_000n, + }); + rpc.answer( + "getTokenAccountsByOwner", + tokenAccountsByOwnerResult(1, [ { - decimals: 6, - extensions: [ - scaledUiAmountConfig({ - multiplier: 2, - newMultiplier: 4, - newMultiplierEffectiveTimestamp: 1_800_000_000n, - }), - ], + pubkey: syntheticAddress(12), + mint, + amount: "20000000", + decimals: 9, + uiAmountString: "40", + state: "initialized", }, - ], - ]), - clockUnixTimestamp: 1_750_000_000n, - }); - rpc.answer( - "getTokenAccountsByOwner", - tokenAccountsByOwnerResult(1, [ - { - pubkey: syntheticAddress(12), - mint, - amount: "20000000", - decimals: 6, - uiAmountString: "40", - state: "initialized", - }, - ]) - ); + ]) + ); - const { transferInstruction } = await runInClusterScope( - { projectId: null, environment: "sandbox" }, - () => + const result = runInClusterScope({ projectId: null, environment: "sandbox" }, () => buildSplTransferInstructions(createClusterRpc(), { authority: createNoopSigner(syntheticAddress(13)), destination: syntheticAddress(14), mint, - amount: "10", + amount: "9007199254.740993", ataRentPayer: syntheticAddress(15), }) - ); + ); - expect(transferInstruction.programAddress).toBe(TOKEN_2022_PROGRAM_ADDRESS); - expect( - getTransferCheckedInstructionDataDecoder().decode(transferInstruction.data) - ).toMatchObject({ amount: 5_000_000n, decimals: 6 }); - }); + if (scaled) { + await expect(result).rejects.toMatchObject({ code: "BAD_REQUEST", statusCode: 400 }); + return; + } + const { transferInstruction } = await result; + expect(rpc.requests.filter((request) => request.method === "getAccountInfo")).toHaveLength(1); + expect(transferInstruction.programAddress).toBe(TOKEN_2022_PROGRAM_ADDRESS); + expect( + getTransferCheckedInstructionDataDecoder().decode(transferInstruction.data) + ).toMatchObject({ amount: 9007199254740993n, decimals: 6 }); + } + ); }); diff --git a/apps/sdp-api/src/routes/payments/token-accounts.ts b/apps/sdp-api/src/routes/payments/token-accounts.ts index 353361e00f..b54e0844ab 100644 --- a/apps/sdp-api/src/routes/payments/token-accounts.ts +++ b/apps/sdp-api/src/routes/payments/token-accounts.ts @@ -15,8 +15,9 @@ import { } from "@solana-program/token-2022"; import { badRequest } from "@/lib/errors"; import { - loadPaymentAmountConverter, + loadPaymentMint, type PaymentAmountConverter, + type PaymentMint, } from "@/services/payments/token-amount"; const SPL_TOKEN_PROGRAM_ID = address(SPL_TOKEN_PROGRAMS["spl-token"]); @@ -74,17 +75,6 @@ export function withIssuedTokenLabels }); } -export async function resolveMintDecimals(rpc: SolanaRpc, mint: Address): Promise { - const response = await rpc.getTokenSupply(mint, { commitment: "confirmed" }).send(); - const decimals = response.value.decimals; - - if (typeof decimals !== "number" || !Number.isInteger(decimals) || decimals < 0) { - throw badRequest("Token mint decimals could not be resolved"); - } - - return decimals; -} - function parseTokenAmountInfo(info: JsonParsedTokenAccount): { mint: Address; amount: bigint; @@ -223,21 +213,18 @@ export async function resolveSourceTokenAccountOrAta( rpc: SolanaRpc, owner: Address, mint: Address, - tokenProgram: Address + paymentMint: PaymentMint ): Promise<{ tokenAccount: Address; decimals: number; exists: boolean }> { const [tokenAccount] = await findAssociatedTokenPda({ owner, - tokenProgram, + tokenProgram: paymentMint.tokenProgram, mint, }); - const [tokenAccountInfo, decimals] = await Promise.all([ - getAccountInfo(rpc, tokenAccount), - resolveMintDecimals(rpc, mint), - ]); + const tokenAccountInfo = await getAccountInfo(rpc, tokenAccount); return { tokenAccount, - decimals, + decimals: paymentMint.decimals, exists: tokenAccountInfo !== null, }; } @@ -259,13 +246,20 @@ export async function buildSplTransferInstructions( source?: SplTransferSource; } ) { - const tokenProgram = - input.source?.tokenProgram ?? (await resolveMintTokenProgram(rpc, input.mint)); - const sourceTokenAccount = - input.source ?? - (await resolveSourceTokenAccount(rpc, input.authority.address, input.mint, tokenProgram)); - const toBaseUnits = input.source?.toBaseUnits ?? (await loadPaymentAmountConverter(input.mint)); - const transferAmount = toBaseUnits(input.amount); + 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 + ); + source = { ...account, ...mint }; + } + const { tokenProgram } = source; + const sourceTokenAccount = source; + const transferAmount = source.toBaseUnits(input.amount); if (transferAmount <= 0n) { throw badRequest("Transfer amount must be greater than zero"); } 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 bc139ba995..34e0a36986 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 @@ -1947,7 +1947,7 @@ describe("payment transfer batches", () => { } ); - it("refuses a scaled UI amount recipient that converts to zero base units before signing anything", async () => { + it("refuses a scaled UI mint before storing or signing a batch", async () => { const mint = syntheticAddress(22); getAccountInfoMock.mockImplementationOnce( async (client, account) => @@ -2003,7 +2003,10 @@ describe("payment transfer batches", () => { expect(res.status).toBe(400); expect(await res.json()).toMatchObject({ - error: { code: "BAD_REQUEST", message: "Recipient amount must be greater than zero" }, + error: { + code: "BAD_REQUEST", + message: "Payments do not support the ScaledUiAmountConfig mint extension", + }, }); expect(createOrgSignerForCustodyWalletMock).not.toHaveBeenCalled(); expect(sendTransactionMock).not.toHaveBeenCalled(); @@ -2018,7 +2021,7 @@ describe("payment transfer batches", () => { expect(count).toEqual({ count: 0 }); }); - it("moves a scaled UI amount mint's converted base units while recipients keep their requested amount", async () => { + it("moves exact base units on a plain Token-2022 mint", async () => { const sourceSigner = await generateKeyPairSigner(); await updateSeededWalletPublicKey(sourceSigner.address); createOrgSignerForCustodyWalletMock.mockResolvedValueOnce(sourceSigner); @@ -2038,13 +2041,7 @@ describe("payment transfer batches", () => { mint, { decimals: 6, - extensions: [ - scaledUiAmountConfig({ - multiplier: 2, - newMultiplier: 4, - newMultiplierEffectiveTimestamp: 1_800_000_000n, - }), - ], + extensions: null, }, ], ]), @@ -2095,7 +2092,7 @@ describe("payment transfer batches", () => { ? [getTransferCheckedInstructionDataDecoder().decode(instruction.data).amount] : [] ); - expect(transferCheckedAmounts).toEqual([5_000_000n, 1_250_000n]); + expect(transferCheckedAmounts).toEqual([10_000_000n, 2_500_000n]); const stored = await getDb(env) .prepare( `SELECT r.destination_address, r.amount, b.total_amount 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 e6e7865828..70356ebe6e 100644 --- a/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts +++ b/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts @@ -1,17 +1,14 @@ import { sumDecimalAmounts } from "@sdp/payments/decimal"; import { createClusterRpc } from "@sdp/rpc/factory"; import { assertValidAddress } from "@sdp/solana/address"; -import { AmountError, formatDecimalAmount, parseDecimalAmount } from "@sdp/solana/amount"; +import { AmountError, formatDecimalAmount, parseTokenAmount } from "@sdp/solana/amount"; import { SOL_DECIMALS, SOL_MINT } from "@sdp/types/well-known-tokens"; import type { Address } from "@solana/kit"; import type { CounterpartyAccountRow } from "@/db/repositories/counterparty-account.repository"; import { requireProjectId } from "@/lib/auth"; import { badRequest, notFound } from "@/lib/errors"; import { type AppContext, getCounterpartyAccountsRepository } from "@/routes/payments/context"; -import { - resolveMintTokenProgram, - resolveSourceTokenAccount, -} from "@/routes/payments/token-accounts"; +import { resolveSourceTokenAccount } from "@/routes/payments/token-accounts"; import { type ResolvedScope, resolveScope, @@ -23,10 +20,7 @@ import { isNativePaymentToken, normalizePaymentToken, } from "@/services/payment-operation.service"; -import { - loadPaymentAmountConverter, - type PaymentAmountConverter, -} from "@/services/payments/token-amount"; +import { loadPaymentMint, type PaymentAmountConverter } from "@/services/payments/token-amount"; import type { CustodyWallet } from "@/services/stores/custody-config.store"; import type { CreateTransferBatchInput, @@ -39,9 +33,7 @@ import type { /** * Parses a recipient amount into token base units, mapping amount-format - * violations to a 400 response. A positive UI amount can still convert to zero - * base units on an interest-bearing or scaled mint; that is refused too, so no - * recipient becomes a sponsored transfer that moves nothing. + * violations to a 400 response. Every recipient must move positive base units. * * @param amount - Decimal amount string from the request body. * @param toBaseUnits - The batch token's converter from UI amounts to base units. @@ -81,21 +73,23 @@ async function resolveTokenContext( kind: "sol", token: SOL_MINT, decimals: SOL_DECIMALS, - toBaseUnits: (amount) => parseDecimalAmount(amount, SOL_DECIMALS), + toBaseUnits: (amount) => parseTokenAmount(amount, SOL_DECIMALS), }; } const mintAddress = assertValidAddress(token, "token"); - const tokenProgram = await resolveMintTokenProgram(rpc, mintAddress); - const [sourceTokenAccount, toBaseUnits] = await Promise.all([ - resolveSourceTokenAccount(rpc, sourceAddress, mintAddress, tokenProgram), - loadPaymentAmountConverter(mintAddress), - ]); + const { tokenProgram, decimals, toBaseUnits } = await loadPaymentMint(mintAddress); + const sourceTokenAccount = await resolveSourceTokenAccount( + rpc, + sourceAddress, + mintAddress, + tokenProgram + ); return { kind: "spl", token, - decimals: sourceTokenAccount.decimals, + decimals, mintAddress, tokenProgram, sourceTokenAccount: sourceTokenAccount.tokenAccount, @@ -158,7 +152,7 @@ function resolveRecipients(params: { counterpartyId: account.counterparty_id, counterpartyAccountId: recipient.counterpartyAccountId, destinationAddress: readCryptoWalletAddress(account, index), - amount: formatDecimalAmount(parseDecimalAmount(recipient.amount, decimals), decimals), + amount: formatDecimalAmount(parseTokenAmount(recipient.amount, decimals), decimals), amountBaseUnits, }; }); diff --git a/apps/sdp-api/src/routes/payments/transfers/handlers.ts b/apps/sdp-api/src/routes/payments/transfers/handlers.ts index bad2056877..7217a8dbd3 100644 --- a/apps/sdp-api/src/routes/payments/transfers/handlers.ts +++ b/apps/sdp-api/src/routes/payments/transfers/handlers.ts @@ -8,7 +8,7 @@ import { type SolanaRpc, } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; -import { formatDecimalAmount, parseDecimalAmount } from "@sdp/solana/amount"; +import { formatDecimalAmount, parseTokenAmount } from "@sdp/solana/amount"; import type { Action } from "@sdp/types/actions"; import { isRampTransferType, @@ -82,7 +82,6 @@ import { mapTransferRow } from "@/routes/payments/mappers"; import { transferIdParamsSchema } from "@/routes/payments/schemas"; import { buildSplTransferInstructions, - resolveMintTokenProgram, resolveSourceTokenAccount, resolveTokenLabel, type SplTransferSource, @@ -104,7 +103,7 @@ import { resolveOutboundPaymentOperation, } from "@/services/payment-operation.service"; import { createTransferSignedSubmissionStore } from "@/services/payments/signed-submission"; -import { loadPaymentAmountConverter } from "@/services/payments/token-amount"; +import { loadPaymentMint } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -259,11 +258,15 @@ async function assertRampDepositFunded( if (isNativePaymentToken(operation.token)) return undefined; const rpc = createClusterRpc(); const mint = assertValidAddress(operation.token, "token"); - const tokenProgram = await resolveMintTokenProgram(rpc, mint); - const [source, toBaseUnits] = await Promise.all([ - resolveSourceTokenAccount(rpc, address(operation.sourceWallet.publicKey), mint, tokenProgram), - loadPaymentAmountConverter(mint), - ]); + const paymentMint = await loadPaymentMint(mint); + const { tokenProgram, toBaseUnits } = paymentMint; + const account = await resolveSourceTokenAccount( + rpc, + address(operation.sourceWallet.publicKey), + mint, + tokenProgram + ); + const source = { ...account, ...paymentMint }; const needed = toBaseUnits(operation.amount); if (!source.frozen && source.amount >= needed) return { ...source, tokenProgram, toBaseUnits }; @@ -730,7 +733,7 @@ async function executeSolTransfer( amount: string, submissionStore: SignedSubmissionStore ): Promise<{ signature: string; slot: number | null; blockTime: string | null }> { - const lamports = parseDecimalAmount(amount, 9); + const lamports = parseTokenAmount(amount, 9); if (lamports <= 0n) { throw badRequest("Transfer amount must be greater than zero"); } 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 b49f199a95..8455226a0d 100644 --- a/apps/sdp-api/src/services/payments/payment-request-sponsorship.ts +++ b/apps/sdp-api/src/services/payments/payment-request-sponsorship.ts @@ -2,7 +2,7 @@ import { hashString } from "@sdp/payments/hash"; import { createClusterRpc } from "@sdp/rpc/factory"; import { getRecentBlockhash, type SolanaRpc } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; -import { parseDecimalAmount } from "@sdp/solana/amount"; +import { parseTokenAmount } from "@sdp/solana/amount"; import { SOL_DECIMALS, SOL_MINT } from "@sdp/types/well-known-tokens"; import { type AccountMeta, @@ -233,7 +233,7 @@ async function buildPaymentTransaction( let instructions: Instruction[]; if (request.token === SOL_MINT) { - const lamports = parseDecimalAmount(request.amount, SOL_DECIMALS); + const lamports = parseTokenAmount(request.amount, SOL_DECIMALS); if (lamports <= 0n) { throw badRequest("Transfer amount must be greater than zero"); } 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 6a942a6c63..1e303f4c11 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/activation.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/activation.ts @@ -7,7 +7,6 @@ import { import { createClusterRpc } from "@sdp/rpc/factory"; import type { SolanaRpc } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; -import { parseDecimalAmount } from "@sdp/solana/amount"; import { IN_FLIGHT_RECURRING_PAYMENT_ATTEMPT_STATUSES, isActivatingRecurringPaymentStatus, @@ -41,13 +40,11 @@ import { import { AppError, badRequest, conflict, internalError, transactionFailed } from "@/lib/errors"; import { currentMandateActivator } from "@/lib/money-principal"; 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 { createSigningService } from "@/services/domain/signing.service"; import { parseU64String } from "@/services/payment-operation.service"; +import { loadPaymentMint } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -744,9 +741,10 @@ export async function activateRecurringPayment(input: { if (sourceSigner.address !== input.sourceWallet.publicKey) { throw badRequest("Resolved signing wallet does not match source wallet"); } - const tokenProgram = await resolveMintTokenProgram(rpc, mint); - const sourceTokenAccount = await resolveSourceTokenAccountOrAta(rpc, owner, mint, tokenProgram); - const amountBaseUnits = parseDecimalAmount(claimed.amount, sourceTokenAccount.decimals); + const paymentMint = await loadPaymentMint(mint); + const { tokenProgram } = paymentMint; + const sourceTokenAccount = await resolveSourceTokenAccountOrAta(rpc, owner, mint, paymentMint); + const amountBaseUnits = paymentMint.toBaseUnits(claimed.amount); if (amountBaseUnits <= 0n) { throw badRequest("Subscription amount must be greater than zero"); 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 4c73c84cb5..b817dcee05 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/collection.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/collection.ts @@ -7,7 +7,6 @@ import { import { createClusterRpc } from "@sdp/rpc/factory"; import { getTransaction, type ParsedInstruction, type SolanaRpc } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; -import { parseDecimalAmount } from "@sdp/solana/amount"; import { COLLECTION_ATTEMPT_STATUSES_BLOCKING_NEW_CYCLE, COLLECTION_ATTEMPT_STATUSES_WITH_SUBMITTED_TRANSFER, @@ -73,7 +72,6 @@ import { import type { MoneyMovementRefusedError } from "@/lib/money-admission"; import { createTenantScope } from "@/lib/tenant-scope"; import { - resolveMintDecimals, resolveMintTokenProgram, resolveSourceTokenAccountOrAta, } from "@/routes/payments/token-accounts"; @@ -81,6 +79,7 @@ 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 { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -493,11 +492,8 @@ async function verifyRecurringPaymentCollection(input: { const rpc = createClusterRpc(); const mint = assertValidAddress(input.recurringPayment.token, "token"); - const [tokenProgram, decimals] = await Promise.all([ - resolveMintTokenProgram(rpc, mint), - resolveMintDecimals(rpc, mint), - ]); - const amountBaseUnits = parseDecimalAmount(input.recurringPayment.amount, decimals); + const { tokenProgram, toBaseUnits } = await loadPaymentMint(mint); + const amountBaseUnits = toBaseUnits(input.recurringPayment.amount); const confirmedTransaction = await getTransaction(rpc, input.signature); if (!confirmedTransaction) { throw solanaRpcError( @@ -1738,17 +1734,15 @@ export async function collectRecurringPayment(input: { throw badRequest("Resolved signing wallet does not match source wallet"); } - const tokenProgram = await resolveMintTokenProgram(rpc, mint); + const paymentMint = await loadPaymentMint(mint); + const { tokenProgram } = paymentMint; const sourceTokenAccount = await resolveSourceTokenAccountOrAta( rpc, sourceOwner, mint, - tokenProgram - ); - const amountBaseUnits = parseDecimalAmount( - input.recurringPayment.amount, - sourceTokenAccount.decimals + 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/shared.ts b/apps/sdp-api/src/services/payments/recurring-payments/shared.ts index 2eacdb25e5..e005d159ac 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/shared.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/shared.ts @@ -6,7 +6,6 @@ import { type SolanaRpc, } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; -import { parseDecimalAmount } from "@sdp/solana/amount"; import type { ApiKeyWalletScope } from "@sdp/types/api-keys"; import type { MovementId } from "@sdp/types/movements"; import type { PreparedPaymentSubscriptionTransaction } from "@sdp/types/payments"; @@ -53,7 +52,6 @@ import { import { assertMovementAdmitted, decidePrincipal } from "@/lib/money-admission"; import type { currentMandateActivator } from "@/lib/money-principal"; import { createTenantScope } from "@/lib/tenant-scope"; -import { resolveMintTokenProgram } from "@/routes/payments/token-accounts"; import { getLogger } from "@/runtime/logger"; import { parseGrantsColumn } from "@/services/api-key.service"; import { @@ -61,7 +59,7 @@ import { normalizePaymentToken, parseU64String, } from "@/services/payment-operation.service"; -import { readStaticAmountMintDecimals } from "@/services/payments/token-amount"; +import { loadPaymentMint, readStaticAmountMintDecimals } from "@/services/payments/token-amount"; import { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -134,12 +132,8 @@ export async function resolvePlanRuntime( plan: PaymentSubscriptionPlanRow ): Promise<{ amountBaseUnits: bigint; mint: Address; tokenProgram: Address }> { const mint = assertSubscriptionTokenMint(plan.token); - const rpc = createClusterRpc(); - const [tokenProgram, decimals] = await Promise.all([ - resolveMintTokenProgram(rpc, mint), - readStaticAmountMintDecimals(mint), - ]); - const amountBaseUnits = parseDecimalAmount(plan.amount, decimals); + const { tokenProgram, toBaseUnits } = await loadPaymentMint(mint); + const amountBaseUnits = toBaseUnits(plan.amount); if (amountBaseUnits <= 0n) { throw badRequest("Subscription amount must be greater than zero"); 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 d37647dff9..6e78a94a81 100644 --- a/apps/sdp-api/src/services/payments/recurring-payments/update.ts +++ b/apps/sdp-api/src/services/payments/recurring-payments/update.ts @@ -7,7 +7,6 @@ import { import { createClusterRpc } from "@sdp/rpc/factory"; import type { SolanaRpc } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; -import { parseDecimalAmount } from "@sdp/solana/amount"; import { assertNever, COLLECTION_ATTEMPT_STATUSES_BLOCKING_NEW_CYCLE, @@ -62,14 +61,12 @@ import { import { normalizeDecimalString } from "@/lib/idempotency"; import { currentMandateActivator } from "@/lib/money-principal"; 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 { 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 { createOrgSignerForCustodyWallet } from "@/services/solana/signer"; import { createSponsorshipFeePayment, @@ -1453,9 +1450,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 tokenProgram = await resolveMintTokenProgram(rpc, mint); - const sourceTokenAccount = await resolveSourceTokenAccountOrAta(rpc, owner, mint, tokenProgram); - const amountBaseUnits = parseDecimalAmount(input.resolved.amount, sourceTokenAccount.decimals); + const paymentMint = await loadPaymentMint(mint); + const { tokenProgram } = paymentMint; + const sourceTokenAccount = await resolveSourceTokenAccountOrAta(rpc, owner, mint, paymentMint); + const amountBaseUnits = paymentMint.toBaseUnits(input.resolved.amount); if (amountBaseUnits <= 0n) { throw badRequest("Subscription amount must be greater than zero"); } 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 6517a80fa9..e5aead86a2 100644 --- a/apps/sdp-api/src/services/payments/token-amount.test.ts +++ b/apps/sdp-api/src/services/payments/token-amount.test.ts @@ -1,14 +1,9 @@ import { runInClusterScope } from "@sdp/rpc/scope"; -import { AmountError, parseDecimalAmount } from "@sdp/solana/amount"; +import { parseDecimalAmount } from "@sdp/solana/amount"; import { WELL_KNOWN_TOKENS } from "@sdp/types/well-known-tokens"; -import { address } from "@solana/kit"; -import { SYSVAR_CLOCK_ADDRESS } from "@solana/sysvars"; +import { address, getBase64Decoder, none } from "@solana/kit"; import { TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; -import { - type ExtensionArgs, - extension, - uiAmountToAmountForInterestBearingMintWithoutSimulation, -} from "@solana-program/token-2022"; +import { type ExtensionArgs, extension, getMintEncoder } from "@solana-program/token-2022"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import { installClusterRpcResponder } from "@/test/helpers/cluster-rpc"; @@ -114,68 +109,72 @@ describe("loadPaymentAmountConverter", () => { expect(accountInfoReads()).toEqual([usdsMainnetMint]); }); - it("divides by the current multiplier before the new one takes effect", async () => { - serveMint({ decimals: 6, extensions: [SCALED_BY_TWO_UNTIL_LATER] }); - - const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); - - expect(toBaseUnits("10")).toBe(5_000_000n); - expect(accountInfoReads()).toEqual([MINT, SYSVAR_CLOCK_ADDRESS]); - }); - - it("divides by the new multiplier from its effective timestamp on", async () => { - serveMint({ - decimals: 6, - extensions: [ - scaledUiAmountConfig({ - multiplier: 2, - newMultiplier: 4, - newMultiplierEffectiveTimestamp: CLOCK_UNIX_TIMESTAMP, - }), - ], - }); - - const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); - - expect(toBaseUnits("10")).toBe(2_500_000n); - }); - - it("discounts an interest-bearing mint's accrued interest as of the cluster clock", async () => { - serveMint({ decimals: 6, extensions: [INTEREST_BEARING_CONFIG] }); - - const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); - - const baseUnits = toBaseUnits("100"); - expect(baseUnits).toBeLessThan(parseDecimalAmount("100", 6)); - expect(baseUnits).toBe( - uiAmountToAmountForInterestBearingMintWithoutSimulation( - "100", - 6, - Number(CLOCK_UNIX_TIMESTAMP), - Number(INTEREST_RATE_UPDATED_AT), - Number(INTEREST_INITIALIZED_AT), - PRE_UPDATE_AVERAGE_RATE_BPS, - CURRENT_RATE_BPS - ) - ); - }); - it.each([ { label: "scaled UI amount", extensions: [SCALED_BY_TWO_UNTIL_LATER] }, { label: "interest-bearing", extensions: [INTEREST_BEARING_CONFIG] }, + { label: "non-transferable", extensions: [extension("NonTransferable", {})] }, ])( - "refuses a $label amount finer than the mint's decimals as a plain mint does", + "rejects a $label mint before reading the clock or converting amounts", async ({ extensions }) => { serveMint({ decimals: 6, extensions }); + await expect(inSandboxScope(() => loadPaymentAmountConverter(MINT))).rejects.toMatchObject({ + code: "BAD_REQUEST", + statusCode: 400, + }); + expect(accountInfoReads()).toEqual([MINT]); + } + ); - const toBaseUnits = await inSandboxScope(() => loadPaymentAmountConverter(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)); + expect(convert("9007199254.740993")).toBe(9007199254740993n); + expect(convert("18446744073709.551615")).toBe(18446744073709551615n); + }); - expect(() => toBaseUnits("1.0000001")).toThrow( - new AmountError("Amount has too many decimal places") + it.each(["1.0000001", "18446744073709.551616"])( + "rejects %s with 400 instead of rounding or overflowing", + async (amount) => { + serveMint({ decimals: 6, extensions: null }); + const convert = await inSandboxScope(() => loadPaymentAmountConverter(MINT)); + expect(() => convert(amount)).toThrow( + expect.objectContaining({ code: "BAD_REQUEST", statusCode: 400 }) ); } ); + it.each(["wrong owner", "uninitialized", "malformed"])( + "rejects a %s mint account", + async (kind) => { + const data = + kind === "malformed" + ? new Uint8Array(3) + : getMintEncoder().encode({ + mintAuthority: none(), + supply: 0n, + decimals: 6, + isInitialized: kind !== "uninitialized", + freezeAuthority: none(), + extensions: none(), + }); + rpc.answer("getAccountInfo", { + context: { slot: 1 }, + value: { + data: [getBase64Decoder().decode(data), "base64"], + executable: false, + lamports: 1, + owner: kind === "wrong owner" ? syntheticAddress(99) : TOKEN_PROGRAM_ADDRESS, + rentEpoch: 0, + space: data.length, + }, + }); + await expect(inSandboxScope(() => loadPaymentAmountConverter(MINT))).rejects.toMatchObject({ + code: "BAD_REQUEST", + statusCode: 400, + }); + } + ); + it("answers 400 for a mint with no account", async () => { respondMintAccounts(rpc, { mints: new Map(), clockUnixTimestamp: CLOCK_UNIX_TIMESTAMP }); @@ -212,7 +211,6 @@ describe("readStaticAmountMintDecimals", () => { await expect(inSandboxScope(() => readStaticAmountMintDecimals(MINT))).rejects.toMatchObject({ code: "BAD_REQUEST", statusCode: 400, - message: "Recurring payments do not support interest-bearing or scaled UI amount tokens", }); }); }); diff --git a/apps/sdp-api/src/services/payments/token-amount.ts b/apps/sdp-api/src/services/payments/token-amount.ts index 223258b772..5bcb246ed7 100644 --- a/apps/sdp-api/src/services/payments/token-amount.ts +++ b/apps/sdp-api/src/services/payments/token-amount.ts @@ -1,149 +1,98 @@ import { createClusterRpc } from "@sdp/rpc/factory"; import { currentClusterScope } from "@sdp/rpc/scope"; -import { parseDecimalAmount } from "@sdp/solana/amount"; -import { WELL_KNOWN_TOKEN_BY_MINT } from "@sdp/types/well-known-tokens"; -import { type Address, unwrapOption } from "@solana/kit"; -import { fetchSysvarClock } from "@solana/sysvars"; +import { AmountError, parseTokenAmount } from "@sdp/solana/amount"; +import { SPL_TOKEN_PROGRAMS, WELL_KNOWN_TOKEN_BY_MINT } from "@sdp/types/well-known-tokens"; import { - type Extension, - fetchMaybeMint, - uiAmountToAmountForInterestBearingMintWithoutSimulation, - uiAmountToAmountForScaledUiAmountMintWithoutSimulation, -} from "@solana-program/token-2022"; + type Account, + type Address, + address, + fetchEncodedAccount, + unwrapOption, +} from "@solana/kit"; +import { decodeMint, type Mint } from "@solana-program/token-2022"; import { badRequest } from "@/lib/errors"; -/** Converts a payment's UI decimal amount string into the base units its mint moves. */ -export type PaymentAmountConverter = (uiAmount: string) => bigint; +/** An exact decimal-string to u64 base-unit conversion. */ +export type PaymentAmountConverter = (amount: string) => bigint; -type UiAmountExtension = Extract< - Extension, - { __kind: "InterestBearingConfig" } | { __kind: "ScaledUiAmountConfig" } ->; - -function isUiAmountExtension(extension: Extension): extension is UiAmountExtension { - return ( - extension.__kind === "InterestBearingConfig" || - // biome-ignore lint/security/noSecrets: Token-2022 extension name, not a secret. - extension.__kind === "ScaledUiAmountConfig" - ); +export interface PaymentMint { + decimals: number; + tokenProgram: Address; + toBaseUnits: PaymentAmountConverter; } -/** Interest-bearing wins over scaled UI amount, the order Kit's own mint dispatch uses. */ -function findUiAmountExtension(extensions: readonly Extension[]): UiAmountExtension | null { - const uiAmountExtensions = extensions.filter(isUiAmountExtension); - const interestBearing = uiAmountExtensions.find( - (extension) => extension.__kind === "InterestBearingConfig" - ); - const selected = interestBearing !== undefined ? interestBearing : uiAmountExtensions.at(0); - return selected === undefined ? null : selected; +const UNSUPPORTED_PAYMENT_EXTENSIONS: ReadonlySet = new Set([ + "InterestBearingConfig", + // biome-ignore lint/security/noSecrets: Token-2022 extension name, not a secret. + "ScaledUiAmountConfig", + "NonTransferable", +]); + +/** Maps invalid precision or a value outside u64 to a client error, without rounding. */ +function parsePaymentAmount(amount: string, decimals: number): bigint { + try { + return parseTokenAmount(amount, decimals); + } catch (error) { + if (error instanceof AmountError) throw badRequest(error.message); + throw error; + } } /** - * Reads a mint's decimals and the extension, if any, that rescales its UI amounts. - * A well-known mint declared on the open scope's cluster answers from the catalogue - * without an RPC read: `pnpm check:well-known-mints` pins its decimals and that it - * carries no UI-amount extension. Any other mint is read from the cluster. - * - * @param mint - The mint to read. - * @returns The mint's decimals and its UI-amount extension, or null when it has none. + * Resolves the mint's actual decimals and owning token program in one read. + * Refuses extensions that change the requested amount or prevent transfer. + * 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. */ -async function readUiAmountMint( - mint: Address -): Promise<{ decimals: number; uiAmountExtension: UiAmountExtension | null }> { +export async function loadPaymentMint(mint: Address): Promise { const wellKnown = WELL_KNOWN_TOKEN_BY_MINT.get(mint); if (wellKnown?.clusters.includes(currentClusterScope().cluster)) { - return { decimals: wellKnown.decimals, uiAmountExtension: null }; + return { + decimals: wellKnown.decimals, + tokenProgram: address(SPL_TOKEN_PROGRAMS[wellKnown.tokenProgram]), + toBaseUnits: (amount) => parsePaymentAmount(amount, wellKnown.decimals), + }; } - const mintAccount = await fetchMaybeMint(createClusterRpc(), mint, { commitment: "confirmed" }); - if (!mintAccount.exists) { - throw badRequest("Token mint account does not exist"); + + const account = await fetchEncodedAccount(createClusterRpc(), mint, { commitment: "confirmed" }); + if (!account.exists) throw badRequest("Token mint account does not exist"); + if ( + account.programAddress !== SPL_TOKEN_PROGRAMS["spl-token"] && + account.programAddress !== SPL_TOKEN_PROGRAMS["token-2022"] + ) { + throw badRequest("Unsupported token program for mint"); + } + // Legacy and Token-2022 mints share the base layout; the decoder also reads extensions. + let decoded: Account; + try { + decoded = decodeMint(account); + } catch { + throw badRequest("Token mint account data is invalid"); } - const extensions = unwrapOption(mintAccount.data.extensions); + 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 decimals = decoded.data.decimals; return { - decimals: mintAccount.data.decimals, - uiAmountExtension: extensions === null ? null : findUiAmountExtension(extensions), + decimals, + tokenProgram: account.programAddress, + toBaseUnits: (amount) => parsePaymentAmount(amount, decimals), }; } -/** - * Reads a mint's decimals for a flow that fixes a raw amount once and reuses it - * (recurring payments and subscription plans pin a per-period cap in base units), - * refusing interest-bearing and scaled-UI-amount mints, whose raw amount for a - * fixed UI amount drifts with the cluster clock. Token-2022 extensions are fixed - * at mint initialization, so a mint admitted here stays admissible. - * - * @param mint - The mint the flow is denominated in. - * @returns The mint's decimals. - */ -export async function readStaticAmountMintDecimals(mint: Address): Promise { - const { decimals, uiAmountExtension } = await readUiAmountMint(mint); - if (uiAmountExtension !== null) { - throw badRequest( - "Recurring payments do not support interest-bearing or scaled UI amount tokens" - ); - } - return decimals; -} - -/** - * Reads a mint once and returns the converter every payment path uses to turn its - * UI amount into base units. A plain SPL Token or Token-2022 mint converts by its - * decimals alone; a mint with `InterestBearingConfig` or `ScaledUiAmountConfig` - * reverses the token program's UI scaling as of the cluster clock read here, so - * `TransferChecked` debits what the caller asked for rather than more. Every - * conversion validates precision with `parseDecimalAmount` first, so the input - * grammar and error contract are the same on every mint. - * - * The converter is synchronous: callers converting many amounts for one mint - * load it once and reuse it, and a funding check and the build it precedes - * must share one instance so both see the same raw amount. - * - * @param mint - The mint the amounts are denominated in. - * @returns A converter from UI decimal strings to base units for that mint. - */ +/** Resolves a mint once for a batch of exact conversions. */ export async function loadPaymentAmountConverter(mint: Address): Promise { - const { decimals, uiAmountExtension } = await readUiAmountMint(mint); - const toStaticBaseUnits: PaymentAmountConverter = (uiAmount) => - parseDecimalAmount(uiAmount, decimals); - if (uiAmountExtension === null) { - return toStaticBaseUnits; - } + return (await loadPaymentMint(mint)).toBaseUnits; +} - const clock = await fetchSysvarClock(createClusterRpc(), { commitment: "confirmed" }); - switch (uiAmountExtension.__kind) { - case "InterestBearingConfig": { - const config = uiAmountExtension; - return (uiAmount) => { - toStaticBaseUnits(uiAmount); - return uiAmountToAmountForInterestBearingMintWithoutSimulation( - uiAmount.trim(), - decimals, - Number(clock.unixTimestamp), - Number(config.lastUpdateTimestamp), - Number(config.initializationTimestamp), - config.preUpdateAverageRate, - config.currentRate - ); - }; - } - // biome-ignore lint/security/noSecrets: Token-2022 extension name, not a secret. - case "ScaledUiAmountConfig": { - const multiplier = - clock.unixTimestamp >= uiAmountExtension.newMultiplierEffectiveTimestamp - ? uiAmountExtension.newMultiplier - : uiAmountExtension.multiplier; - return (uiAmount) => { - toStaticBaseUnits(uiAmount); - return uiAmountToAmountForScaledUiAmountMintWithoutSimulation( - uiAmount.trim(), - decimals, - multiplier - ); - }; - } - default: { - const exhaustive: never = uiAmountExtension; - return exhaustive; - } - } +/** Recurring and subscription amounts use the same admissible mints as single payments. */ +export async function readStaticAmountMintDecimals(mint: Address): Promise { + return (await loadPaymentMint(mint)).decimals; } diff --git a/packages/sdp-solana/src/amount.test.ts b/packages/sdp-solana/src/amount.test.ts index 67c988cf9a..f7e158c200 100644 --- a/packages/sdp-solana/src/amount.test.ts +++ b/packages/sdp-solana/src/amount.test.ts @@ -6,6 +6,7 @@ import { compareDecimalAmounts, formatDecimalAmount, parseDecimalAmount, + parseTokenAmount, scaleDecimalAmountByBps, toMosaicAmount, toNumberAmount, @@ -163,3 +164,14 @@ describe("kit-backed amount helpers", () => { ); }); }); + +describe("parseTokenAmount", () => { + it("accepts the full u64 range without floating-point conversion", () => { + assert.equal(parseTokenAmount("9007199254.740993", 6), 9007199254740993n); + assert.equal(parseTokenAmount("18446744073709.551615", 6), 18446744073709551615n); + }); + it("rejects excess precision and u64 overflow", () => { + assert.throws(() => parseTokenAmount("1.0000001", 6), AmountError); + assert.throws(() => parseTokenAmount("18446744073709.551616", 6), AmountError); + }); +}); diff --git a/packages/sdp-solana/src/amount.ts b/packages/sdp-solana/src/amount.ts index 8e1fc1752e..30a577f1e9 100644 --- a/packages/sdp-solana/src/amount.ts +++ b/packages/sdp-solana/src/amount.ts @@ -72,7 +72,7 @@ const TOTAL_BITS = 256; * decimals configuration is invalid, the string exceeds the target scale, or * the scaled value overflows the 256-bit raw range. */ -const parseDecimalFixedPoint = (value: string, decimals: number) => { +const parseDecimalFixedPoint = (value: string, decimals: number, totalBits: number) => { const normalized = value.trim(); if (!isDecimalString(normalized)) { @@ -88,7 +88,7 @@ const parseDecimalFixedPoint = (value: string, decimals: number) => { } try { - return decimalFixedPoint("unsigned", TOTAL_BITS, decimals)(normalized); + return decimalFixedPoint("unsigned", totalBits, decimals)(normalized, "strict"); } catch { throw new AmountError("Amount is out of range"); } @@ -105,7 +105,11 @@ const parseDecimalFixedPoint = (value: string, decimals: number) => { * the scaled value overflows the 256-bit raw range. */ export const parseDecimalAmount = (value: string, decimals: number): bigint => - parseDecimalFixedPoint(value, decimals).raw; + parseDecimalFixedPoint(value, decimals, TOTAL_BITS).raw; + +/** Parses an exact token or lamport amount, rejecting precision loss and u64 overflow. */ +export const parseTokenAmount = (value: string, decimals: number): bigint => + parseDecimalFixedPoint(value, decimals, 64).raw; /** * Compares two decimal amount strings at their maximum shared scale. @@ -118,8 +122,8 @@ export const parseDecimalAmount = (value: string, decimals: number): bigint => */ export const compareDecimalAmounts = (left: string, right: string): number => { const decimals = Math.max(decimalScale(left), decimalScale(right)); - const leftAmount = parseDecimalFixedPoint(left, decimals); - const rightAmount = parseDecimalFixedPoint(right, decimals); + const leftAmount = parseDecimalFixedPoint(left, decimals, TOTAL_BITS); + const rightAmount = parseDecimalFixedPoint(right, decimals, TOTAL_BITS); return cmpDecimalFixedPoint(leftAmount, rightAmount); }; diff --git a/scripts/check-well-known-mints.mjs b/scripts/check-well-known-mints.mjs index e58cb58a2b..da24c86293 100644 --- a/scripts/check-well-known-mints.mjs +++ b/scripts/check-well-known-mints.mjs @@ -7,8 +7,8 @@ // 1. the account parses as a mint at all (not a token account, not a wallet) // 2. its on-chain `decimals` matches the declared value // 3. its owning program matches the declared `tokenProgram` -// 4. it carries no Token-2022 extension that rescales UI amounts -// (`interestBearingConfig`, `scaledUiAmountConfig`): payment amount +// 4. it carries none of the three Token-2022 extensions payments refuse +// (interest, scale or non-transferability): payment amount // conversion takes a catalogue mint's decimals without reading the chain, // which is exact only for a mint whose UI amount is its raw amount scaled // by decimals. Extensions are fixed at mint initialization, so this holds. @@ -33,8 +33,12 @@ import { WELL_KNOWN_TOKENS, } from "../packages/sdp-types/src/well-known-tokens.ts"; -// biome-ignore lint/security/noSecrets: Token-2022 extension names, not secrets. -const UI_AMOUNT_EXTENSIONS = ["interestBearingConfig", "scaledUiAmountConfig"]; +const UNSUPPORTED_PAYMENT_EXTENSIONS = [ + "interestBearingConfig", + "nonTransferable", + // biome-ignore lint/security/noSecrets: Token-2022 extension name, not a secret. + "scaledUiAmountConfig", +]; const RPC_URLS = { devnet: "https://api.devnet.solana.com", @@ -87,8 +91,8 @@ function findProblems(account, mint, tokenProgram) { const extensions = account.data?.parsed?.info?.extensions ?? []; for (const { extension } of extensions) { - if (UI_AMOUNT_EXTENSIONS.includes(extension)) { - problems.push(`carries ${extension}, which rescales UI amounts`); + if (UNSUPPORTED_PAYMENT_EXTENSIONS.includes(extension)) { + problems.push(`carries ${extension}, which payments do not support`); } } From 13bd8200ba94bf59b1dbb84a1525765dfb057da3 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Wed, 21 Oct 2026 20:27:52 +0800 Subject: [PATCH 09/11] test(payments): account for catalogue mint read reuse --- .../routes/payments.transfers.ramp-deposit-funding.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts b/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts index 95643b71cf..e5dc39a1f7 100644 --- a/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts +++ b/apps/sdp-api/src/routes/payments.transfers.ramp-deposit-funding.test.ts @@ -148,9 +148,9 @@ describe("Payments routes — funding an off-ramp token deposit", () => { status: "settling", }); expect(sendTransactionMock).toHaveBeenCalledTimes(1); - // The send reuses the source the check read: one read of each, as before the check. + // The send reuses the source read; the catalogue mint needs no RPC read, as before the check. expect(tokenAccountReads() - tokenAccountReadsBefore).toBe(1); - expect(getAccountInfoMock.mock.calls.length - mintReadsBefore).toBe(1); + expect(getAccountInfoMock.mock.calls.length - mintReadsBefore).toBe(0); }); it("refuses a deposit from a frozen token account before claiming it", async () => { From 2eb18d43470c7525f3692f72f2f1fd1d95a2f4f4 Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Thu, 22 Oct 2026 18:10:53 +0800 Subject: [PATCH 10/11] fix(payments): format batch amounts from parsed base units and check catalogue mints on catalogue changes --- .github/workflows/well-known-mints.yml | 43 +++++++++++++++++++ .../payments/transfer-batches/resolve.ts | 4 +- scripts/check-well-known-mints.mjs | 6 +-- 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/well-known-mints.yml diff --git a/.github/workflows/well-known-mints.yml b/.github/workflows/well-known-mints.yml new file mode 100644 index 0000000000..2d4da5c9d4 --- /dev/null +++ b/.github/workflows/well-known-mints.yml @@ -0,0 +1,43 @@ +# Live gate: payments trust a catalogue mint's decimals and extensions without reading the chain, +# so every catalogue change is checked against devnet and mainnet before it merges. +name: Well-Known Mints + +on: + pull_request: + paths: + - 'packages/sdp-types/src/well-known-tokens.ts' + - 'scripts/check-well-known-mints.mjs' + - '.github/workflows/well-known-mints.yml' + +permissions: + contents: read + +concurrency: + group: well-known-mints-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Catalogue matches chain + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10.16.0 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.22.1 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Check catalogue mints + run: pnpm check:well-known-mints 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 70356ebe6e..a9e2806b27 100644 --- a/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts +++ b/apps/sdp-api/src/routes/payments/transfer-batches/resolve.ts @@ -143,8 +143,6 @@ function resolveRecipients(params: { throw notFound(`Counterparty account ${recipient.counterpartyAccountId}`); } - // `amount` stays the requested UI amount in canonical form; an interest-bearing - // or scaled mint moves `amountBaseUnits` away from its static scaling. const amountBaseUnits = parseRecipientAmount(recipient.amount, toBaseUnits); return { index, @@ -152,7 +150,7 @@ function resolveRecipients(params: { counterpartyId: account.counterparty_id, counterpartyAccountId: recipient.counterpartyAccountId, destinationAddress: readCryptoWalletAddress(account, index), - amount: formatDecimalAmount(parseTokenAmount(recipient.amount, decimals), decimals), + amount: formatDecimalAmount(amountBaseUnits, decimals), amountBaseUnits, }; }); diff --git a/scripts/check-well-known-mints.mjs b/scripts/check-well-known-mints.mjs index da24c86293..c6f288a966 100644 --- a/scripts/check-well-known-mints.mjs +++ b/scripts/check-well-known-mints.mjs @@ -18,9 +18,9 @@ // distinguishes them; a wrong `decimals` silently misscales every amount shown // for that token. // -// Deliberately not wired into CI: it needs public RPC, and a rate-limited -// endpoint would make the pipeline flaky for no safety gain. Run it by hand -// whenever you add or change a catalogue entry. +// CI runs it only on pull requests that touch the catalogue or this script +// (`.github/workflows/well-known-mints.yml`): it needs public RPC, and a +// rate-limited endpoint would make every pipeline flaky for no safety gain. // // Run it through the root script, not with bare `node`. It imports the // catalogue straight from TypeScript source, and `engines.node` here is From a6656c3d7d7769fd8163ffadb463e093eaded99c Mon Sep 17 00:00:00 2001 From: bashtwigs Date: Thu, 22 Oct 2026 18:14:09 +0800 Subject: [PATCH 11/11] test(api): prove /pay/:token/tx refuses a rescaling mint before claiming or signing --- apps/sdp-api/src/routes/pay.test.ts | 58 +++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/sdp-api/src/routes/pay.test.ts b/apps/sdp-api/src/routes/pay.test.ts index 849c185b71..62a66822e0 100644 --- a/apps/sdp-api/src/routes/pay.test.ts +++ b/apps/sdp-api/src/routes/pay.test.ts @@ -31,10 +31,11 @@ import { TEST_SOLANA_ADDRESSES } from "@/test/fixtures/tokens"; import { clusterOfRpcClient, ENVIRONMENT_CLUSTER_CASES, - installClusterRpcStub, + installClusterRpcResponder, } from "@/test/helpers/cluster-rpc"; import { seedUnreadPrivyWallet } from "@/test/helpers/custody-connections"; import { env } from "@/test/helpers/env"; +import { syntheticAddress } from "@/test/helpers/parsed-transactions"; import { createKoraAdapterMock, getRecentBlockhashMock, @@ -51,6 +52,7 @@ import { TEST_MOCK_FEE_PAYER, TEST_MOCK_FEE_PAYER_KEY_PAIR, } from "@/test/helpers/sponsor-signing"; +import { respondMintAccounts, scaledUiAmountConfig } from "@/test/helpers/token-mints"; interface RequestWallet { projectId: string; @@ -102,7 +104,7 @@ const sponsoredGrantMetadataSchema = z.object({ describe("Public payment request routes", () => { installPaymentsRouteTestHooks(); - const rpc = installClusterRpcStub(); + const rpc = installClusterRpcResponder(); beforeEach(() => { rpc.answer("getSignaturesForAddress", []); @@ -110,6 +112,10 @@ describe("Public payment request routes", () => { }); function createAwaitingPaymentRequest(wallet: RequestWallet) { + return createAwaitingTokenPaymentRequest(wallet, SOL_MINT); + } + + function createAwaitingTokenPaymentRequest(wallet: RequestWallet, token: string) { return createPaymentRequestsRepository( env, createTenantScope({ organizationId: TEST_ORG.id, projectId: wallet.projectId }) @@ -119,7 +125,7 @@ describe("Public payment request routes", () => { custodyWalletId: wallet.custodyWalletId, walletId: wallet.walletId, destinationAddress: wallet.publicKey, - token: SOL_MINT, + token, amount: "1.5", expiresAt: null, createdBy: TEST_USER.id, @@ -518,5 +524,51 @@ describe("Public payment request routes", () => { expect((await postTransaction(request.public_token)).status).toBe(200); }); + + it("refuses a request in a mint whose extension rescales amounts before claiming or signing", async () => { + const sign = vi.fn(sponsorSignTestTransaction); + stubSponsorship().mockImplementation(() => ({ + providerId: "test", + getFeePayer: async () => TEST_MOCK_FEE_PAYER, + signAsFeePayer: sign, + getSponsorshipConfiguration: () => Promise.reject(new Error("not used")), + prepareOwnedSubmission: () => Promise.reject(new Error("not used")), + })); + const mint = syntheticAddress(355); + const clockUnixTimestamp = 1_750_000_000n; + respondMintAccounts(rpc, { + mints: new Map([ + [ + mint, + { + decimals: 6, + extensions: [ + scaledUiAmountConfig({ + multiplier: 2, + newMultiplier: 2, + newMultiplierEffectiveTimestamp: clockUnixTimestamp, + }), + ], + }, + ], + ]), + clockUnixTimestamp, + }); + const request = await createAwaitingTokenPaymentRequest(SANDBOX_REQUEST_WALLET, mint); + + const response = await postTransaction(request.public_token); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { + code: "BAD_REQUEST", + message: "Payments do not support the ScaledUiAmountConfig mint extension", + }, + }); + expect(sign).not.toHaveBeenCalled(); + expect( + await createSystemPaymentRequestsRepository(env).getSponsoredTransactionClaim(request.id) + ).toBeNull(); + }); }); });