Skip to content

feat(pointer): hand a pointer over for good, and read forks by majority - #210

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

grumbach wants to merge 16 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.

Client logic only; the merge-rule change it relies on is reviewed in WithAutonomi/ant-protocol#40 and WithAutonomi/ant-node#239.

Stacked on #208. This branch is rebased on #208's head, because it extends the read tally and write judgement #208 introduces. Merge #208 first; until then the diff includes #208's commits. It pins WithAutonomi/ant-protocol#40 and WithAutonomi/ant-node#239 by rev, so those merge first.

What

Pointer ownership transfer by final redirection (ADR-0018 in ant-node). The owner key never changes, but what the address resolves to can be handed over. The owner signs its final state, at u64::MAX, pointing at a pointer the new owner holds the key to. Readers are redirected there by every node that holds it, the address stays the same, and no node that holds it gives it up, because a final state is replaced by nothing (WithAutonomi/ant-protocol#40, WithAutonomi/ant-node#239). The owner can still sign a second final state; nodes and readers settle that by majority, below.

  • pointer_transfer, with pointer_sign_transfer for external payers. Before anything is paid it asks the whole close group, not an ordinary read, since a read ignores a final state too few peers hold. It refuses when:

    • any peer holds a different final state (Error::PointerFinal, or PointerForked for several); the same state, left by an earlier attempt at this very transfer, does not refuse;
    • not every peer of the configured group answered (CloseGroupShortfall), since a silent one could hold one;
    • the recipient is the pointer itself, does not exist, or its chain leads back here, including at the very last hop a reader can follow; a chain too long to follow is refused too.

    It then signs, pays and stores, and returns what it stored with the group's finality beside it (PointerTransfer): a check that fails after the write says nothing about a transfer that is already stored. A write whose acknowledgements were all lost is reported as done when the group holds the transfer on a majority, natively and in the browser, including a transfer stored again from its onPaid journal.

  • pointer_finality asks every peer of the configured close group rather than stopping at a quorum, so a rival held by even one peer is seen. It returns one of:

    • Open: no peer that answered holds a final state;
    • Settling: one final state, short of a majority;
    • Unconfirmed: one final state on a majority, but not every peer answered;
    • Final: one final state on a majority, every peer answered, and none holds a rival;
    • Forked: every final state seen, and the majority's, if any.

    Final is what a recipient checks before relying on a transfer.

  • pointer_controller follows transfers only (final states with a pointer target) to the pointer whose owner now decides what the address resolves to.

  • Reads decide between final states by majority, counted over the configured close group. A read that meets a final state is settled only once a final state is held by a majority, and returns that one. Two different final states with no majority fail as Error::PointerForked rather than guess. One uncontested final state short of a majority is returned once corroborated, as any state is. Below the final counter, reads are fix(pointer): read back a completed write, and pay only for writes that can land #208's.

  • Updating asks the whole group too, and refuses as PointerFinal before anything is paid if any peer holds a final state, since the nodes holding it would never follow. An update goes ahead when some peers do not answer, since an update can be replaced.

  • CLI: ant pointer transfer --key <FILE> <RECIPIENT>, ant pointer finality <ADDRESS> and ant pointer controller <ADDRESS>, all with --json; a transfer's JSON always carries the stored state beside the finality check.

  • Browser: transferPointer and pointerFinality on BrowserNetworkClient.

  • Pins ant-protocol and the ant-node dev dependency to the companion branches' heads.

Compatibility

  • Wire: none.
  • Storage: none.
  • API: additive: Client::pointer_transfer, pointer_sign_transfer, pointer_finality and pointer_controller; the types PointerTransfer, PointerFinality, FinalityStatus, FinalState and PointerController; and the error variants Error::PointerFinal and Error::PointerForked, both classified as application errors. Behaviour:
    • pointer_get on a forked pointer with no majority now errors instead of returning the smaller-target final state.
    • pointer_update on a pointer any peer holds a final state for now returns PointerFinal instead of paying for an update those nodes would refuse.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • Rebased on 2026-10-02 onto fix(pointer): read back a completed write, and pay only for writes that can land #208's rebased head, linearly. The three merges of fix(pointer): read back a completed write, and pay only for writes that can land #208's head are gone; the one manual resolution they carried, in data/client/pointer.rs, is carried by the first commit, and that file is byte-identical to the reviewed head. Every other file differs from the reviewed head only by main's changes. The pin commits point at the rebased companion commits, with the ant-node requirement at main's 0.21.0, and the last pin moves both to the heads: ant-protocol ab459e5, ant-node 99eee09 (which includes #238 and #240). Every commit resolves with --locked, and the lock holds one ant-protocol, one ant-node and one each of saorsa-core, saorsa-transport, saorsa-pqc and evmlib.
  • One commit is new: the real-Chromium pointer test now also transfers its pointer and expects pointerFinality to report one final state, held by a majority, redirecting to the recipient. It passed against seven nodes built from #239's head, and against c092f22, the node revision CI pins for this suite, which predates the final-state rule: so a client from this PR hands a pointer over on nodes that do not have the rule yet.
  • At head 7d0679c, locally on macOS with Rust 1.99.0: cargo test --lib --all 770 passed; unit_self_encrypt 16 (2 ignored), merkle_unit 8, daemon_integration 3, node_add_integration 6; WASM bindings built as CI builds them, 205 passed; e2e_pointer 17 passed against nodes built from #239's head, which CI does not run (a_final_state_left_on_one_node_refuses_a_second_transfer_before_paying first failed in harness setup, create P2P node: ... Failed to create transport, while another suite held local ports, and passed when run again alone); cargo clippy --all-targets --all-features -- -D warnings, the wasm32 check and clippy with browser-wasm, cargo fmt --check and cargo doc with -D warnings all clean. The serial e2e suites run in CI on this head.
  • Mixed fleet, locally: this PR's ant against six nodes from feat(pointer): keep the first final state, and look before taking one ant-node#239's head plus six released ant-node 0.21.0 nodes, close groups holding both: create, update, read, a paid transfer reported final with 7 of 7, controller, resolve, and an update refused before paying once final. The released ant 0.3.9 on the same network writes and reads its own pointer and reads and resolves the transferred one; this PR's ant reads 0.3.9's pointer.
  • Before the rebase, at ba3a31e:
  • cargo test -p ant-core --lib pointer: 34 passed, including: a transfer the group holds is read back and settles on a majority; of two final states the majority is read in every arrival order; two final states with no majority are a fork, even when only one peer holds the other side; one uncontested final state is read once corroborated; the finality check names open, settling, unconfirmed, final, forked and frozen, and a majority is not final while part of the group is silent; a refused transfer names the final state that beat it; a transfer the group holds is done whatever its acknowledgements said; any final state refuses an update; a recipient chain is checked to the last hop a reader takes, including a cycle reached exactly as the walk runs out. Each new test fails when its fix is reverted, which I checked.
  • cargo test -p ant-core --features test-utils --test e2e_pointer -- --test-threads=1 against a local testnet of nodes built from the ant-node branch, with real Anvil settlement: 17 passed. Besides fix(pointer): read back a completed write, and pay only for writes that can land #208's 14:
    • a_transfer_hands_the_address_over_for_good: transfers to itself, to nowhere and into a cycle are refused and nothing is paid; the transfer is paid and Final; readers resolve through the recipient, who moves it; the former owner's update and re-transfer are refused before paying; a paid final state whose target sorts first, sent over QUIC around the client to every node holding the transfer, is refused Stale by each.
    • a_raced_final_state_is_read_by_its_majority_or_reported_as_forked: a 4:3 race reads as the four; a 3:3 race fails as PointerForked and refuses a transfer before paying.
    • a_final_state_left_on_one_node_refuses_a_second_transfer_before_paying: a final state stored on one node only is invisible to a read, yet a second transfer and an update are both refused before anything is paid. Without the whole-group check the second transfer lands on the other six nodes and forks the pointer, which I checked.
  • WASM bindings (wasm-pack build ... --features browser-wasm,test-utils, then the generated-binding suite): 194 passed, including a transfer whose acknowledgements are all lost, and one stored again from its onPaid journal the same way, both reported as done.
  • Every test step CI runs, locally on macOS at that head: cargo test --lib --all 761 passed; the serial e2e suites (e2e_chunk, e2e_data, e2e_file, e2e_payment, e2e_native_payment, e2e_security, e2e_cost_estimate, e2e_adr0004) all passed; e2e_pointer 17 passed, which CI does not run.
  • cargo clippy --all-targets --all-features -- -D warnings (native, and wasm32 with browser-wasm) and cargo fmt --all -- --check: clean.
  • Dev testnet: not run. That gate is the release manager's call.

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.

Mitigation / rollback

Revert this PR. Transfers stop being offered, and reads fall back to the merge rule alone, which is what the previous ant-protocol rev applies. No stored data changes shape.

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — reviewed ba3a31e.

No blocking findings. Checked transfer signing, recipient-chain preflight, full-group finality, minority-final refusal before payment, failed-write reconciliation, and the distinction between ordinary reads and finality checks. The runtime and dev-dependency node pins agree with companion ant-node#239 at 8f5af8d; the last two client changes were pin/lockfile updates, not pointer-logic changes.

Verification:

  • Exact head: cargo test --locked -p ant-core --lib pointer — 34 passed, zero failures.
  • Earlier head 0e3c504 (node pin e813b9c): CLI pointer tests — 6 passed; cargo test --locked -p ant-core --test e2e_pointer transfer -- --test-threads=1 — 2 passed using local nodes and Anvil. These exercise handover and refusing a second transfer before payment when one node retains a final state. They are supporting earlier-pin evidence, not exact-head E2E certification.

Watch-outs: finality is a close-group observation, not a global ownership certificate. Mixed-version deployment needs coordination. Current-head CI remains running/queued; approval is not a claim that all merge gates are green.

Review scope: the three companion pointer-transfer PRs were read together. Independent GLM-5.2 review found no blockers on the earlier reviewed heads; I checked subsequent deltas directly and reran the relevant tests. Its cautions about mixed-version deployment and the lack of global ownership consensus are valid, documented limitations rather than demonstrated regressions. Codex CLI could not review because its credentials were revoked; it is not counted as a completed review. Additional source-review seats have not returned, so this is not a claim of full-panel consensus.

…at can land

A read could return the state a successful write replaced. A write lands
on five of seven peers, so the two it missed still hold the older state.
With one more peer replaying that older record, the older state was
backed by two peers after four answers while the newer one had been named
once, and the read stopped there. A read now keeps asking while a state
that would replace its answer has been named by too few peers, until that
state is corroborated or the group is exhausted. A newer state only one
peer names still never wins.

A browser paid for pointer writes that could not land. During a node
rollout, some of a close group do not advertise pointers yet and are never
sent one, so with four of seven advertising, a write needing five was paid
for and then fell short. Before quoting, the client now asks each member
of the close group, over the lane the write would use and through the same
checks a request passes, whether it would take the write, and refuses
before the wallet is called when too many say no for a write quorum to be
reachable. A node that cannot be asked in time is not counted as refusing,
and a native client, which cannot tell, behaves as before.

BrowserNetwork gains a provided method, accepts_pointer_writes, whose
default answers that it cannot tell.
… end a paid write

- Pointer quorums are counted over the configured close group, not over
  however many peers a lookup returned, so a short lookup is a shortfall
  rather than a quorum of one copy and one answer.
- A peer claiming the pointer has moved on no longer ends a paid write on
  its word: a read confirms the move before the write gives up, and any
  other refusal is final only if no peer stored the record. Otherwise the
  round is retried with the proof already paid for.
- The browser stores a paid state even if the page's onPaid callback never
  settles, after 30 seconds.
- `ant pointer resolve` prints the kind when the target is not a chunk.
…m width

A browser write waits at most 30 seconds on the page's onPaid callback
before storing the paid state anyway. No test covered that: the only
onPaid test used a synchronous callback, which passes with or without the
deadline. Under test-utils the deadline is one second, and a new WASM
test hands over a callback that never settles and requires the write to
be paid once and stored.

The read quorum comment still said it was taken from the group a lookup
returned. It is taken from the configured close group, or more peers if
the lookup returned more.
…ond agrees

A paid pointer write ended on the first refusal whenever no peer had
stored the record. With the rest of the group timing out, that let one
peer answering PaymentRequired or an error throw the payment away,
since native writes do not hand the proof back for recovery. A refusal
is now final only when no peer stored the record and at least two peers
refused (one in a group of one). Anything less is a shortfall, retried
with the proof already paid for.
The judgement that one refusal does not end a paid write was tested only
with a hand-built tally, which the old counting would have passed too. A
unit test now feeds one and then two refusals, among timeouts, through
the same group ask a write uses. A browser test has one node refuse every
write while the other six miss the first round, and requires the write
to land on a retry with the proof already paid for; the test node can
now refuse a pointer write. Two comments still said a single definite
refusal was final.
A node answering a pointer PUT with an error, a full disk for example,
was read as a refusal of the record. Two such nodes with the rest of the
group missing a round then ended a paid write, and a native write
dropped the proof it had paid for, although the nodes that missed the
round could still have formed a quorum. The node's error is now kept as
it gave it and retried as a shortfall; only a refused payment or an
acknowledgement of some other record is a refusal. The docs said a write
failed only when no storer accepted it; it fails when fewer than the
write quorum do.
A pointer's owner key never changes, but the pointer can now be handed
over for good (ADR-0018 in ant-node). The owner signs its final state, at
u64::MAX, pointing at a pointer the new owner holds the key to. Readers of
the address are redirected there and the address stays the same, and the
former owner's key can change nothing: ant-protocol now replaces a final
state with nothing.

pointer_transfer refuses before paying when a transfer could not land
well -- the pointer is already final, the recipient is the pointer itself,
does not exist, or leads back here -- then signs, pays, stores and reports
the group's finality. pointer_finality asks every peer rather than
stopping at a quorum, so a rival final state held by even one peer is seen,
and answers open, settling, final or forked; final is what a recipient
checks before relying on a transfer. pointer_controller follows transfers
to the pointer that now decides.

Two final states are unordered, so the merge rule cannot choose between
them: each node keeps the one it took first. A read that meets a final
state is therefore settled only once a final state is held by a majority
of the group, and returns that one; two with no majority fail as
PointerForked rather than guess. One uncontested final state short of a
majority is returned once corroborated, as any state is. Updating a final
pointer is refused as PointerFinal before anything is paid.

Also: ant pointer transfer / finality / controller, transferPointer and
pointerFinality in the browser, and the ant-protocol and ant-node pins
moved to the companion branches.
… a half-heard group final

A read returns a state only once two peers name it, so a final state an
earlier transfer left on one peer was invisible to the transfer and
update preflights. A second transfer then signed past it, paid, and
forked the pointer for good on every node that had not heard of the
first. A transfer now asks the whole close group before it is stored,
and any final state other than its own refuses it; the same state, left
by an earlier attempt, does not. An update refuses a final state any
peer its read heard from reported.

The finality check called one final state held by a majority Final even
when part of the group had not answered, although a silent peer could
hold a rival. It now counts over the configured close group, and a
majority without every peer answering is Unconfirmed; only Final means
final.

A transfer whose write landed but whose finality check then failed was
reported as a failed transfer, although it was stored and final. The
transfer now returns what it stored with the check beside it, and the
CLI prints a failed check as that.

The recipient-chain check could run out of hops exactly as the chain
reached the pointer being handed over, and let the cycle through. It
now reserves the hop a reader spends on that pointer and refuses a chain
it cannot follow to the end. The PointerFinal error no longer carries
the cost-estimation doc that belongs to the next variant.

ant-protocol and ant-node are pinned at the heads of their companion
changes.
The node change now keeps both sides of a proven fork and shares one
finality look among replays queued behind it.
…nce before a transfer

An update read the pointer the ordinary way, which settles once enough
peers agree, so a final state an earlier transfer left on one slow peer
could go unheard. The owner then signed and paid for an update that
peer would never take, splitting the group for good. An update now asks
the whole close group first and refuses any final state a peer holds.

A transfer treated an incomplete finality check as open, although a peer
that did not answer could hold a final state, and payment is settled
before nodes can refuse. A transfer now goes ahead only when every peer
of the configured group answered.

A transfer whose write reached a majority but whose acknowledgements
were all lost was reported as failed, and a caller might pay for it
again. When the group then shows the transfer's own state held by a
majority, it is reported as done.

The CLI's JSON for a transfer now always carries the address and the
stored state beside the finality check. The README describes the
unconfirmed status and what open means when not every peer answered.

ant-protocol and ant-node are pinned at the heads of their companion
changes, and #208's retry of a node's own storage error is merged in.
…done, and one recovery path for both

A browser transfer reported failure whenever its write did, even when
every node had stored the final state and only the acknowledgements were
lost, so a page could tell its user an irreversible transfer had failed.
The browser now asks the group after a failed final write, exactly as a
native transfer does, through one shared recovery path, and reports the
transfer as done when the group holds it on a majority.

The changelog now lists the unconfirmed finality status and no longer
says the former owner's key can change nothing: no node that holds the
final state gives it up.

ant-node is pinned at the head of its companion change.
…he group holds it

A page that journals a paid transfer through onPaid and stores it again
after a reload goes through storePaidPointer, which reported failure
when every acknowledgement was lost although the transfer had landed.
It now takes the same recovery path as a transfer written the first
time, and a browser test covers it.

ant-node is pinned at the head of its companion change.
The node change now counts repair votes by the whole state and drops a
summary about another address.
The node change now names the remembered final state when it refuses a
rival.
…final

The real-Chromium suite created, updated, read and resolved pointers on real
nodes, but never called transferPointer or pointerFinality, so the browser
half of a transfer was covered only against mocked peers. The pointer test
now also hands its pointer over to the second pointer it wrote, then asks the
whole group for its finality and expects one final state, held by a
majority, redirecting to that pointer.

CI pins this suite's nodes to an earlier node revision, before the rule that
keeps the first final state, so there it also shows a client of this change
handing a pointer over on nodes that do not have it yet.

The README no longer says the native ant-node dependency has no git
revision: this change pins one.
@grumbach
grumbach force-pushed the feat/pointer-ownership-transfer branch from ba3a31e to 7d0679c Compare October 2, 2026 04:21
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.

2 participants