dexprotocol: a reclaimed export cannot become writable

The D->C leg is the sharpest monetary edge in the system. D debits the
owner and writes an object for C to consume; if the write is lost, the
value has left D and never arrives. Refunding on a timer is the obvious
repair and is a double-spend waiting to happen — the moment a refund and
a late delivery can both take effect, the value exists twice.

The property that must hold: after a reclaim, no copy of the original
object, however delayed or retried, can ever become importable on C.

It is structural rather than argued, and the mechanism is who can obtain
the bytes:

  Available --Export--> Pending --Deliver--> Delivered --retry--> C
                           |
                           +---Reclaim---> Available

PendingExport exposes no Claim. There is no accessor, so the object to
write cannot be obtained while a reclaim is still possible. Only Deliver
produces one, and Deliver MOVES the record out of the pending domain, so
a record is either reclaimable or writable and never both. The write is
therefore only ever attempted from a committed Delivered state — the same
commit-then-apply discipline the import leg already uses.

A timeout is not the recovery mechanism. Delivery is retryable from
durable state for as long as it takes, and Redeliver returns a
byte-identical object, which is what makes C's exactly-once import treat
a duplicate arrival as the replay it is rather than a second claim.

Pending value still counts as D's in the rail equation:
imported - delivered == owned + earmarked. It has left the owner's
available balance but has not crossed, and a reclaim brings it back;
counting it as gone would make every open export read as a shortfall.

