@@ -28,6 +28,7 @@ process.env.CRYPTO_DIRECT_SOLANA_RECEIVE_ADDRESS = "1111111111111111111111111111
2828process . env . CRYPTO_DIRECT_BASE_RPC_URL = "http://mocked-base" ;
2929process . env . CRYPTO_DIRECT_BSC_RPC_URL = "http://mocked-bsc" ;
3030process . 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+
146174vi . 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 ( / b e l o w t h e e x p e c t e d f l o o r / ) ;
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 ( / a b o v e t h e e x p e c t e d c e i l i n g / ) ;
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 ( / Q u o t e s i g n a t u r e / ) ;
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 ( / R e c e i v i n g A T A o w n e r d o e s n o t m a t c h / ) ;
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