mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
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:
+30
-37
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user