Six attacks, all refused: deliver after reclaim, redeliver after reclaim,
re-export under a reclaimed id, double reclaim, reclaim after deliver,
and the full two-chain reclaim-plus-late-delivery. Plus 500 randomly
sequenced steps asserting after each that no id is both reclaimable and
writable. 67 tests; the attack run closed at 10000 in - 660 delivered =
9323 owned + 17 earmarked.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-07 16:16:55 -07:00
co-authored by Hanzo Dev
parent 22ee0495b4
commit 9061f81167
4 changed files with 602 additions and 42 deletions
+30 -37
View File
@@ -109,7 +109,7 @@ func (c *Claim) Validate() error {
return nil
}
func (c *Claim) Big() *big.Int { return new(big.Int).SetBytes(c.Amount[:]) }
func (c Claim) Big() *big.Int { return new(big.Int).SetBytes(c.Amount[:]) }
// balance is one owner's holding of one asset on D.
//
@@ -129,13 +129,21 @@ type Custody struct {
chainID ids.ID
balances map[common.Address]map[ids.ID]*balance
consumed map[ids.ID]struct{}
// Export domains — see export.go. A claim id lives in exactly one of them,
// which is what makes reclaimable and writable mutually exclusive.
pending map[ids.ID]PendingExport
delivered map[ids.ID]Deliverable
reclaimed map[ids.ID]Reclaimed
}
func NewCustody(chainID ids.ID) *Custody {
return &Custody{
chainID: chainID,
balances: make(map[common.Address]map[ids.ID]*balance),
consumed: make(map[ids.ID]struct{}),
chainID: chainID,
balances: make(map[common.Address]map[ids.ID]*balance),
consumed: make(map[ids.ID]struct{}),
pending: make(map[ids.ID]PendingExport),
delivered: make(map[ids.ID]Deliverable),
reclaimed: make(map[ids.ID]Reclaimed),
}
}
@@ -179,30 +187,6 @@ func (c *Custody) Import(cl Claim) error {
return nil
}
// Export removes value from D and produces the transfer object C will consume. It
// draws ONLY on available balance: reserved value is committed to open orders and
// cannot leave, which is the second half of "spendable on exactly one chain".
func (c *Custody) Export(owner common.Address, asset ids.ID, amount *big.Int, dest, claimID ids.ID) (Claim, error) {
if amount.Sign() <= 0 {
return Claim{}, ErrClaimAmount
}
b := c.at(owner, asset)
if b.available.Cmp(amount) < 0 {
return Claim{}, fmt.Errorf("%w: have %s available, exporting %s", ErrNoBalance, b.available, amount)
}
b.available.Sub(b.available, amount)
cl := Claim{ClaimID: claimID, Source: c.chainID, Dest: dest, Beneficiary: owner, Asset: asset}
var buf [32]byte
amount.FillBytes(buf[:])
cl.Amount = buf
if err := cl.Validate(); err != nil {
// Put it back rather than leaving value in neither place.
b.available.Add(b.available, amount)
return Claim{}, err
}
return cl, nil
}
// Reserve moves value from available to reserved when an order is placed. Nothing
// leaves the account — this is a change of what the value may be used for, and
// Owned is unchanged by construction.
@@ -288,15 +272,24 @@ func (c *Custody) Owned(asset ids.ID) *big.Int {
return total
}
// Conserved asserts the rail's invariant for one asset: what D owns equals what
// crossed in, minus what crossed out. Callers pass the boundary totals they observed
// so the check is against the RAIL, not against an internal number that would agree
// with itself by construction.
func (c *Custody) Conserved(asset ids.ID, imported, exported *big.Int) error {
want := new(big.Int).Sub(imported, exported)
if got := c.Owned(asset); got.Cmp(want) != 0 {
return fmt.Errorf("dexprotocol: D owns %s of %s, rail says %s in - %s out = %s",
got, asset, imported, exported, want)
// Conserved asserts the rail's invariant for one asset:
//
// imported - delivered == Owned + Earmarked
//
// `delivered` is what was COMMITTED for delivery, not what was merely earmarked: a
// pending export has left the owner's available balance but is still D's and still
// reclaimable, so it counts on D's side of the equation. Leaving it out would make
// every open export look like a shortfall.
//
// The caller supplies the boundary totals it observed, so the check is against the
// RAIL rather than against an internal number that would agree with itself by
// construction.
func (c *Custody) Conserved(asset ids.ID, imported, delivered *big.Int) error {
want := new(big.Int).Sub(imported, delivered)
got := new(big.Int).Add(c.Owned(asset), c.Earmarked(asset))
if got.Cmp(want) != 0 {
return fmt.Errorf("dexprotocol: D holds %s of %s (owned %s + earmarked %s), rail says %s in - %s delivered = %s",
got, asset, c.Owned(asset), c.Earmarked(asset), imported, delivered, want)
}
return nil
}
+29 -5
View File
@@ -100,11 +100,20 @@ func TestReservedValueCannotLeaveD(t *testing.T) {
t.Fatalf("got %v, want ErrNoBalance — reserved value must not leave", err)
}
// Exactly the available amount is fine.
cl, err := c.Export(alice, testUSDC, n(20), cChain, ids.ID{0x01})
p, err := c.Export(alice, testUSDC, n(20), cChain, ids.ID{0x01})
if err != nil {
t.Fatalf("exporting available balance refused: %v", err)
}
if cl.Big().Cmp(n(20)) != 0 || cl.Beneficiary != alice || cl.Source != dChain || cl.Dest != cChain {
if p.Amount().Cmp(n(20)) != 0 || p.Owner() != alice || p.Asset() != testUSDC {
t.Fatalf("malformed pending export: %+v", p)
}
// A pending export must not hand out anything writable — that is what keeps
// reclaim safe. Only Deliver produces the object.
d, err := c.Deliver(p.ClaimID())
if err != nil {
t.Fatal(err)
}
if cl := d.Claim(); cl.Beneficiary != alice || cl.Source != dChain || cl.Dest != cChain {
t.Fatalf("malformed claim: %+v", cl)
}
if got := held(t, c, alice, testUSDC); got.Cmp(n(80)) != 0 {
@@ -203,10 +212,17 @@ func TestRoundTripConserves(t *testing.T) {
}
}
// Take some out.
// Take some out. Export earmarks; only Deliver commits it to the rail, so only
// then does it leave D's side of the equation.
if _, err := c.Export(alice, testUSDC, n(250), cChain, ids.ID{0xE1}); err != nil {
t.Fatal(err)
}
if err := c.Conserved(testUSDC, imported, exported); err != nil {
t.Fatalf("a pending export read as a shortfall: %v", err)
}
if _, err := c.Deliver(ids.ID{0xE1}); err != nil {
t.Fatal(err)
}
exported.Add(exported, n(250))
if err := c.Conserved(testUSDC, imported, exported); err != nil {
t.Fatal(err)
@@ -249,9 +265,17 @@ func TestCustodyConservesUnderRandomOperations(t *testing.T) {
t.Fatalf("step %d unreserve: %v", step, err)
}
case 3:
_, err := c.Export(actor, testUSDC, amt, cChain, seqID(0xE0))
id := seqID(0xE0)
_, err := c.Export(actor, testUSDC, amt, cChain, id)
if err == nil {
exported.Add(exported, amt)
// Commit roughly half of them, leaving the rest pending, so the
// run exercises both columns of the rail equation.
if rng.Intn(2) == 0 {
if _, derr := c.Deliver(id); derr != nil {
t.Fatalf("step %d deliver: %v", step, derr)
}
exported.Add(exported, amt)
}
} else if !errors.Is(err, ErrNoBalance) {
t.Fatalf("step %d export: %v", step, err)
}
+238
View File
@@ -0,0 +1,238 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexprotocol
import (
"errors"
"fmt"
"math/big"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
)
// export.go is the D->C leg, and it exists because that leg has the sharpest
// monetary edge in the system.
//
// THE HAZARD. D debits the owner and writes a transfer object for C to consume. If
// the write is skipped or lost, the value is gone from D and never arrives on C.
// Refunding it on a timer looks like the obvious repair and is a double-spend
// waiting to happen: the moment a refund and a late delivery can both occur, the
// same value exists twice.
//
// THE PROPERTY THAT MUST HOLD:
//
// After a reclaim, no copy of the original transfer object — however delayed,
// retried or replayed — can ever become importable on C.
//
// It is made structural rather than argued, by controlling who can obtain the bytes:
//
// Available --Export--> Pending --Deliver--> Delivered --(retry the write)--> C
// |
// +----Reclaim----> Available
//
// A PendingExport does not expose a Claim. There is no accessor, so the bytes to
// write simply cannot be obtained from it. Only Deliver produces a Deliverable, and
// Deliver MOVES the record out of the pending domain — so a record is either
// reclaimable or writable, never both, and the choice is one D state transition that
// exactly one of the two contenders wins.
//
// The write is therefore attempted only ever from a COMMITTED Delivered state. That
// is the same commit-then-apply discipline the import leg already uses, and it is
// what makes the property above true by construction rather than by scheduling:
// reclaim requires Pending, a write requires Delivered, and nothing is both.
//
// A TIMEOUT IS NOT THE RECOVERY MECHANISM. Delivery is retryable from durable state
// for as long as it takes, because a Delivered record keeps yielding its Deliverable.
// A deadline may exist as a last resort, but the ordinary repair for a lost write is
// to write again — not to wait a week and refund.
// Export storage domains. Transitions MOVE the record; nothing is updated in place.
const (
domainPending = "exports/pending/"
domainDelivered = "exports/delivered/"
domainReclaimed = "exports/reclaimed/"
)
var (
ErrNotPending = errors.New("dexprotocol: no pending export under that claim id")
ErrNotDelivered = errors.New("dexprotocol: no delivered export under that claim id")
ErrExportExists = errors.New("dexprotocol: an export already exists under that claim id")
ErrAlreadyResolved = errors.New("dexprotocol: this export was already delivered or reclaimed")
ErrReclaimDeliverd = errors.New("dexprotocol: a delivered export cannot be reclaimed — retry the write instead")
)
// PendingExport is value that has left the owner's available balance and not yet
// been committed for delivery. It is reclaimable and DELIBERATELY NOT WRITABLE:
// there is no way to get a Claim out of it, so no code path can write the object
// while a reclaim is still possible.
type PendingExport struct {
claim Claim
}
func (p PendingExport) ClaimID() ids.ID { return p.claim.ClaimID }
func (p PendingExport) Owner() common.Address { return p.claim.Beneficiary }
func (p PendingExport) Asset() ids.ID { return p.claim.Asset }
func (p PendingExport) Amount() *big.Int { return p.claim.Big() }
func (p PendingExport) Key() string { return domainPending + p.claim.ClaimID.String() }
// Deliverable is a committed export whose object may be written to shared memory.
// Obtaining one is the ONLY way to reach the bytes, and obtaining one has already
// moved the record out of the reclaimable domain.
//
// It is safe to ask for repeatedly: a lost write is repaired by writing again, and
// the object is identical every time, so C's exactly-once import handles a duplicate
// arrival as the replay it is.
type Deliverable struct {
claim Claim
}
func (d Deliverable) Claim() Claim { return d.claim }
func (d Deliverable) ClaimID() ids.ID { return d.claim.ClaimID }
func (d Deliverable) Key() string { return domainDelivered + d.claim.ClaimID.String() }
// Reclaimed is terminal. There is no function from Reclaimed back to Pending or
// Delivered, so a reclaimed export can never produce a writable object.
type Reclaimed struct {
claim Claim
}
func (r Reclaimed) ClaimID() ids.ID { return r.claim.ClaimID }
func (r Reclaimed) Key() string { return domainReclaimed + r.claim.ClaimID.String() }
// Export debits available balance and opens a PENDING export. It writes nothing and
// produces nothing writable — the object cannot be delivered until Deliver commits
// the record, which is what leaves a clean window in which reclaiming is safe.
//
// Reserved balance is untouchable here, as everywhere: value backing an open order
// cannot also leave the chain.
func (c *Custody) Export(owner common.Address, asset ids.ID, amount *big.Int, dest, claimID ids.ID) (PendingExport, error) {
if amount.Sign() <= 0 {
return PendingExport{}, ErrClaimAmount
}
if c.exportResolved(claimID) {
return PendingExport{}, fmt.Errorf("%w: %s", ErrExportExists, claimID)
}
if _, open := c.pending[claimID]; open {
return PendingExport{}, fmt.Errorf("%w: %s", ErrExportExists, claimID)
}
cl := Claim{ClaimID: claimID, Source: c.chainID, Dest: dest, Beneficiary: owner, Asset: asset}
var buf [32]byte
amount.FillBytes(buf[:])
cl.Amount = buf
if err := cl.Validate(); err != nil {
return PendingExport{}, err
}
b := c.at(owner, asset)
if b.available.Cmp(amount) < 0 {
return PendingExport{}, fmt.Errorf("%w: have %s available, exporting %s", ErrNoBalance, b.available, amount)
}
b.available.Sub(b.available, amount)
p := PendingExport{claim: cl}
c.pending[claimID] = p
return p, nil
}
// Deliver commits a pending export and yields the writable object. This is the
// transition that makes the export irreversible on D: afterwards the value is
// C's problem to receive, and D's only remaining job is to keep trying to hand it
// over.
func (c *Custody) Deliver(claimID ids.ID) (Deliverable, error) {
p, ok := c.pending[claimID]
if !ok {
if c.exportResolved(claimID) {
return Deliverable{}, fmt.Errorf("%w: %s", ErrAlreadyResolved, claimID)
}
return Deliverable{}, fmt.Errorf("%w: %s", ErrNotPending, claimID)
}
d := Deliverable{claim: p.claim}
delete(c.pending, claimID)
c.delivered[claimID] = d
return d, nil
}
// Redeliver hands back the object for a committed export whose write did not land.
// This is the ORDINARY repair for a lost write. It is idempotent by nature — the
// object is byte-identical — and C's import is exactly-once, so a duplicate arrival
// is refused there rather than credited twice.
func (c *Custody) Redeliver(claimID ids.ID) (Deliverable, error) {
d, ok := c.delivered[claimID]
if !ok {
return Deliverable{}, fmt.Errorf("%w: %s", ErrNotDelivered, claimID)
}
return d, nil
}
// Reclaim returns a PENDING export to available balance. It refuses a delivered
// export outright: once the object is obtainable, refunding it would let a late
// write and a refund both take effect, which is the double-spend this whole file
// exists to prevent. The repair for a stuck delivery is Redeliver, not Reclaim.
func (c *Custody) Reclaim(claimID ids.ID) (Reclaimed, error) {
if _, ok := c.delivered[claimID]; ok {
return Reclaimed{}, fmt.Errorf("%w: %s", ErrReclaimDeliverd, claimID)
}
p, ok := c.pending[claimID]
if !ok {
if _, done := c.reclaimed[claimID]; done {
return Reclaimed{}, fmt.Errorf("%w: %s", ErrAlreadyResolved, claimID)
}
return Reclaimed{}, fmt.Errorf("%w: %s", ErrNotPending, claimID)
}
b := c.at(p.claim.Beneficiary, p.claim.Asset)
b.available.Add(b.available, p.claim.Big())
delete(c.pending, claimID)
r := Reclaimed{claim: p.claim}
c.reclaimed[claimID] = r
return r, nil
}
func (c *Custody) exportResolved(claimID ids.ID) bool {
if _, ok := c.delivered[claimID]; ok {
return true
}
_, ok := c.reclaimed[claimID]
return ok
}
// InFlight is the total committed for delivery but not yet known to have arrived —
// the D->C leg of the cross-chain conservation sum. Pending exports are NOT in
// flight: they are still D's, merely earmarked, and a reclaim returns them.
func (c *Custody) InFlight(asset ids.ID) *big.Int {
total := new(big.Int)
for _, d := range c.delivered {
if d.claim.Asset == asset {
total.Add(total, d.claim.Big())
}
}
return total
}
// Earmarked is the total sitting in pending exports — debited from available,
// not yet committed, still reclaimable.
func (c *Custody) Earmarked(asset ids.ID) *big.Int {
total := new(big.Int)
for _, p := range c.pending {
if p.claim.Asset == asset {
total.Add(total, p.claim.Big())
}
}
return total
}
// ExportsExclusive asserts that a claim id lives in exactly one export domain. An id
// that were both pending and delivered would be simultaneously reclaimable and
// writable, which is precisely the state that permits the double-spend.
func (c *Custody) ExportsExclusive() error {
for id := range c.pending {
if c.exportResolved(id) {
return fmt.Errorf("dexprotocol: export %s is pending AND resolved", id)
}
}
for id := range c.delivered {
if _, ok := c.reclaimed[id]; ok {
return fmt.Errorf("dexprotocol: export %s is delivered AND reclaimed", id)
}
}
return nil
}
+305
View File
@@ -0,0 +1,305 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexprotocol
import (
"errors"
"math/big"
"math/rand"
"testing"
"github.com/luxfi/ids"
)
// export_test.go attacks the D->C leg. The property under attack:
//
// After a reclaim, no copy of the original transfer object — however delayed,
// retried or replayed — can ever become importable on C.
//
// The classic way to break it is reclaim-plus-late-delivery: refund on D, then have
// the original write land on C anyway. Every test below tries to reach that state.
func fund(t *testing.T, c *Custody, amt int64) {
t.Helper()
if err := c.Import(claim(1, alice, testUSDC, amt)); err != nil {
t.Fatal(err)
}
}
// THE ADVERSARIAL CORE. A reclaimed export must never yield a writable object, by
// any route: not Deliver, not Redeliver, not a second Export reusing the id.
func TestReclaimedExportCanNeverBecomeWritable(t *testing.T) {
c := NewCustody(dChain)
fund(t, c, 100)
id := ids.ID{0xE1}
p, err := c.Export(alice, testUSDC, n(100), cChain, id)
if err != nil {
t.Fatal(err)
}
if p.ClaimID() != id {
t.Fatal("pending export lost its id")
}
if _, err := c.Reclaim(id); err != nil {
t.Fatal(err)
}
// Attack 1: deliver the reclaimed export.
if _, err := c.Deliver(id); !errors.Is(err, ErrAlreadyResolved) {
t.Fatalf("Deliver after Reclaim: got %v, want ErrAlreadyResolved", err)
}
// Attack 2: ask for a redelivery of it.
if _, err := c.Redeliver(id); !errors.Is(err, ErrNotDelivered) {
t.Fatalf("Redeliver after Reclaim: got %v, want ErrNotDelivered", err)
}
// Attack 3: re-export under the same id to manufacture a fresh object.
if _, err := c.Export(alice, testUSDC, n(100), cChain, id); !errors.Is(err, ErrExportExists) {
t.Fatalf("re-Export under a reclaimed id: got %v, want ErrExportExists", err)
}
// Attack 4: reclaim twice, to double the refund.
if _, err := c.Reclaim(id); !errors.Is(err, ErrAlreadyResolved) {
t.Fatalf("second Reclaim: got %v, want ErrAlreadyResolved", err)
}
if got := avail(t, c, alice, testUSDC); got.Cmp(n(100)) != 0 {
t.Fatalf("alice has %s after one reclaim, want exactly 100", got)
}
if err := c.ExportsExclusive(); err != nil {
t.Fatal(err)
}
}
// The mirror attack: a delivered export must never be refundable. Once the object is
// obtainable, a refund would let a late write and the refund both take effect.
func TestDeliveredExportCanNeverBeReclaimed(t *testing.T) {
c := NewCustody(dChain)
fund(t, c, 100)
id := ids.ID{0xE2}
if _, err := c.Export(alice, testUSDC, n(100), cChain, id); err != nil {
t.Fatal(err)
}
d, err := c.Deliver(id)
if err != nil {
t.Fatal(err)
}
if d.Claim().Big().Cmp(n(100)) != 0 {
t.Fatal("deliverable lost its amount")
}
if _, err := c.Reclaim(id); !errors.Is(err, ErrReclaimDeliverd) {
t.Fatalf("Reclaim after Deliver: got %v, want ErrReclaimDeliverd", err)
}
if got := avail(t, c, alice, testUSDC); got.Sign() != 0 {
t.Fatalf("a refused reclaim credited %s back", got)
}
if err := c.ExportsExclusive(); err != nil {
t.Fatal(err)
}
}
// A lost write is repaired by writing again, for as long as it takes. Redelivery
// must hand back a BYTE-IDENTICAL object, because C's exactly-once import is what
// makes a duplicate arrival harmless — a different object would be a second claim.
func TestRedeliveryIsTheOrdinaryRepairAndIsIdentical(t *testing.T) {
c := NewCustody(dChain)
fund(t, c, 100)
id := ids.ID{0xE3}
if _, err := c.Export(alice, testUSDC, n(100), cChain, id); err != nil {
t.Fatal(err)
}
first, err := c.Deliver(id)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
again, err := c.Redeliver(id)
if err != nil {
t.Fatalf("retry %d refused: %v", i, err)
}
if again.Claim() != first.Claim() {
t.Fatalf("retry %d produced a different object", i)
}
}
// And the receiving side treats the duplicates as the replay they are.
dest := NewCustody(cChain)
if err := dest.Import(first.Claim()); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if err := dest.Import(first.Claim()); !errors.Is(err, ErrClaimConsumed) {
t.Fatalf("duplicate arrival %d: got %v, want ErrClaimConsumed", i, err)
}
}
if got := avail(t, dest, alice, testUSDC); got.Cmp(n(100)) != 0 {
t.Fatalf("five writes and four arrivals credited %s, want 100", got)
}
}
// THE FULL DOUBLE-SPEND ATTEMPT, end to end across two chains. Reclaim on D, then
// try to make the object land on C anyway. The value must exist in exactly one place
// at the end.
func TestReclaimAndLateDeliveryCannotBothTakeEffect(t *testing.T) {
d := NewCustody(dChain)
cSide := NewCustody(cChain)
fund(t, d, 100)
id := ids.ID{0xE4}
if _, err := d.Export(alice, testUSDC, n(100), cChain, id); err != nil {
t.Fatal(err)
}
// The relayer is slow. D gives up and reclaims.
if _, err := d.Reclaim(id); err != nil {
t.Fatal(err)
}
// The relayer now tries to obtain the bytes it never got. There is no route:
// a PendingExport never exposed a Claim, and the record is no longer pending.
if _, err := d.Deliver(id); err == nil {
t.Fatal("a reclaimed export produced a writable object — this is the double-spend")
}
if _, err := d.Redeliver(id); err == nil {
t.Fatal("a reclaimed export produced a writable object via Redeliver")
}
// Value exists once, on D.
if got := avail(t, d, alice, testUSDC); got.Cmp(n(100)) != 0 {
t.Fatalf("D holds %s, want 100", got)
}
if _, _, ok := cSide.Balance(alice, testUSDC); ok {
t.Fatal("C credited something for an export that was reclaimed")
}
total := new(big.Int).Add(d.Owned(testUSDC), cSide.Owned(testUSDC))
if total.Cmp(n(100)) != 0 {
t.Fatalf("the value exists %s times over", total)
}
}
// Reserved balance cannot be exported even into a pending record — the earmark must
// not be a way around the reservation.
func TestExportCannotEarmarkReservedValue(t *testing.T) {
c := NewCustody(dChain)
fund(t, c, 100)
if err := c.Reserve(alice, testUSDC, n(90)); err != nil {
t.Fatal(err)
}
if _, err := c.Export(alice, testUSDC, n(11), cChain, ids.ID{0xE5}); !errors.Is(err, ErrNoBalance) {
t.Fatalf("got %v, want ErrNoBalance", err)
}
if got := held(t, c, alice, testUSDC); got.Cmp(n(90)) != 0 {
t.Fatalf("reserved is %s after a refused export, want 90", got)
}
}
// Pending value is still D's — it must count on D's side of the rail equation, or
// every open export would read as a shortfall.
func TestPendingValueStillCountsAsDs(t *testing.T) {
c := NewCustody(dChain)
fund(t, c, 100)
imported, delivered := n(100), new(big.Int)
if err := c.Conserved(testUSDC, imported, delivered); err != nil {
t.Fatal(err)
}
if _, err := c.Export(alice, testUSDC, n(40), cChain, ids.ID{0xE6}); err != nil {
t.Fatal(err)
}
// Debited from available, still D's.
if got := avail(t, c, alice, testUSDC); got.Cmp(n(60)) != 0 {
t.Fatalf("available %s, want 60", got)
}
if got := c.Earmarked(testUSDC); got.Cmp(n(40)) != 0 {
t.Fatalf("earmarked %s, want 40", got)
}
if err := c.Conserved(testUSDC, imported, delivered); err != nil {
t.Fatalf("a pending export read as a shortfall: %v", err)
}
// Committing it moves the value out of D's column and into the rail's.
if _, err := c.Deliver(ids.ID{0xE6}); err != nil {
t.Fatal(err)
}
delivered.Add(delivered, n(40))
if got := c.InFlight(testUSDC); got.Cmp(n(40)) != 0 {
t.Fatalf("in flight %s, want 40", got)
}
if err := c.Conserved(testUSDC, imported, delivered); err != nil {
t.Fatal(err)
}
// Reclaiming it after delivery is refused, so the columns cannot both hold it.
if _, err := c.Reclaim(ids.ID{0xE6}); !errors.Is(err, ErrReclaimDeliverd) {
t.Fatalf("got %v, want ErrReclaimDeliverd", err)
}
}
// Random adversarial sequencing. Every export is driven through an arbitrary mix of
// deliver, redeliver, reclaim and re-export attempts, asserting after each step that
// no id is ever both reclaimable and writable, and that the rail equation holds.
func TestExportLifecycleUnderRandomAttack(t *testing.T) {
c := NewCustody(dChain)
rng := rand.New(rand.NewSource(17))
imported, delivered := new(big.Int), new(big.Int)
// Seed with plenty of balance.
for i := 0; i < 10; i++ {
cl := claim(byte(i), alice, testUSDC, 1000)
if err := c.Import(cl); err != nil {
t.Fatal(err)
}
imported.Add(imported, n(1000))
}
seq := 0
writable := map[ids.ID]bool{}
for step := 0; step < 500; step++ {
seq++
id := ids.ID{0xE0, byte(seq), byte(seq >> 8)}
switch rng.Intn(4) {
case 0: // open one
if _, err := c.Export(alice, testUSDC, n(int64(rng.Intn(20)+1)), cChain, id); err != nil &&
!errors.Is(err, ErrNoBalance) {
t.Fatalf("step %d export: %v", step, err)
}
case 1: // commit an arbitrary open one
for open := range c.pending {
d, err := c.Deliver(open)
if err != nil {
t.Fatalf("step %d deliver: %v", step, err)
}
delivered.Add(delivered, d.Claim().Big())
writable[open] = true
break
}
case 2: // reclaim an arbitrary open one
for open := range c.pending {
if _, err := c.Reclaim(open); err != nil {
t.Fatalf("step %d reclaim: %v", step, err)
}
if writable[open] {
t.Fatalf("step %d: reclaimed an id that was already writable", step)
}
break
}
case 3: // try to reclaim something committed — must always fail
for done := range c.delivered {
if _, err := c.Reclaim(done); !errors.Is(err, ErrReclaimDeliverd) {
t.Fatalf("step %d: committed export was reclaimable: %v", step, err)
}
break
}
}
if err := c.ExportsExclusive(); err != nil {
t.Fatalf("step %d: %v", step, err)
}
if err := c.Conserved(testUSDC, imported, delivered); err != nil {
t.Fatalf("step %d: %v", step, err)
}
if err := c.NoNegative(); err != nil {
t.Fatalf("step %d: %v", step, err)
}
}
t.Logf("imported %s, delivered %s, owned %s, earmarked %s",
imported, delivered, c.Owned(testUSDC), c.Earmarked(testUSDC))
}