Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 86 additions & 26 deletions apps/sdp-api/src/routes/payments.recurring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { collectDueRecurringPayments } from "@/services/jobs/collect-recurring-p
import { cancelRecurringPaymentsActivatedByKey } from "@/services/payments/recurring-payments/cancel-activated-by-key";
import { skipRefusedRecurringCollectionPeriod } from "@/services/payments/recurring-payments/collection";
import * as recurringShared from "@/services/payments/recurring-payments/shared";
import * as paymentAmounts from "@/services/payments/token-amount";
import { TEST_SOLANA_ADDRESSES } from "@/test/fixtures/tokens";
import { authenticateTestClerkUser } from "@/test/helpers/clerk";
import { clusterOfRpcClient } from "@/test/helpers/cluster-rpc";
Expand Down Expand Up @@ -97,6 +98,7 @@ import {
} from "@/test/helpers/recurring-payments";
import { required } from "@/test/helpers/required";
import { sendTransactionPreflightError } from "@/test/helpers/rpc-errors";
import { respondMintAccounts, scaledUiAmountConfig } from "@/test/helpers/token-mints";

// Every mutation carries a fresh key unless a test sets its own (HOO-1918).
const app = withIdempotencyKeys(baseApp);
Expand Down Expand Up @@ -838,6 +840,7 @@ describe("Payments routes — recurring", () => {

const issuedMint = (await generateKeyPairSigner()).address;
const pausedMint = (await generateKeyPairSigner()).address;
const scaledMint = (await generateKeyPairSigner()).address;
await seedIssuedTokenMint({
projectId: TEST_PROJECT.id,
mintAddress: issuedMint,
Expand All @@ -848,8 +851,38 @@ describe("Payments routes — recurring", () => {
mintAddress: pausedMint,
status: "paused",
});
await seedIssuedTokenMint({
projectId: TEST_PROJECT.id,
mintAddress: scaledMint,
status: "active",
});
respondMintAccounts(recurringExecution.rpc, {
mints: new Map([
[issuedMint, { decimals: 6, extensions: null }],
[
scaledMint,
{
decimals: 6,
extensions: [
scaledUiAmountConfig({
multiplier: 2,
newMultiplier: 2,
newMultiplierEffectiveTimestamp: 0n,
}),
],
},
],
]),
clockUnixTimestamp: 1_750_000_000n,
});
await expectTokenRejected(pausedMint);

const scaledRes = await createRecurring(scaledMint);
expect(scaledRes.status).toBe(400);
expect(errorResponseSchema.parse(await scaledRes.json()).error.message).toBe(
"Payments do not support the ScaledUiAmountConfig mint extension"
);

const issuedRes = await createRecurring(issuedMint);
expect(issuedRes.status).toBe(201);
const issuedBody = await parseRecurringResponse(issuedRes);
Expand Down Expand Up @@ -1962,6 +1995,46 @@ describe("Payments routes — recurring", () => {
expect(sendTransactionMock).toHaveBeenCalledTimes(3);
});

it("cancels an active recurring payment whose issued token was paused after activation", async () => {
const issuedMint = (await generateKeyPairSigner()).address;
const issuedTokenId = await seedIssuedTokenMint({
projectId: TEST_PROJECT.id,
mintAddress: issuedMint,
status: "active",
});
const activated = await activateRecurringPaymentFixture({
...DEFAULT_RECURRING_FIXTURE,
token: issuedMint,
});
await getDb(env)
.prepare("UPDATE issued_tokens SET status = 'paused' WHERE id = ?")
.bind(issuedTokenId)
.run();

const cancelRes = await app.request(
`/v1/payments/recurring-payments/${activated.id}/cancel`,
{ method: "POST", headers: RECURRING_HEADERS, body: "{}" },
env
);

expect(cancelRes.status).toBe(200);
expect((await parseRecurringResponse(cancelRes)).data.recurringPayment).toMatchObject({
id: activated.id,
status: "canceled",
});
expect(
await getDb(env)
.prepare(
`SELECT status, signature
FROM payment_recurring_payment_lifecycle_attempts
WHERE recurring_payment_id = ? AND operation = 'cancel'`
)
.bind(activated.id)
.first<{ status: string; signature: string | null }>()
).toEqual({ status: "confirmed", signature: poolSentSignatures()[2] });
expect(sendTransactionMock).toHaveBeenCalledTimes(3);
});

it("keeps a cancel whose send outcome is unknown in flight with its signature saved", async () => {
const activated = await activateRecurringPaymentFixture(DEFAULT_RECURRING_FIXTURE);
sendTransactionMock.mockRejectedValueOnce(new Error("RPC response lost"));
Expand Down Expand Up @@ -2907,17 +2980,14 @@ describe("Payments routes — recurring", () => {
const mintLookupReleased = new Promise<void>((resolve) => {
releaseMintLookup = resolve;
});
const getAccountInfo = getAccountInfoMock.getMockImplementation();
if (!getAccountInfo) {
throw new Error("Account info mock is not installed");
}
getAccountInfoMock.mockImplementationOnce(async (...args) => {
if (signalMintLookupReached) {
signalMintLookupReached();
}
await mintLookupReleased;
return getAccountInfo(...args);
});
const loadPaymentMint = paymentAmounts.loadPaymentMint;
const mintLookup = vi
.spyOn(paymentAmounts, "loadPaymentMint")
.mockImplementationOnce(async (mint) => {
if (signalMintLookupReached) signalMintLookupReached();
await mintLookupReleased;
return loadPaymentMint(mint);
});

const preparePromise = app.request(
`/v1/payments/subscriptions/${subscriptionId}/prepare-authorization`,
Expand Down Expand Up @@ -2949,6 +3019,7 @@ describe("Payments routes — recurring", () => {
}

const prepareRes = await preparePromise;
mintLookup.mockRestore();
expect(prepareRes.status).toBe(409);
const persisted = await repo.getSubscriptionById({
subscriptionId,
Expand Down Expand Up @@ -3914,13 +3985,9 @@ describe("Payments routes — recurring", () => {
const activated = await activateProductionUsdcSchedule();

expect(activated.token).toBe(MAINNET_USDC_MINT);
expect(recurringExecution.rpc.calls).toContainEqual({
cluster: "mainnet-beta",
method: "getTokenSupply",
});
expect(new Set(recurringExecution.rpc.calls.map((call) => call.cluster))).toEqual(
new Set(["mainnet-beta"])
);
expect(
recurringExecution.rpc.calls.filter((call) => call.method === "getTokenSupply")
).toEqual([]);
expect(
new Set(
[
Expand Down Expand Up @@ -3954,14 +4021,7 @@ describe("Payments routes — recurring", () => {
const tokenSupplyReads = recurringExecution.rpc.requests
.slice(requestsBefore)
.filter((request) => request.method === "getTokenSupply");
const clustersReadingMint = (mint: string) =>
new Set(
tokenSupplyReads
.filter((request) => request.params[0] === mint)
.map((request) => request.cluster)
);
expect(clustersReadingMint(DEVNET_USDC_MINT)).toEqual(new Set(["devnet"]));
expect(clustersReadingMint(MAINNET_USDC_MINT)).toEqual(new Set(["mainnet-beta"]));
expect(tokenSupplyReads).toEqual([]);
const clustersConfirming = (collectionSignature: string) =>
new Set(
confirmTransactionMock.mock.calls
Expand Down
5 changes: 3 additions & 2 deletions apps/sdp-api/src/routes/payments.subscriptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
preparePaymentSubscriptionPlanResponseSchema,
} from "@/openapi/schemas/payments";
import { TEST_SOLANA_ADDRESSES } from "@/test/fixtures/tokens";
import { installClusterRpcStub, tokenSupplyResult } from "@/test/helpers/cluster-rpc";
import { installClusterRpcStub } from "@/test/helpers/cluster-rpc";
import { env } from "@/test/helpers/env";
import {
DEVNET_USDC_MINT,
Expand All @@ -31,6 +31,7 @@ import {
} from "@/test/helpers/payments-routes";
import { required } from "@/test/helpers/required";
import { TEST_MOCK_FEE_PAYER } from "@/test/helpers/sponsor-signing";
import { mintAccountInfoResult } from "@/test/helpers/token-mints";

const SUBSCRIPTION_HEADERS = {
Authorization: "Bearer sk_test_payments_policy",
Expand Down Expand Up @@ -81,7 +82,7 @@ describe("Payments routes — subscriptions", () => {
const rpc = installClusterRpcStub();

beforeEach(() => {
rpc.answer("getTokenSupply", tokenSupplyResult(1, 6));
rpc.answer("getAccountInfo", mintAccountInfoResult({ decimals: 6, extensions: null }));
});

it("stores a Production plan's USDC as the mainnet mint", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,9 @@ describe("Payments routes — funding an off-ramp token deposit", () => {
status: "settling",
});
expect(sendTransactionMock).toHaveBeenCalledTimes(1);
// The send reuses the source the check read: one read of each, as before the check.
// The send reuses the source read; the catalogue mint needs no RPC read, as before the check.
expect(tokenAccountReads() - tokenAccountReadsBefore).toBe(1);
expect(getAccountInfoMock.mock.calls.length - mintReadsBefore).toBe(1);
expect(getAccountInfoMock.mock.calls.length - mintReadsBefore).toBe(0);
});

it("refuses a deposit from a frozen token account before claiming it", async () => {
Expand Down
83 changes: 81 additions & 2 deletions apps/sdp-api/src/routes/payments/token-accounts.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import { createClusterRpc } from "@sdp/rpc/factory";
import { runInClusterScope } from "@sdp/rpc/scope";
import type { SolanaRpc } from "@sdp/rpc/solana";
import { SPL_TOKEN_PROGRAMS, WELL_KNOWN_TOKENS } from "@sdp/types/well-known-tokens";
import type { Address } from "@solana/kit";
import { type Address, createNoopSigner } from "@solana/kit";
import {
getTransferCheckedInstructionDataDecoder,
TOKEN_2022_PROGRAM_ADDRESS,
} from "@solana-program/token-2022";
import { describe, expect, it } from "vitest";
import { getSplTokenBalances, resolveTokenLabel, withIssuedTokenLabels } from "./token-accounts";
import { installClusterRpcResponder, tokenAccountsByOwnerResult } from "@/test/helpers/cluster-rpc";
import { syntheticAddress } from "@/test/helpers/parsed-transactions";
import { respondMintAccounts, scaledUiAmountConfig } from "@/test/helpers/token-mints";
import {
buildSplTransferInstructions,
getSplTokenBalances,
resolveTokenLabel,
withIssuedTokenLabels,
} from "./token-accounts";

describe("resolveTokenLabel", () => {
it("uses the issued token symbol for a known mint", () => {
Expand Down Expand Up @@ -61,3 +75,68 @@ describe("getSplTokenBalances", () => {
await expect(balances).resolves.toEqual([]);
});
});

describe("buildSplTransferInstructions", () => {
const rpc = installClusterRpcResponder();

it.each([false, true])(
"builds exact amounts or rejects scaled mints (scaled=%s)",
async (scaled) => {
const mint = syntheticAddress(11);
respondMintAccounts(rpc, {
mints: new Map([
[
mint,
{
decimals: 6,
extensions: scaled
? [
scaledUiAmountConfig({
multiplier: 2,
newMultiplier: 4,
newMultiplierEffectiveTimestamp: 1_800_000_000n,
}),
]
: null,
},
],
]),
clockUnixTimestamp: 1_750_000_000n,
});
rpc.answer(
"getTokenAccountsByOwner",
tokenAccountsByOwnerResult(1, [
{
pubkey: syntheticAddress(12),
mint,
amount: "20000000",
decimals: 9,
uiAmountString: "40",
state: "initialized",
},
])
);

const result = runInClusterScope({ projectId: null, environment: "sandbox" }, () =>
buildSplTransferInstructions(createClusterRpc(), {
authority: createNoopSigner(syntheticAddress(13)),
destination: syntheticAddress(14),
mint,
amount: "9007199254.740993",
ataRentPayer: syntheticAddress(15),
})
);

if (scaled) {
await expect(result).rejects.toMatchObject({ code: "BAD_REQUEST", statusCode: 400 });
return;
}
const { transferInstruction } = await result;
expect(rpc.requests.filter((request) => request.method === "getAccountInfo")).toHaveLength(1);
expect(transferInstruction.programAddress).toBe(TOKEN_2022_PROGRAM_ADDRESS);
expect(
getTransferCheckedInstructionDataDecoder().decode(transferInstruction.data)
).toMatchObject({ amount: 9007199254740993n, decimals: 6 });
}
);
});
Loading
Loading