Skip to content

Commit 8a39cc8

Browse files
authored
feat: exclude offer-locked holdings from indexed balances (#340)
* feat(indexer): exclude offer-locked holdings from balances Offer-based transfers (USDCx/AllocationFactory) lock the sender's holding when an offer is created; the locked amount is escrowed, not spendable. The indexer derived balances purely from holding create/archive, so the sender's balance only dropped at accept — the offered amount still showed as available while the offer was pending. Capture the holding's `lock` field in the decoder and skip locked holdings in the processor (neither stored nor counted). Because a locked holding is never stored, its later archive is a no-op, and the matching unlocked holding — created for the receiver on accept or returned to the sender on claim-back — drives the balance change. Net effect: the sender's balance is reduced when the offer locks the funds, credited to the receiver on accept, and restored to the sender on claim-back. Direct CIP-56 transfers are unaffected (their holdings are unlocked). No migration: locked holdings are simply not persisted. Implements #338. * test(e2e): assert offer-time balance deduction in P1↔P1 USDCx transfer Extend TestUSDCx_InternalTransfer_P1HolderToP1Holder: after User1 offers 10 of its 15 USDCx to User2 (and before User2 accepts), assert User1's spendable balance has already dropped to 5 — confirming the indexer excludes the locked (escrowed) holding from balances at offer time, not only on accept. Adds WaitForAPIBalanceExact (the existing >= helper can't detect a decrease).
1 parent b1e6361 commit 8a39cc8

7 files changed

Lines changed: 153 additions & 0 deletions

File tree

pkg/indexer/engine/decoder.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ func NewHoldingDecoder(
221221
change.InstrumentAdmin = ev.PartyField("registrar")
222222
change.InstrumentID = ev.NestedTextField("instrument", "id")
223223
change.Amount = ev.NumericField("amount")
224+
// `lock` is Optional Lock: Some => the holding is escrowed by an
225+
// outstanding offer (not spendable), None => freely spendable.
226+
change.Locked = !ev.IsNone("lock")
224227
if change.Owner == "" || change.InstrumentID == "" {
225228
logger.Warn("Holding CREATED decoded with empty owner or instrument — field-name mismatch?",
226229
zap.String("contract_id", ev.ContractID),

pkg/indexer/engine/decoder_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,29 @@ func decodeAll(decode func(*streaming.LedgerTransaction, *streaming.LedgerEvent)
7575
// Decoder tests
7676
// ---------------------------------------------------------------------------
7777

78+
func makeHoldingEvent(contractID string, lock streaming.FieldValue) *streaming.LedgerEvent {
79+
return streaming.NewLedgerEvent(contractID, "pkg-id", holdingModule, holdingEntity, true,
80+
map[string]streaming.FieldValue{
81+
"owner": streaming.MakePartyField("alice::1220"),
82+
"registrar": streaming.MakePartyField("admin::1220"),
83+
"instrument": streaming.MakeRecordField(map[string]streaming.FieldValue{"id": streaming.MakeTextField("USDCx")}),
84+
"amount": streaming.MakeNumericField("10"),
85+
"lock": lock,
86+
})
87+
}
88+
89+
func TestHoldingDecoder_LockField(t *testing.T) {
90+
dec := NewHoldingDecoder("pkg-id", zap.NewNop())
91+
92+
unlocked, ok := dec(makeTx(1), makeHoldingEvent("h-unlocked", streaming.MakeNoneField()))
93+
require.True(t, ok)
94+
assert.False(t, unlocked.Locked, "None lock => spendable")
95+
96+
locked, ok := dec(makeTx(2), makeHoldingEvent("h-locked", streaming.MakeSomeRecordField(map[string]streaming.FieldValue{})))
97+
require.True(t, ok)
98+
assert.True(t, locked.Locked, "Some lock => escrowed")
99+
}
100+
78101
func TestDecoder_FilterModeAll_Mint(t *testing.T) {
79102
decode := NewTokenTransferDecoder(indexer.FilterModeAll, nil, zap.NewNop())
80103

pkg/indexer/engine/processor.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,15 @@ func (p *Processor) processHoldingChange(ctx context.Context, tx Store, h *index
369369
// Malformed CREATED event already logged by decoder; skip silently.
370370
return nil
371371
}
372+
if h.Locked {
373+
// Escrowed by an outstanding transfer offer — not spendable, so excluded from
374+
// balances. We also don't store it: its archive (on accept or claim-back) then
375+
// finds no row and is skipped, and the matching unlocked holding created for
376+
// the receiver (accept) or returned to the sender (claim-back) drives the
377+
// balance change. Net effect: the sender's balance drops when the offer locks
378+
// the funds and is restored only if the offer is claimed back.
379+
return nil
380+
}
372381
if err := tx.InsertHolding(ctx, h); err != nil {
373382
return fmt.Errorf("insert holding %s: %w", h.ContractID, err)
374383
}

pkg/indexer/engine/processor_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ func makeOfferBatch(offset int64, offers ...*indexer.Transfer) *streaming.Batch[
106106
}
107107
}
108108

109+
func makeHoldingBatch(offset int64, holdings ...*indexer.HoldingChange) *streaming.Batch[any] {
110+
items := make([]any, len(holdings))
111+
for i, h := range holdings {
112+
items[i] = h
113+
}
114+
return &streaming.Batch[any]{
115+
Offset: offset,
116+
UpdateID: "update-" + string(rune('0'+offset)),
117+
Items: items,
118+
}
119+
}
120+
109121
func pendingOffer() *indexer.Transfer {
110122
return &indexer.Transfer{
111123
ContractID: "offer-contract-1",
@@ -419,6 +431,60 @@ func TestProcessor_Run_OfferArchived_CompletesTransfer(t *testing.T) {
419431
require.NoError(t, engine.NewProcessor(fetcher, store, engine.NewNopMetrics(), zap.NewNop()).Run(context.Background()))
420432
}
421433

434+
func TestProcessor_Run_LockedHoldingCreated_SkippedFromBalance(t *testing.T) {
435+
store := mocks.NewStore(t)
436+
fetcher := mocks.NewEventFetcher(t)
437+
// A locked holding is escrowed by an outstanding offer; it must not be stored or
438+
// counted toward balances (so the sender's balance reflects the offered amount as
439+
// already deducted from offer creation).
440+
locked := &indexer.HoldingChange{
441+
ContractID: "holding-locked-1",
442+
Owner: testSender,
443+
InstrumentAdmin: testInstrumentAdmin,
444+
InstrumentID: testInstrumentID,
445+
Amount: testAmount,
446+
LedgerOffset: 12,
447+
Locked: true,
448+
}
449+
450+
store.EXPECT().LatestOffset(mock.Anything).Return(int64(0), nil)
451+
fetcher.EXPECT().Start(mock.Anything, int64(0))
452+
fetcher.EXPECT().Events().Return(feedCh(makeHoldingBatch(12, locked)))
453+
454+
setupRunInTx(store)
455+
// No InsertHolding / ApplyBalanceDelta expected: the strict mock fails if either
456+
// is called. Only the offset advances.
457+
store.EXPECT().SaveOffset(mock.Anything, int64(12)).Return(nil)
458+
459+
require.NoError(t, engine.NewProcessor(fetcher, store, engine.NewNopMetrics(), zap.NewNop()).Run(context.Background()))
460+
}
461+
462+
func TestProcessor_Run_UnlockedHoldingCreated_Tracked(t *testing.T) {
463+
store := mocks.NewStore(t)
464+
fetcher := mocks.NewEventFetcher(t)
465+
// An unlocked (spendable) holding is stored and credited to the owner's balance.
466+
h := &indexer.HoldingChange{
467+
ContractID: "holding-1",
468+
Owner: testRecipient,
469+
InstrumentAdmin: testInstrumentAdmin,
470+
InstrumentID: testInstrumentID,
471+
Amount: testAmount,
472+
LedgerOffset: 13,
473+
Locked: false,
474+
}
475+
476+
store.EXPECT().LatestOffset(mock.Anything).Return(int64(0), nil)
477+
fetcher.EXPECT().Start(mock.Anything, int64(0))
478+
fetcher.EXPECT().Events().Return(feedCh(makeHoldingBatch(13, h)))
479+
480+
setupRunInTx(store)
481+
store.EXPECT().InsertHolding(mock.Anything, h).Return(nil)
482+
store.EXPECT().ApplyBalanceDelta(mock.Anything, testRecipient, testInstrumentAdmin, testInstrumentID, testAmount).Return(nil)
483+
store.EXPECT().SaveOffset(mock.Anything, int64(13)).Return(nil)
484+
485+
require.NoError(t, engine.NewProcessor(fetcher, store, engine.NewNopMetrics(), zap.NewNop()).Run(context.Background()))
486+
}
487+
422488
func TestProcessor_Run_MixedBatch_ParsedEventAndOffer(t *testing.T) {
423489
store := mocks.NewStore(t)
424490
fetcher := mocks.NewEventFetcher(t)

pkg/indexer/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,12 @@ type HoldingChange struct {
141141
InstrumentAdmin string
142142
InstrumentID string
143143
Amount string
144+
// Locked is true when the holding carries a lock (DAML `lock` is Some) — i.e. it
145+
// is escrowed by an outstanding transfer offer rather than spendable. Locked
146+
// holdings are excluded from indexed balances so a sender's balance reflects the
147+
// offered amount as deducted from offer creation until accept or claim-back.
148+
// Only meaningful for CREATED events.
149+
Locked bool
144150
}
145151

146152
// InstrumentKey is the Canton equivalent of an ERC-20 contract address.

tests/e2e/devstack/dsl/dsl.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,46 @@ func (d *DSL) WaitForAPIBalance(ctx context.Context, t *testing.T, tok *stack.To
232232
minTokens, tok.Symbol, minWei, ownerAddr.Hex())
233233
}
234234

235+
// WaitForAPIBalanceExact polls the api-server until ownerAddr's balance of tok equals
236+
// exactly tokens (scaled by the token's decimals), or the timeout is reached. Unlike
237+
// WaitForAPIBalance (>=), this detects a decrease — e.g. funds becoming unspendable
238+
// when an offer locks them — so it converges on the exact expected value.
239+
func (d *DSL) WaitForAPIBalanceExact(ctx context.Context, t *testing.T, tok *stack.Token, ownerAddr common.Address, tokens string) {
240+
t.Helper()
241+
exp := new(big.Int).Exp(big.NewInt(decimalBase), big.NewInt(int64(tok.Decimals)), nil)
242+
wantF, ok := new(big.Float).SetString(tokens)
243+
if !ok {
244+
t.Fatalf("WaitForAPIBalanceExact: invalid amount %q", tokens)
245+
}
246+
wantF.Mul(wantF, new(big.Float).SetInt(exp))
247+
wantWei, _ := wantF.Int(nil)
248+
249+
deadline := time.Now().Add(waitForAPIBalanceTimeout)
250+
ticker := time.NewTicker(pollInterval)
251+
defer ticker.Stop()
252+
var lastBal *big.Int
253+
for time.Now().Before(deadline) {
254+
bal, err := d.apiServer.ERC20Balance(ctx, tok.Address, ownerAddr)
255+
if err == nil {
256+
lastBal = bal
257+
if bal.Cmp(wantWei) == 0 {
258+
return
259+
}
260+
}
261+
select {
262+
case <-ctx.Done():
263+
t.Fatal("context canceled waiting for exact API balance")
264+
case <-ticker.C:
265+
}
266+
}
267+
last := "<none>"
268+
if lastBal != nil {
269+
last = lastBal.String()
270+
}
271+
t.Fatalf("WaitForAPIBalanceExact: timed out waiting for %s %s balance == %s (owner %s): last seen %s",
272+
tokens, tok.Symbol, wantWei, ownerAddr.Hex(), last)
273+
}
274+
235275
// ERC20Balance returns the on-chain ERC-20 balance of account for tokenAddr.
236276
func (d *DSL) ERC20Balance(ctx context.Context, t *testing.T, tokenAddr common.Address, account stack.Account) *big.Int {
237277
t.Helper()

tests/e2e/tests/api/cross_transfer_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ func TestUSDCx_InternalTransfer_P1HolderToP1Holder(t *testing.T) {
187187
t.Fatalf("expected status 'completed', got %q", execResp.Status)
188188
}
189189

190+
// Offering locks User1's 10 USDCx: the indexer excludes the escrowed (locked)
191+
// holding from balances, so User1's spendable balance drops 15 → 5 immediately at
192+
// offer time — before User2 accepts. (Without offer-locked accounting it would
193+
// still read 15 here and only drop on accept.)
194+
sys.DSL.WaitForAPIBalanceExact(ctx, t, &sys.Tokens.USDCx, sys.Accounts.User1.Address, "5")
195+
190196
// User2 accepts the inbound offer via the api-server incoming accept flow.
191197
cid2 := sys.DSL.WaitForIncomingTransferOffer(ctx, t, sys.Accounts.User2)
192198
prepAccept2, err := sys.APIServer.PrepareAcceptTransfer(ctx, &sys.Accounts.User2, cid2,

0 commit comments

Comments
 (0)