Skip to content

feat: reflect offer-locked balance in indexer (deduct sender at offer, restore on withdraw) #338

Description

@sadiq1971

Background

For offer/accept-flow tokens (e.g. USDCx, via the Splice AllocationFactory/TransferInstruction), creating a transfer offer locks the sender's holding on-ledger but does not move it. The indexer currently derives balances purely from Utility.Registry.Holding create/archive (and CIP-56 TokenTransferEvents), so:

  • The sender's indexed balance only decreases when the offer is accepted (their locked holding is archived and a new one is created for the receiver).
  • Between offer creation and accept, the locked amount still counts toward the sender's indexed balance, even though it is not spendable.

This is misleading: a user who has offered out their entire balance still shows the full balance until the counterparty accepts.

Desired behavior

Track the sender's spendable balance by accounting for the lock at offer time:

  • Offer created (indexer_transfers row goes pending, kind=offer): deduct the amount from the sender's balance.
  • Offer accepted (archive → completed): credit the receiver (as today). The sender was already debited at offer time, so do not debit again.
  • Offer withdrawn / claimed back (sender reclaims a pending/expired offer — see feat: claim back expired offers #292): credit the sender back (the lock is released).
  • Offer expired but not yet withdrawn: amount stays deducted (funds remain locked on-ledger until withdrawn).

Direct CIP-56 transfers (DEMO/PROMPT) settle atomically with no offer, so they are unaffected.

Implementation notes / risks

  • The processor today applies balance deltas from holding create/archive and from CIP-56 events (balanceUpdatesFromEvent, pkg/indexer/engine/processor.go). Moving the sender debit to offer time must not double-count against the holding-archive debit that fires on accept. The offer/holding lifecycle for USDCx needs to be reconciled so each unit is debited exactly once.
  • Decide the model explicitly: either (a) balance = "spendable" (holdings minus outstanding outbound offers), computed by layering offer deltas on top of holding deltas, or (b) drive sender debits from the indexer_transfers offer lifecycle and suppress the holding-archive debit on accept. Whichever is chosen must be idempotent under ledger-stream replay (offers are keyed by contract_id).
  • Must handle the offer terminal states symmetrically: accept (debit stays, receiver credited), withdraw (debit reversed), expiry (debit stays until withdrawn).

Scope

  • Offer/accept-flow (registry/AllocationFactory) tokens only.
  • Indexer processor + balance accounting; no API surface change required (the existing balance endpoints reflect the corrected numbers).

Acceptance

  • Creating an outbound USDCx offer immediately reduces the sender's indexed balance by the offered amount.
  • Accepting credits the receiver without re-debiting the sender; final balances match a settled transfer.
  • Withdrawing/claiming back a pending or expired offer restores the sender's balance.
  • No double-counting and idempotent under reconnect/replay.

Relates to #199 (USDCx) and #292 (claim back expired offers).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type: FeatureAdded to issues and PRs to identify that the change is a new feature.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions