Skip to content

Build the trusted XCHandles foundation and verified resolver - #919

Merged
Rigidity merged 15 commits into
mainfrom
feat/xchandles-resolver
Oct 26, 2026
Merged

Rigidity merged 15 commits into
mainfrom
feat/xchandles-resolver

Conversation

@Rigidity

@Rigidity Rigidity commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #870. Phase 1 of #867.

Adds the trusted XCHandles foundation: per-network deployment config, a typed client for the listener API, on-chain verification of every lookup, a verified mapping store, and a resolve_handle endpoint. Recipient integration (#871), address display (#912), broader endpoints (#913) and NFT metadata (#911) are separate.

Config

  • Turns on chia-wallet-sdk's action-layer feature (XCHandles drivers from 0.37.1, no Git dependency).
  • Adds Network.xchandles (registry launcher ID, price singleton launcher ID, precommit payout puzzle hash, relative block height, API URL). Networks pick it up through inherit.
  • The constants were decoded from each registry's launcher solution on chain, and tests re-parse those solutions with the SDK to check the config matches.
  • A custom network gets no deployment unless it sets one. Inheriting only works if its genesis challenge matches the built-in network's, so nothing falls back to mainnet.

Verification (crates/sage-wallet/src/xchandles/)

For every lookup:

  1. Rebuild the slot coin from the returned value and lineage, and reject it unless its parent is a coin of the trusted registry singleton.
  2. Confirm through a peer that the slot is confirmed and unspent.
  3. Check expiry against the peak transaction block's timestamp, only once the slot is confirmed.
  4. Derive the address from the resolved NFT or DID by parsing it from its parent's spend (Sage's existing ChildKind parser), following later spends if the API is behind. A singleton in a clawback is rejected.

A slot spent by neighbour activity triggers a re-fetch (up to 3 attempts) rather than a rejection. API lookups pin ?launcher_id=<registry>. A whole lookup is capped at 15 seconds, and a timeout counts as unavailable.

Store

Migration 0006_xchandles.sql adds an xchandles table keyed by (registry_launcher_id, handle). The policy is documented at the top of cache.rs:

  • When the API and peers are reachable, every lookup is verified fresh.
  • When they're unavailable (network errors, rate limits, server errors, unparseable responses, timeouts), a mapping verified in the last 24 hours is returned and marked cached, unless it has expired.
  • A rejected answer, or a handle that is no longer registered, marks the stored row unverified, keeping its last verified values.
  • CoinStateUpdate.fork_height now marks rows above the fork point stale. Failures there are logged and don't block coin processing.
  • A new resolved address keeps the old one as a pending change until it is acknowledged. Database helpers for acknowledging and recording the last-sent address are in place for Resolve XCHandles safely in every recipient flow #871.
  • The stored row is re-read in the same transaction as the write, so concurrent writes aren't lost.

Endpoint

resolve_handle takes a handle (with or without @) and returns a HandleRecord: address, status (verified or changed), previous_address, last_sent_address, cached, and the proof and height fields. It's wired into RPC/OpenAPI, the Tauri command list and the main-host permissions. Not-registered handles return NotFound.

There's also a VITE_DISABLE_XCHANDLES build flag in features.ts. Nothing uses it yet.

Testing

  • Mocked API: the fixtures are real responses captured from the mainnet and testnet APIs. The 410 and 503 bodies are built from the documented error format.
  • Peer simulator: a stand-in registry singleton creates and spends real slot coins. Tests cover:
    • a valid resolution
    • a forged slot lineage
    • a tampered slot value or expiration
    • a slot from another registry, and the wrong deployment constants
    • expiry
    • a slot recreated by a neighbour (re-verifies with no change warning)
    • an NFT that moved after indexing
    • bad resolved-singleton data
  • Store: database tests cover change tracking, the offline window, which errors downgrade a row, reorg invalidation, per-registry isolation, and concurrent writes.
  • RPC: invalid handles and unsupported networks are rejected.
  • Live data: the slot coins for yakuhito (mainnet) and cloudwallet (testnet11) were rebuilt from the fixtures and confirmed to exist, unspent, through coinset. The yakuhito NFT parsed from its real parent spend to the owner the API reports.
  • Gap: there's no end-to-end RPC success test, because the peer simulator doesn't serve block headers.

Notes for reviewers

  • Testnet11's relative block height is 8, not 32. That's what its launcher solution contains.
  • Failed lookups return an error, never an address.
  • Single-peer trust. Verification trusts one peer (the one claiming the highest peak), as the rest of Sage does.
  • Reorg coverage is partial. Invalidation only fires when the wallet's own coins change, so some reorgs are missed. This only affects the 24-hour offline window.
  • Small repo-wide changes:

Note

High Risk
Changes payment-adjacent resolution (wrong address = lost funds), persists trust decisions in SQLite, and touches sync/DID parsing; verification relies on a single peer like the rest of the wallet.

Overview
Adds XCHandles support so wallets can resolve @handle names to addresses with on-chain verification, not API trust alone.

resolve_handle is exposed through RPC/OpenAPI and Tauri. It fetches index data from the configured listener API, verifies registry slot lineage and coin state via peers, follows the resolved NFT/DID to the current owner puzzle hash, and returns a rich HandleRecord (including verified vs changed, optional cached fallback, and proof metadata). Lookups are bounded by a 15s timeout; unsupported or custom networks without a matching genesis/inherit chain reject resolution.

