Skip to content

Commit c1a87e8

Browse files
Shawclaude
andcommitted
test(cloud-shared): PGlite integration tests for direct crypto payments
Covers the full create->attach->confirm->credits state machine plus HMAC quote verification, slippage floor/ceiling, MAX_VERIFY_ATTEMPTS cap, and Solana ATA owner check. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent e812f2a commit c1a87e8

1 file changed

Lines changed: 153 additions & 11 deletions

File tree

packages/cloud-shared/src/lib/services/__tests__/direct-wallet-payments.integration.test.ts

Lines changed: 153 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ process.env.CRYPTO_DIRECT_SOLANA_RECEIVE_ADDRESS = "1111111111111111111111111111
2828
process.env.CRYPTO_DIRECT_BASE_RPC_URL = "http://mocked-base";
2929
process.env.CRYPTO_DIRECT_BSC_RPC_URL = "http://mocked-bsc";
3030
process.env.CRYPTO_DIRECT_SOLANA_RPC_URL = "http://mocked-solana";
31+
process.env.CRYPTO_DIRECT_QUOTE_SIGNING_KEY = "test-signing-key-deadbeef";
3132

3233
// ---------------------------------------------------------------------------
3334
// Module mocks
@@ -143,13 +144,40 @@ vi.mock("../bnb-price-oracle", async () => {
143144
// Solana — we don't test the Solana confirm path through verify (would need a
144145
// huge mock of getParsedTransaction + ATA owner check). The Solana createPayment
145146
// is exercised separately though, so we still need these imports to resolve.
147+
// Mutable state read by the spl-token / Connection mocks to flip behavior per
148+
// test. Hoisted so `vi.mock(...)` factories — which run before module init —
149+
// can capture a reference.
150+
const solanaTestState = vi.hoisted(() => ({
151+
ataOwnerOverride: null as Uint8Array | null,
152+
parsedTxOverride: null as unknown,
153+
}));
154+
155+
vi.mock("@solana/spl-token", async (importOriginal) => {
156+
const actual = await importOriginal<typeof import("@solana/spl-token")>();
157+
return {
158+
...actual,
159+
getAccount: vi.fn(async (_connection: unknown, ata: { toBase58(): string }) => {
160+
if (solanaTestState.ataOwnerOverride) {
161+
const { PublicKey } = await import("@solana/web3.js");
162+
return {
163+
address: ata,
164+
owner: new PublicKey(solanaTestState.ataOwnerOverride),
165+
mint: ata,
166+
amount: 0n,
167+
} as unknown as Awaited<ReturnType<typeof actual.getAccount>>;
168+
}
169+
return actual.getAccount(_connection as never, ata as never);
170+
}),
171+
};
172+
});
173+
146174
vi.mock("@solana/web3.js", async (importOriginal) => {
147175
const actual = await importOriginal<typeof import("@solana/web3.js")>();
148176
return {
149177
...actual,
150178
Connection: class FakeConnection {
151179
async getParsedTransaction() {
152-
return null;
180+
return solanaTestState.parsedTxOverride;
153181
}
154182
async getAccountInfo() {
155183
return null;
@@ -528,11 +556,7 @@ describe.skipIf(!process.env.DATABASE_URL || !pgliteAvailable)(
528556
).rejects.toThrow(/below the expected floor/);
529557
});
530558

531-
test("BNB native verify accepts overpayment at 1.5x (no ceiling enforced today)", async () => {
532-
// NOTE: the task spec mentions an overpayment ceiling, but the current
533-
// implementation only enforces a floor — there is no ceiling check.
534-
// Document the existing behavior so a future change to add a ceiling
535-
// breaks this test loudly.
559+
test("BNB native verify rejects above the slippage ceiling (gross overpayment)", async () => {
536560
await resetTable();
537561
const { payment } = await service.createPayment(env, {
538562
organizationId: ORG_ID,
@@ -550,16 +574,134 @@ describe.skipIf(!process.env.DATABASE_URL || !pgliteAvailable)(
550574
chainTxs.set(overHash, {
551575
from: PAYER_EVM,
552576
to: receive,
553-
value: (expectedUnits * 3n) / 2n,
577+
value: (expectedUnits * 3n) / 2n, // +50% — way above 200bps ceiling
554578
status: "success",
555579
receiveAddress: receive,
556580
});
557-
await service.confirmPayment(env, {
558-
paymentId: payment.id,
559-
txHash: overHash,
581+
await expect(
582+
service.confirmPayment(env, {
583+
paymentId: payment.id,
584+
txHash: overHash,
585+
userId: USER_ID,
586+
}),
587+
).rejects.toThrow(/above the expected ceiling/);
588+
expect(creditsLedger).toHaveLength(0);
589+
});
590+
591+
test("confirmPayment rejects a tampered quote_signature without touching the chain", async () => {
592+
await resetTable();
593+
const { payment } = await service.createPayment(env, {
594+
organizationId: ORG_ID,
560595
userId: USER_ID,
596+
accountWalletAddress: null,
597+
payerAddress: PAYER_EVM,
598+
amountUsd: 10,
599+
network: "bsc",
600+
tokenSymbol: "USDT",
561601
});
562-
expect(creditsLedger).toHaveLength(1);
602+
const meta = payment.metadata as Record<string, unknown>;
603+
const tokenAddress = meta.token_address as string;
604+
const receive = env.CRYPTO_DIRECT_BSC_RECEIVE_ADDRESS;
605+
const hash = `0x${"7".repeat(64)}`;
606+
// Provide a perfectly-valid on-chain tx — the failure must come from the
607+
// HMAC check, not from anything on chain.
608+
chainTxs.set(hash, {
609+
from: PAYER_EVM,
610+
to: tokenAddress,
611+
value: 0n,
612+
status: "success",
613+
receiveAddress: receive,
614+
erc20: {
615+
tokenAddress,
616+
from: PAYER_EVM,
617+
to: receive,
618+
value: BigInt(meta.expected_token_units as string),
619+
},
620+
});
621+
// Tamper with the persisted signature directly in the DB.
622+
await dbWrite.execute(
623+
`UPDATE crypto_payments SET metadata = metadata || '{"quote_signature":"deadbeef"}'::jsonb WHERE id = '${payment.id}'`,
624+
);
625+
await expect(
626+
service.confirmPayment(env, { paymentId: payment.id, txHash: hash, userId: USER_ID }),
627+
).rejects.toThrow(/Quote signature/);
628+
expect(creditsLedger).toHaveLength(0);
629+
});
630+
631+
test("processBroadcastBatch bumps verify_attempts and gives up at MAX_VERIFY_ATTEMPTS", async () => {
632+
await resetTable();
633+
const { payment } = await service.createPayment(env, {
634+
organizationId: ORG_ID,
635+
userId: USER_ID,
636+
accountWalletAddress: null,
637+
payerAddress: PAYER_EVM,
638+
amountUsd: 10,
639+
network: "bsc",
640+
tokenSymbol: "USDT",
641+
});
642+
const hash = `0x${"8".repeat(64)}`;
643+
await service.attachTransaction({ paymentId: payment.id, txHash: hash, userId: USER_ID });
644+
// No entry in chainTxs => transient "not found". One pass bumps verify_attempts.
645+
await service.processBroadcastBatch(env);
646+
const row1 = await dbWrite.query.cryptoPayments.findFirst();
647+
expect(row1?.status).toBe("broadcast");
648+
const attempts1 = Number(
649+
(row1?.metadata as Record<string, unknown>).verify_attempts ?? 0,
650+
);
651+
expect(attempts1).toBeGreaterThanOrEqual(1);
652+
653+
// Jump straight to MAX-1 to keep the test fast, then one more pass should
654+
// give up.
655+
await dbWrite.execute(
656+
`UPDATE crypto_payments SET metadata = metadata || '{"verify_attempts":60}'::jsonb WHERE id = '${payment.id}'`,
657+
);
658+
const stats = await service.processBroadcastBatch(env);
659+
expect(stats.failed).toBe(1);
660+
const row2 = await dbWrite.query.cryptoPayments.findFirst();
661+
expect(row2?.status).toBe("failed_chain");
662+
});
663+
664+
test("Solana confirmPayment rejects when receiving ATA owner mismatches treasury", async () => {
665+
await resetTable();
666+
// Configure the parsed-tx + ATA-owner overrides for this test only.
667+
solanaTestState.parsedTxOverride = {
668+
slot: 1,
669+
meta: {
670+
err: null,
671+
preTokenBalances: [],
672+
postTokenBalances: [],
673+
fee: 0,
674+
preBalances: [],
675+
postBalances: [],
676+
},
677+
transaction: { message: { accountKeys: [], instructions: [] }, signatures: [] },
678+
};
679+
// 32-byte pubkey distinct from the configured treasury (all-1s default).
680+
solanaTestState.ataOwnerOverride = new Uint8Array(32).fill(2);
681+
682+
try {
683+
const { payment } = await service.createPayment(env, {
684+
organizationId: ORG_ID,
685+
userId: USER_ID,
686+
accountWalletAddress: null,
687+
payerAddress: PAYER_SOL,
688+
amountUsd: 10,
689+
network: "solana",
690+
tokenSymbol: "USDC",
691+
});
692+
const solHash = "S".repeat(64);
693+
await expect(
694+
service.confirmPayment(env, {
695+
paymentId: payment.id,
696+
txHash: solHash,
697+
userId: USER_ID,
698+
}),
699+
).rejects.toThrow(/Receiving ATA owner does not match/);
700+
expect(creditsLedger).toHaveLength(0);
701+
} finally {
702+
solanaTestState.parsedTxOverride = null;
703+
solanaTestState.ataOwnerOverride = null;
704+
}
563705
});
564706

565707
test("processBroadcastBatch confirms a broadcast row when verify succeeds", async () => {

0 commit comments

Comments
 (0)