Conversation
dirvine
left a comment
There was a problem hiding this comment.
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.
ba3a31e to
7d0679c
Compare
Linear issue
Closes V2-1354
Risk tier
Client logic only; the merge-rule change it relies on is reviewed in WithAutonomi/ant-protocol#40 and WithAutonomi/ant-node#239.
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, atu64::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, withpointer_sign_transferfor 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:Error::PointerFinal, orPointerForkedfor several); the same state, left by an earlier attempt at this very transfer, does not refuse;CloseGroupShortfall), since a silent one could hold one;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 itsonPaidjournal.pointer_finalityasks 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.Finalis what a recipient checks before relying on a transfer.pointer_controllerfollows 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::PointerForkedrather 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
PointerFinalbefore 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>andant pointer controller <ADDRESS>, all with--json; a transfer's JSON always carries the stored state beside the finality check.Browser:
transferPointerandpointerFinalityonBrowserNetworkClient.Pins ant-protocol and the
ant-nodedev dependency to the companion branches' heads.Compatibility
Client::pointer_transfer,pointer_sign_transfer,pointer_finalityandpointer_controller; the typesPointerTransfer,PointerFinality,FinalityStatus,FinalStateandPointerController; and the error variantsError::PointerFinalandError::PointerForked, both classified as application errors. Behaviour:pointer_geton a forked pointer with no majority now errors instead of returning the smaller-target final state.pointer_updateon a pointer any peer holds a final state for now returnsPointerFinalinstead of paying for an update those nodes would refuse.Semver impact
Test evidence
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-protocolab459e5, ant-node99eee09(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.pointerFinalityto report one final state, held by a majority, redirecting to the recipient. It passed against seven nodes built from #239's head, and againstc092f22, 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.7d0679c, locally on macOS with Rust 1.99.0:cargo test --lib --all770 passed;unit_self_encrypt16 (2 ignored),merkle_unit8,daemon_integration3,node_add_integration6; WASM bindings built as CI builds them, 205 passed;e2e_pointer17 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_payingfirst 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 withbrowser-wasm,cargo fmt --checkandcargo docwith-D warningsall clean. The serial e2e suites run in CI on this head.antagainst six nodes from feat(pointer): keep the first final state, and look before taking one ant-node#239's head plus six releasedant-node0.21.0 nodes, close groups holding both: create, update, read, a paid transfer reportedfinalwith 7 of 7, controller, resolve, and an update refused before paying once final. The releasedant0.3.9 on the same network writes and reads its own pointer and reads and resolves the transferred one; this PR'santreads 0.3.9's pointer.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=1against 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 andFinal; 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 refusedStaleby 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 asPointerForkedand 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-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 itsonPaidjournal the same way, both reported as done.cargo test --lib --all761 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_pointer17 passed, which CI does not run.cargo clippy --all-targets --all-features -- -D warnings(native, and wasm32 withbrowser-wasm) andcargo fmt --all -- --check: clean.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.