Skip to content

feat(pointer): keep the first final state, and look before taking one - #239

Open
grumbach wants to merge 4 commits into
mainfrom
feat/pointer-ownership-transfer
Open

grumbach wants to merge 4 commits 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 which pointer state a node keeps at the final counter, and adds a close-group round trip before a node takes a final state.

What

Pointer ownership transfer by final redirection (ADR-0018). The owner key never changes. 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, the address stays the same, and the former owner has no move left. WithAutonomi/ant-protocol#40 makes a final state final: nothing replaces it, not even another final state whose target sorts first. This PR pins that rev and does the node's half.

  • First-come at the final counter. The store, the admission gate, fresh offers, repair and hints all compare with the protocol's replaces. They keep whichever final state they took first, with no code of their own.
  • Look before taking a final state. The merge rule alone leaves one gap. A node holding no final state takes any, so a former owner could still finalize again on a node that joined the group after the transfer, or lost its copy. Before taking a final state it does not hold, from a client PUT or a fresh offer, a node now asks its close group which state each peer holds. A peer claiming a different final state is asked for the record. If that record verifies (only the owner could have signed it), the write is refused as Stale, naming the state the group proved.
    • A claim alone refuses nothing, so one dishonest peer cannot block a transfer.
    • The look runs after payment is verified and only asks peers that have sent a pointer message.
    • It is bounded at four seconds, inside the client's ten-second store timeout, and silence proves nothing.
  • Forks the owner races. Two different final states can still exist if the owner sends them to different nodes at once, and each node keeps its first.
    • The possession check no longer penalises a member holding the other side of such a fork, because it holds what the merge rule told it to. It is logged at warn. A member holding nothing is still penalised.
    • Between two final states a wide group backs at quorum, repair adopts the larger side rather than whichever answered first.
    • The client decides reads by majority and reports forks (feat(pointer): hand a pointer over for good, and read forks by majority ant-client#210).
  • ReplicationEngine::with_pointers now takes the PointerService and wires both directions: fresh writes and the finality witness. The node and the e2e harness can no longer attach one and forget the other.
  • ADR-0018 records the decision. ADR-0016, still Proposed, is amended where it said a final state is not frozen.

Compatibility

  • Wire: none. No message or field changes. The look before a final state uses the existing PointerStateRequest and PointerFetchRequest.
  • Storage: none.
  • API: ReplicationEngine::with_pointers(&PointerService) replaces with_pointers(PointerStore, UnboundedReceiver<PointerFreshWrite>). New pointer::FinalStateWitness and PointerService::attach_final_state_witness. Behaviour: a node refuses a second final state with Stale where it previously took one whose target sorted first.
  • Mixed fleet: a node on the previous rule still lets a smaller-target final state displace the first. Reads follow the majority, so a transfer holds wherever most of a close group has upgraded (ADR-0018, Negative).

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • cargo test --all-features --lib: 1233 passed. New tests in the request handler:
    • A final state the group proves is already superseded is refused, named, and nothing is written.
    • A final state nobody contradicts is taken.
    • The group is asked only about a final state the node lacks.
    • Two final states raced to two nodes leave each on its first.
  • New repair tests: a node holding a final state adopts nothing else, and of two final states with quorum the larger side is adopted in either answer order.
  • cargo test --all-features --test pointer_convergence: 17 passed.
    • New property tests check every delivery order exhaustively: with one final state every order converges on it, and with two the first delivered is kept.
    • A stored transfer is not displaced by a final state whose target sorts first, nor by any lower counter. This replaces the test that asserted the opposite.
  • cargo test --all-features --test e2e pointer_replication over real QUIC: 15 passed, 3 of them new.
    • A transfer written to one node reaches the group, and a paid final state whose target sorts first is refused by every node.
    • A node that missed the transfer refuses a different final state because a peer serves the one it holds, and still takes the group's own.
    • The possession check does not penalise the other side of a fork, and still penalises a member holding nothing.
  • cargo test --all-features --test poc_audit_handler_live --test poc_commitment_audit_attacks: 16 and 19 passed.
  • cargo clippy --all-targets --all-features -- -D warnings, cargo clippy --all-features -- -D clippy::panic -D clippy::unwrap_used -D clippy::expect_used, cargo fmt --all -- --check and scripts/adr-governance.py: all clean.
  • feat(pointer): hand a pointer over for good, and read forks by majority ant-client#210 runs its end-to-end pointer suite against nodes built from this branch, with real Anvil settlement:
    • A transfer lands and is final.
    • Readers resolve through the recipient, who moves the pointer.
    • The former owner is refused before paying, and every node holding the transfer refuses a paid final state sent around the client.
    • A 4:3 race reads as the four, and a 3:3 race fails as forked.

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: docs/adr/ADR-0018-pointer-transfer-by-final-redirection.md, added by this PR (Proposed). It amends ADR-0016's merge rule at the final counter.

Mitigation / rollback

Re-pin ant-protocol to the previous rev and revert this branch. No stored record changes shape. The only coordinated part is that nodes and clients should agree on the final-counter rule, and a mixed group degrades to reads by majority, not to lost data.

A pointer state at u64::MAX is now final (ant-protocol): nothing replaces
it, not even another final state whose target sorts first. That is what
lets an owner hand a pointer's address over for good, by signing one last
state that points at the new owner's pointer. The store, the admission
gate, fresh offers, repair and hints all compare with the protocol's
replaces(), so they keep whichever final state they took first with no
code of their own.

The merge rule alone leaves one gap: a node holding no final state takes
any, so a former owner could still finalize again on a node that joined
the group after the transfer, or lost its copy. So before a node takes a
final state it does not hold, from a client or a fresh offer, it asks its
close group which state each holds. A peer claiming a different final state
is asked for the record, and if it verifies -- only the owner could have
signed it -- the write is refused as stale, naming the state the group
proved. A claim alone refuses nothing, so one peer cannot block a transfer.
The look runs after payment is verified, only asks peers that have sent a
pointer message, and is bounded at four seconds inside the client's store
timeout; silence proves nothing.

Two different final states can still exist if the owner races them to
different nodes. Each node keeps its first; the client decides by the close
group's majority. So the possession check no longer penalises a member
holding the other side of such a fork -- it holds what the merge rule told
it to -- and repair, between two final states a wide group backs at quorum,
adopts the larger side rather than whichever answered first.

with_pointers now takes the service and wires both directions, so the node
and the e2e harness cannot attach one and forget the other.

ADR-0018 records the decision and amends ADR-0016's merge rule.
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