Per-network XchandlesConfig (mainnet/testnet11 built-ins, optional override) pins registry constants checked against on-chain launcher solutions. A new xchandles SQLite table caches verified mappings with status (verified / unverified / stale), change tracking, offline 24h reuse, and reorg invalidation when peer peaks roll back.

Sync hardening: NewPeakWallet can mark handle rows stale after forks; DID sync parsing now requires hint/clawback memos to match the actual puzzle hash.

Also adds VITE_DISABLE_XCHANDLES, sqlx query artifacts, and extensive unit/simulator tests (not wired into UI send flow yet).

Reviewed by Cursor Bugbot for commit d2c57de. Bugbot is set up for automated code reviews on this repo. Configure here.

Constants are taken from the registry launcher solutions on chain and
checked against them in tests. Custom networks only inherit a built-in
deployment when their genesis challenge matches.
- Handle parsing and hashing, and a typed client for the listener API
- Verify each lookup against the chain: rebuild the slot from the
  registry lineage, require it to be confirmed and unspent, and derive
  the resolved address from the resolved singleton's latest spend
- Re-fetch when the slot was spent by neighbor activity
- Store verified mappings per registry, with change tracking, offline
  fallback, and invalidation on rejection or reorg
Resolves a handle through the verified resolver and store, and exposes
it over RPC and Tauri. Also adds a build-time flag for XCHandles UI.
- Only a rejected answer or a not-registered handle marks a stored
  mapping unverified; rate limits, unparseable responses, bad config
  and missing resolution data fall back to the cache instead
- Check expiration only after the slot is confirmed on chain
- Don't let handle cache invalidation abort coin state processing
- Re-read the stored mapping in the same transaction as the write, so
  concurrent last-sent and acknowledgement writes aren't lost
- Share the block header fetch between the timestamp helpers
- Release the Sage lock while resolving, via a new opt-in for endpoints
  that manage the lock themselves (endpoints-unlocked.json)
Releasing the lock needed a macro feature and a one-off endpoint
signature for a lookup that normally takes well under a second. Instead,
cap the whole lookup at 15 seconds and the chain tip walk-back at 16
blocks. A timed-out lookup is unavailable and falls back to the cache.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread crates/sage-wallet/src/xchandles/verify.rs Outdated
ChildKind returned any clawback-shaped memos on a DID transfer, even when
the DID's puzzle wasn't committed to that clawback, so sync recorded a
clawback puzzle for an ordinary transfer. NFTs, CATs and options already
filter on the owner puzzle hash.
- Ignore the API's melted flag. A melt is still detected on chain, when the
  last coin is spent without creating a successor.
- Step past a spent eve coin to its child, which can be parsed from the eve
  spend.
- Refuse to verify an unspent eve coin, since the launcher spend doesn't
  reveal the owner. This doesn't downgrade a stored mapping.
- Apply cargo fmt to earlier commits.
Since chia-sdk-driver 0.37.1, Did::parse_child takes the p2 puzzle hash from
the hint without checking it, leaving that to the caller. Without the check,
anyone spending a DID could name an arbitrary owner, which sync recorded and
the XCHandles verifier would have resolved to.
CoinStateUpdate is sent on ordinary blocks and only when subscribed coins
change, so invalidating on every one of them marked fresh mappings stale
without a reorg, and missed reorgs that didn't touch the wallet's coins.
NewPeakWallet is sent for every peak, and its fork point is below the
previous peak only when blocks were rolled back.
- Treat a handle as expired once the local clock reaches its expiration,
  since the peak timestamp comes from a peer which may be behind.
- Keep the rejection reason if marking the mapping unverified fails.
- Map lookups that may succeed if retried to an internal error rather than
  a bad request.
- Share one HTTP client between lookups so API connections are reused.
- Fetch the chain tip at the same time as the first API response.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread crates/sage-wallet/src/sync_manager.rs
# Conflicts:
#	clippy.toml
#	crates/sage-config/Cargo.toml
- main added 0006_clawback_v1, so the XCHandles migration becomes 0007.
- Import crate items through a single crate group, and group the test
  module with the other modules.
- Format the API fixtures with Prettier.
- main added 0007_nft_metadata_updaters, so the XCHandles migration
  becomes 0008.
- Keep both sides of the RPC tests.
- Add aggregated_signature to a SignCoinSpends in the RPC tests. #907 added
  the field and #925 added the test, so main's RPC tests don't compile.
- Running out of hops while following the resolved singleton no longer
  counts as unavailable. The singleton is known to have moved, so falling
  back to a cached owner could return an outdated address.
- A peer's reorg only invalidates mappings verified against blocks up to
  that peer's previous peak, so a lagging peer can't mark mappings verified
  at the real tip as stale.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 283da99. Configure here.

Comment thread crates/sage-wallet/src/xchandles/error.rs
A resolved singleton that moved too often to follow, or that is now an
unspent eve coin, can't be the owner stored from an earlier verification.
Leaving the mapping verified let a later outage return the old address.
@Rigidity
Rigidity merged commit 521edf0 into main Oct 26, 2026
12 checks passed
@Rigidity
Rigidity deleted the feat/xchandles-resolver branch October 26, 2026 02:45
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.

Build the trusted XCHandles foundation and verified resolver

1 participant