Skip to content

feat(pointer): make a final state final, so ownership can be handed over - #40

Open
grumbach wants to merge 1 commit into
mainfrom
feat/pointer-ownership-transfer
Open

grumbach wants to merge 1 commit into
mainfrom
feat/pointer-ownership-transfer

Conversation

@grumbach

@grumbach grumbach commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Linear issue

Closes V2-1354

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

This changes the pointer merge rule that nodes and clients both apply. The change is one comparison, at the final counter.

What

A pointer's owner key cannot change. The address can still be handed over for good: the owner signs one last state at counter == u64::MAX, pointing at a pointer the new owner holds the key to. Every reader of the address is then redirected to the new owner's pointer, and the address stays the same.

Under ADR-0016's rule that does not stick. A state at u64::MAX is still displaced by another state at the same counter with smaller target bytes, so a former owner can grind a target in about two tries and take the address back. This PR adds one rule ahead of the other two:

0. a final state (counter == u64::MAX) is replaced by nothing
1. larger counter
2. smaller target bytes

Below the final counter nothing changes. At the final counter, two different final states are unordered, so a node keeps whichever it took first. Only the owner can create that fork, by racing two final states to different nodes before any node holds one. Once a node holds a final state, no arrival moves it off. The node PR also has a node look at its close group before taking a final state, and the client PR reads the side a majority holds and reports forks. replaces stays a strict partial order: it is never true both ways round, it is transitive, and it is total except between two final states.

Also adds FINAL_COUNTER, Pointer::finalize (refuses to sign past a record that is already final, since a second final state is how a fork is made), Pointer::transfer_to, and transferred_to on both the record and PointerState. PointerError::CounterExhausted now says the pointer is final, instead of advising a migration before the last counter.

Companion PRs: WithAutonomi/ant-node#239 (ADR-0018, and the node's look before a final state) and WithAutonomi/ant-client#210 (transfer, finality check, majority reads). Both pin this branch's head by rev.

Compatibility

  • Wire: none. No field, message or encoding changes; a final state is an ordinary record at counter u64::MAX.
  • Storage: none.
  • API: additive: FINAL_COUNTER, Pointer::finalize, Pointer::transfer_to, Pointer::transferred_to, PointerState::is_terminal, PointerState::transferred_to. One behaviour change: PointerState::replaces / Pointer::replaces return false when the held state is final. Nodes and clients must agree on it. A node still on the old rule lets a smaller-target final state displace the first; see ADR-0018's mixed-fleet note.

Semver impact

  • breaking
  • feature
  • fix

Pointers are not in a published release yet (3.0.0 has none), so the rule change lands on an unreleased surface.

Test evidence

  • cargo test --lib: 108 passed, 21 of them in pointer. New tests:
    • A final state is replaced by no counter and no other final state, whatever its target, for chunk, pointer and unknown target kinds.
    • Below the final counter the order is unchanged.
    • The strict-order property test now includes final states, and is total except between two of them.
    • A fold keeps the first final state it meets.
    • transfer_to signs at the final counter to the recipient and refuses to sign past a final record.
    • Only a final pointer target counts as a transfer.
  • cargo clippy --all-targets --all-features -- -D warnings, cargo fmt --all -- --check, RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps and cargo build --no-default-features: all clean.
  • Adversarial coverage is in feat(pointer): keep the first final state, and look before taking one ant-node#239: property tests over every delivery order, the request handler, repair, and a live multi-node network. feat(pointer): hand a pointer over for good, and read forks by majority ant-client#210 covers majority reads and fork detection, including end to end against a local testnet with real settlement.

New dependency

None.

ADR

https://github.com/WithAutonomi/ant-node/blob/feat/pointer-ownership-transfer/docs/adr/ADR-0018-pointer-transfer-by-final-redirection.md: ADR-0018, added by WithAutonomi/ant-node#239. It amends ADR-0016's merge rule at the final counter.

Mitigation / rollback

Revert the rule-0 line in PointerState::replaces. Nodes and clients pin this crate by rev, so a rollback is a re-pin, and no stored data changes shape either way.

A pointer's owner key cannot change, but what its address resolves to can
be handed over for good: the owner signs one last state, at counter
u64::MAX, pointing at a pointer the new owner holds the key to. Readers of
the address are redirected there, and the address never changes.

That only works if the last state stays last. Under the previous order a
state at u64::MAX was still displaced by another at the same counter with
smaller target bytes, so a former owner could grind a target and take the
address back. replaces() now has a rule ahead of the other two: nothing
replaces a final state, not even another final one. Below the final counter
the order is unchanged.

The price is that two different final states are unordered, so each node
keeps whichever it took first. Only the owner can make that fork, only by
racing two final states to different nodes, and only while no node holds a
final state yet: once one does, no node that holds it takes another. Which
side a reader believes is decided by how many of the close group hold each,
on the client.

Adds FINAL_COUNTER, Pointer::finalize, Pointer::transfer_to and
transferred_to on both the record and its parsed state. finalize refuses to
sign past a record that is already final, since that is how a fork is made.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant