Receiver-Hook Staged Delivery and Single-Accounting Ownership

  • Name: receiver-hook staged delivery and single-accounting ownership
  • Category: callback-systems reference / receiver-hook design note / NFT and multi-token issuance-control reference / configure-before-deliver discipline
  • Summary: ERC-721, ERC-1155, and ERC-3525 receiver hooks should see assets only after the protocol has already decided what those assets mean and who is allowed to account for them. A healthy design does not let safeTransferFrom(), _safeMint(), or analogous receiver-hook delivery expose half-configured receipts, half-cleared collateral state, half-advanced id allocators, or one inbound transfer that both the outer frame and the receiver hook still believe they may credit. The positive rule is simple but easy to violate: configure first, advance the relevant counters and flags, choose one accounting owner for the transfer, then deliver. If the hook must participate, it should resume one narrowly staged state machine, not discover or redefine the economics of the object it just received.
  • Why this note exists:
    • Several callback incidents are not best understood as generic reentrancy happened stories. The sharper design failure is that a standardized receiver hook saw an asset before its economic meaning, namespace, or collateral consequences were final.
    • Omni Protocol shows the outbound-collateral version: the NFT was delivered before usingAsCollateral had been conclusively retired.
    • Revest Finance shows the issuance-side namespace version: the receiver hook could revisit state before the FNFT id and configuration had become a finished object.
    • Solv Protocol BRO shows the protocol-self receiver version: one incoming wrapped position was visible to a hook that minted immediately while the still-live outer frame still believed it owned mint accounting for the same transfer.

The four things that should already be true before a receiver hook can matter

  1. Object identity is final

    • The id, series, vault slot, tranche, collateral item, or receipt namespace must already be the canonical one.
    • The hook should not be able to race nextId, fnftsCreated, split-id derivation, or other namespace allocators.
  2. Economic meaning is final

    • If the delivered token represents collateral, a claim, a reserve-backed share, a vesting series, or a liquidation result, that meaning should already be fixed before the hook sees it.
    • No callback-visible token should still be waiting for its cap, exchange rule, unlock rule, redemption config, or collateral status to be written.
  3. Solvency and entitlement state is final enough

    • Outbound delivery should not occur while debt gating, collateral-membership flags, or consumed-entitlement markers still describe the old world.
    • Inbound delivery should not occur while issuance rights, claim rights, or mint caps are still undecided.
  4. Exactly one accounting owner exists for the transfer

    • Either the outer frame accounts for the transfer and the receiver hook only validates / routes, or the receiver hook is the sole accounting owner and the outer frame no longer plans to mint, credit, or settle that same event.
    • The system should never rely on both paths are careful when one incoming transfer can still be economically recognized twice.

What sound design looks like

1. Configure first, deliver second

  • Write per-id configuration, tranche meaning, cap consumption, collateral retirement, or entitlement-consumed state before any receiver hook can run.
  • If the asset cannot safely exist before configuration is complete, keep it non-existent or non-delivered until that point rather than minting a callback-visible placeholder.
  • The hook will finish setup is usually a smell unless the hook is explicitly the one and only accounting owner.

2. Advance namespaces before callback-visible minting

  • Id allocators, supply counters, per-user mint limits, and split namespaces should already reflect the new object before delivery exposes it to re-entry.
  • Helpers like getNextId() should not return callback-visible state that the outer frame still plans to advance afterward.
  • If a namespace must be reserved before full completion, reserve it in trusted state first and make later callback paths observe the reservation as already spent.

3. Retire old rights before outbound delivery

  • Withdrawals, liquidations, exits, and unwraps should clear or stage-clear the old collateral / entitlement / debt relationship before the recipient hook can act.
  • A healthy outbound flow does not let the same NFT or receipt be already released enough to transfer but still counted enough to support debt at the same moment.
  • If later cleanup is unavoidable, the callback-reachable surface must see an explicit in-progress stage that forbids reuse of stale rights.

4. Choose one accounting owner for inbound transfers

  • Protocol-self receiver hooks are especially dangerous because teams mentally classify them as internal plumbing.
  • The safe question is: which exact component is allowed to turn this incoming transfer into a claim, mint, credit, or accounting delta?
  • If the answer is the outer frame and also the receiver hook depending on context, the design is already on the wrong track.
  • Good implementations often use one of two shapes:
    • Outer-frame ownership — the hook only checks authenticity / routing and records a minimal receipt signal; the outer frame alone mints or credits.
    • Hook ownership — the outer frame hands off fully, marks the transfer as delegated, and never later accounts for the same asset again.

5. Make callback stages explicit when staging cannot be avoided

  • Some flows genuinely need multi-stage delivery. In that case the stage must be a first-class security object, not an implicit expectation.
  • Record which transfer is in flight, which id or collateral item it concerns, and which post-hook transitions remain allowed.
  • Callback-reachable siblings should accept only the exact stage they are meant to resume, not a broad minting / withdrawing / liquidating ambient bit.

6. Keep inert placeholders inert

  • If pre-minting or pre-delivery placeholders are unavoidable, ensure they cannot yet borrow, redeem, split, vote, configure other series, or bootstrap new issuance.
  • A placeholder that is callback-visible and already economically active is not a placeholder; it is live authority with unfinished semantics.

Design questions worth asking first

  • Before any receiver hook can execute, what exact object has already been fully defined: id, series, collateral status, cap usage, redemption rule, and accounting owner?
  • If this is an outbound transfer, which old right has already been retired so the hook cannot reuse it?
  • If this is an inbound transfer, who exactly owns mint / credit authority for it, and how is double-recognition mechanically impossible?
  • Can any callback-reachable path still observe a pre-advanced namespace, pre-cleared collateral flag, or pre-consumed entitlement?
  • If the destination is protocol-owned, what stops the team from unconsciously treating the hook as trusted plumbing while it still has live economic authority?
  • What single stored stage object proves whether the callback is resuming an unfinished but authorized transition rather than discovering a fresh one?

Red flags

  • _safeMint() or safeTransferFrom() occurs before id allocation, series config, cap consumption, or collateral-status writes are final.
  • A receiver hook can call a sibling path that still reads nextId, isUsingAsCollateral, currentSupply, canClaim, or equivalent state as if the outer frame had already finalized it.
  • Protocol-owned hooks mint, credit, or settle value while the outer caller still intends to do post-transfer accounting for the same event.
  • Teams rely on a generic reentrancy guard but still leave the actual namespace / collateral / entitlement object half-finished across the hook window.
  • The codebase cannot clearly answer whether a transfer is validated by the hook or accounted for by the hook.
  • Delivered receipts become callback-visible before their redemption math or per-series semantics are fixed.

Failure-prevention checklist

  • Finalize per-id configuration, cap consumption, collateral retirement, and entitlement-consumed state before receiver-hook delivery can occur.
  • Reserve and advance ids, namespaces, and supply counters in trusted state before _safeMint() or safeTransferFrom() makes the object callback-visible.
  • For outbound collateral release, prove the old right is already retired or explicitly stage-locked before the recipient hook can call back.
  • For inbound transfers, designate one accounting owner and make the other path mechanically incapable of minting, crediting, or settling the same event.
  • Treat protocol-owned receiver hooks as untrusted economic control surfaces for design review purposes, even if their code is first-party.
  • If multi-stage delivery is unavoidable, store an explicit stage object that binds transfer identity, target object, and remaining allowed transitions.
  • Ensure placeholders and pre-minted objects are economically inert until their semantics are fully fixed.
  • Exercise contract-recipient tests for attacker-owned hooks and protocol-self hooks, not only EOAs.
  • Review generic reentrancy guards as a supplement, not a substitute, for object-level finalization and single-owner accounting.
  • Document in code and tests whether the hook validates, routes, or accounts for the transfer; ambiguity here is itself a bug signal.

Audit prompts

  • What exact facts about this token or position are still undecided at the moment the receiver hook can run?
  • Could the hook or a callback-reachable sibling path still observe a stale namespace, stale collateral flag, or stale entitlement bit?
  • If the destination is protocol-owned, what proof exists that the hook and outer frame cannot both economically recognize the same inbound transfer?
  • Is the delivered object already economically live when the hook sees it, and if so, what invariant proves its semantics are complete?
  • Which old right has already been retired before an outbound transfer occurs, and where is that retirement written?
  • Does any helper like getNextId() or currentSupply() leak a half-finished object definition into the callback window?
  • If the flow relies on staging, what exact stored stage object distinguishes authorized resume from fresh unauthorized action?
  • Could swapping the destination from an EOA to a contract receiver change solvency, issuance, or accounting results?
  • Are there any code paths where safe delivery succeeded is treated as equivalent to accounting is final, even though post-hook writes still remain?
  • Can the team answer in one sentence who owns accounting for the transfer? If not, the design is probably too ambiguous.

Lessons that should already be standard

  • Safe delivery is still delivery into executable control flow.
  • A receiver hook should never be the first place where a token’s economic meaning becomes real.
  • Namespaces, caps, and collateral flags are part of solvency truth, not harmless metadata to clean up later.
  • Protocol-self hooks are not automatically safe just because they are internal code; they can still duplicate accounting or observe unfinished authority.
  • If one transfer has two plausible accounting owners, it effectively has none.
  • Callback-visible placeholders are dangerous unless they are genuinely inert.
  • The clean model is define the object, choose the accounting owner, then deliver the object.

Negative exemplar cluster

  • Omni Protocol — outbound safe-transfer let ERC-721 callbacks race collateral-retirement state, so debt constraints were honored and then forgotten.
  • Revest Finance — ERC-1155 mint delivery exposed a still-live id namespace and config surface before the newly created object had one fixed meaning.
  • Solv Protocol BRO — one inbound transfer had two accounting owners: the hook and the outer frame.

How to use this note with the exploit corpus

Internal linkages

Control surface