@@ -584,6 +584,197 @@ func TestAdvancePaymentWalletSafetyAndIdempotency(t *testing.T) {
584584 })
585585}
586586
587+ // Circle retry states must remain pending regardless of age. CONFIRMED already
588+ // has an on-chain hash, so persisting that hash makes the retry reconcilable;
589+ // only a non-contradictory terminal CANCELLED state may clear retry markers and
590+ // release a payment for replacement.
591+ func TestAdvancePaymentRetryAndTerminalCancellationState (t * testing.T ) {
592+ server .DefaultTestEnv ().Run (t , func (t testing.TB ) {
593+ ctx := context .Background ()
594+ networkId := server .NewId ()
595+ clientSession := session .Testing_CreateClientSession (ctx , & jwt.ByJwt {
596+ NetworkId : networkId ,
597+ })
598+ defer clientSession .Cancel ()
599+
600+ insertRetryPayment := func (record string , txHash * string ) * model.AccountPayment {
601+ paymentId := server .NewId ()
602+ server .Tx (ctx , func (tx server.PgTx ) {
603+ server .RaisePgResult (tx .Exec (
604+ ctx ,
605+ `
606+ INSERT INTO account_payment (
607+ payment_id,
608+ payment_plan_id,
609+ network_id,
610+ wallet_id,
611+ payout_byte_count,
612+ payout_nano_cents,
613+ min_sweep_time,
614+ payment_record,
615+ circle_idempotency_key,
616+ tx_hash
617+ ) VALUES ($1, $2, $3, NULL, 100, 100, $4, $5, $6, $7)
618+ ` ,
619+ paymentId ,
620+ server .NewId (),
621+ networkId ,
622+ server .NowUtc (),
623+ record ,
624+ server .NewId (),
625+ txHash ,
626+ ))
627+ })
628+ payment , err := model .GetPayment (ctx , paymentId )
629+ connect .AssertEqual (t , err , nil )
630+ connect .AssertNotEqual (t , payment , nil )
631+ return payment
632+ }
633+ paymentHasIdempotencyKey := func (paymentId server.Id ) bool {
634+ var hasIdempotencyKey bool
635+ server .Db (ctx , func (conn server.PgConn ) {
636+ result , queryErr := conn .Query (
637+ ctx ,
638+ `SELECT circle_idempotency_key IS NOT NULL FROM account_payment WHERE payment_id = $1` ,
639+ paymentId ,
640+ )
641+ server .WithPgResult (result , queryErr , func () {
642+ if result .Next () {
643+ server .Raise (result .Scan (& hasIdempotencyKey ))
644+ }
645+ })
646+ })
647+ return hasIdempotencyKey
648+ }
649+
650+ {
651+ payment := insertRetryPayment ("circle-confirmed" , nil )
652+ const txHash = "confirmed-chain-hash"
653+ const receipt = `{"state":"CONFIRMED","txHash":"confirmed-chain-hash"}`
654+ SetCircleClient (& mockCircleApiClient {
655+ GetTransactionFunc : func (context.Context , string ) (* GetTransactionResult , error ) {
656+ return & GetTransactionResult {
657+ Transaction : CircleTransaction {
658+ State : "CONFIRMED" ,
659+ TxHash : txHash ,
660+ },
661+ ResponseBodyBytes : []byte (receipt ),
662+ }, nil
663+ },
664+ })
665+
666+ complete , canceled , err := advancePayment (payment , clientSession )
667+ connect .AssertEqual (t , err , nil )
668+ connect .AssertEqual (t , complete , false )
669+ connect .AssertEqual (t , canceled , false )
670+
671+ updated , err := model .GetPayment (ctx , payment .PaymentId )
672+ connect .AssertEqual (t , err , nil )
673+ connect .AssertEqual (t , updated .Completed , false )
674+ connect .AssertEqual (t , updated .Canceled , false )
675+ connect .AssertNotEqual (t , updated .PaymentRecord , nil )
676+ connect .AssertNotEqual (t , updated .TxHash , nil )
677+ connect .AssertNotEqual (t , updated .PaymentReceipt , nil )
678+ connect .AssertEqual (t , * updated .PaymentRecord , "circle-confirmed" )
679+ connect .AssertEqual (t , * updated .TxHash , txHash )
680+ connect .AssertEqual (t , * updated .PaymentReceipt , receipt )
681+ }
682+
683+ {
684+ // Reproduce the old cancel/complete race with the stale payment object
685+ // already held by the worker. A rejected DB completion must not be
686+ // reported as complete, or this worker would silently stop retrying.
687+ payment := insertRetryPayment ("circle-complete-race" , nil )
688+ server .Tx (ctx , func (tx server.PgTx ) {
689+ server .RaisePgResult (tx .Exec (
690+ ctx ,
691+ `UPDATE account_payment SET canceled = true, cancel_time = now() WHERE payment_id = $1` ,
692+ payment .PaymentId ,
693+ ))
694+ })
695+ SetCircleClient (& mockCircleApiClient {
696+ GetTransactionFunc : func (context.Context , string ) (* GetTransactionResult , error ) {
697+ return & GetTransactionResult {
698+ Transaction : CircleTransaction {
699+ State : "COMPLETE" ,
700+ TxHash : "complete-chain-hash" ,
701+ },
702+ ResponseBodyBytes : []byte (`{"state":"COMPLETE"}` ),
703+ }, nil
704+ },
705+ })
706+
707+ complete , canceled , err := advancePayment (payment , clientSession )
708+ connect .AssertNotEqual (t , err , nil )
709+ connect .AssertEqual (t , complete , false )
710+ connect .AssertEqual (t , canceled , false )
711+
712+ updated , err := model .GetPayment (ctx , payment .PaymentId )
713+ connect .AssertEqual (t , err , nil )
714+ connect .AssertEqual (t , updated .Completed , false )
715+ connect .AssertEqual (t , updated .Canceled , true )
716+ connect .AssertNotEqual (t , updated .PaymentRecord , nil )
717+ }
718+
719+ {
720+ oldHash := "mempool-hash"
721+ payment := insertRetryPayment ("circle-cancelled-after-sent" , & oldHash )
722+ const receipt = `{"state":"CANCELLED"}`
723+ SetCircleClient (& mockCircleApiClient {
724+ GetTransactionFunc : func (context.Context , string ) (* GetTransactionResult , error ) {
725+ return & GetTransactionResult {
726+ Transaction : CircleTransaction {State : "CANCELLED" },
727+ ResponseBodyBytes : []byte (receipt ),
728+ }, nil
729+ },
730+ })
731+
732+ complete , canceled , err := advancePayment (payment , clientSession )
733+ connect .AssertNotEqual (t , err , nil )
734+ connect .AssertEqual (t , complete , false )
735+ connect .AssertEqual (t , canceled , false )
736+
737+ updated , err := model .GetPayment (ctx , payment .PaymentId )
738+ connect .AssertEqual (t , err , nil )
739+ connect .AssertEqual (t , updated .Canceled , false )
740+ connect .AssertNotEqual (t , updated .PaymentRecord , nil )
741+ connect .AssertNotEqual (t , updated .TxHash , nil )
742+ connect .AssertNotEqual (t , updated .PaymentReceipt , nil )
743+ connect .AssertEqual (t , * updated .PaymentRecord , "circle-cancelled-after-sent" )
744+ connect .AssertEqual (t , * updated .TxHash , oldHash )
745+ connect .AssertEqual (t , * updated .PaymentReceipt , receipt )
746+ connect .AssertEqual (t , paymentHasIdempotencyKey (payment .PaymentId ), true )
747+ }
748+
749+ {
750+ payment := insertRetryPayment ("circle-cancelled" , nil )
751+ const receipt = `{"state":"CANCELLED"}`
752+ SetCircleClient (& mockCircleApiClient {
753+ GetTransactionFunc : func (context.Context , string ) (* GetTransactionResult , error ) {
754+ return & GetTransactionResult {
755+ Transaction : CircleTransaction {State : "CANCELLED" },
756+ ResponseBodyBytes : []byte (receipt ),
757+ }, nil
758+ },
759+ })
760+
761+ complete , canceled , err := advancePayment (payment , clientSession )
762+ connect .AssertEqual (t , err , nil )
763+ connect .AssertEqual (t , complete , false )
764+ connect .AssertEqual (t , canceled , true )
765+
766+ updated , err := model .GetPayment (ctx , payment .PaymentId )
767+ connect .AssertEqual (t , err , nil )
768+ connect .AssertEqual (t , updated .Canceled , true )
769+ connect .AssertEqual (t , updated .PaymentRecord , nil )
770+ connect .AssertEqual (t , updated .TxHash , nil )
771+ connect .AssertNotEqual (t , updated .PaymentReceipt , nil )
772+ connect .AssertEqual (t , * updated .PaymentReceipt , receipt )
773+ connect .AssertEqual (t , paymentHasIdempotencyKey (payment .PaymentId ), false )
774+ }
775+ })
776+ }
777+
587778func TestFeeToUsd (t * testing.T ) {
588779 server .DefaultTestEnv ().Run (t , func (t testing.TB ) {
589780 coinbaseClient := & mockCoinbaseClient {
0 commit comments