Files
commerce/billing/gateway/review_hold_test.go
zeekay 6556ae89e3 gateway: a hold reserved on a row that went onchain is released; an amount has at most 78 digits
A credit() that read a row while it was still locked at Circle could finish
after the reconciler settled it: the room it reserved was never released,
since a settled, landed row is never swept again, and the payer's cap
shrank for good. The mark-owed step now releases when the row it gets back
is already onchain. A base-unit amount longer than 2^256 has digits is
refused before it is parsed.

Adds Red's round-three tests: the hold leak, and the clawback that keeps
failing and is retried with backoff until the ledger answers.

Claude-Session: https://claude.ai/code/session_015XYKLoQYzUebNbaqKWPw2C
2026-10-29 23:33:29 -07:00

95 lines
2.8 KiB
Go

package gateway
import (
"context"
"strings"
"testing"
"github.com/hanzoai/commerce/models/remittance"
"github.com/hanzoai/commerce/payment/providers/circle"
)
// A stale credit() (read while the row was Accepted/received, not credited)
// finishing AFTER the reconciler moved the row to Settled and landed it:
// reserve() adds a hold item, the Credited swap is a no-op (already credited),
// and nothing ever releases the item: Settled+Landed rows are never swept.
func TestHoldLeaksOnSettledRow(t *testing.T) {
r := newRig(t, circle.Mainnet, true)
ctx := context.Background()
vA, err := r.submit(r.pay(MaxCents, nil))
if err != nil || !vA.Credited {
t.Fatalf("A: %+v %v", vA, err)
}
vB, err := r.submit(r.pay(500, nil))
if err != nil || !vB.Deferred {
t.Fatalf("B: %+v %v", vB, err)
}
stale := r.byID(vB.ID)
r.gw.mark(vA.Payment, circle.Confirmed)
r.gw.mark(vB.Payment, circle.Completed)
r.pass()
if got := r.byID(vB.ID); got.Status != remittance.Settled || !got.Landed {
t.Fatalf("setup: %+v", got)
}
r.s.credit(ctx, stale)
for i := 0; i < 5; i++ {
r.pass()
}
items := holdItems(t, r.byID(vB.ID))
t.Logf("hold after: %v ledger=%d", items, r.credited())
if _, ok := items[vB.ID]; ok {
t.Errorf("LEAK: settled row %s holds %d cents of the payer's unconfirmed cap forever", vB.ID, items[vB.ID])
}
}
// Gateway failed an accepted, credited payment and the clawback keeps failing:
// the row must stay open, be retried with backoff (Due moves), and finish once
// the ledger answers.
func TestUnreversibleRowBacksOffThenFinishes(t *testing.T) {
r := newRig(t, circle.Mainnet, true)
v, err := r.submit(r.pay(600, nil))
if err != nil || !v.Credited {
t.Fatalf("submit: %+v %v", v, err)
}
r.book.mu.Lock()
r.book.broken = true
r.book.mu.Unlock()
r.gw.mark(v.Payment, circle.Failed)
var dues []string
for i := 0; i < 8; i++ {
r.pass()
got := r.byID(v.ID)
if got.Status != remittance.Accepted || !got.Credited || got.Reversed {
t.Fatalf("pass %d: %+v", i, got)
}
dues = append(dues, got.Due)
}
for i := 1; i < len(dues); i++ {
if dues[i] <= dues[i-1] {
t.Errorf("Due did not move: %v", dues)
}
}
r.book.mu.Lock()
r.book.broken = false
r.book.mu.Unlock()
for i := 0; i < 3; i++ {
r.pass()
}
end := r.byID(v.ID)
if end.Status != remittance.Failed || !end.Reversed || r.credited() != 0 {
t.Fatalf("end: %+v ledger %d", end, r.credited())
}
}
// An amount longer than 2^256 has digits is no amount: it fails without being
// parsed, and the same value written with leading zeroes still matches.
func TestAnAmountLongerThanAWordIsNotAnAmount(t *testing.T) {
long := strings.Repeat("1", 79)
if sameAmount(long, long) {
t.Fatal("a 79-digit string matched itself")
}
if !sameAmount("00010000", "10000") {
t.Fatal("leading zeroes no longer compare by value")
}
}