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