Skip to content

Resolve XCHandles safely in every recipient flow - #932

Merged
Rigidity merged 8 commits into
mainfrom
feat/xchandles-recipients
Oct 26, 2026
Merged

Rigidity merged 8 commits into
mainfrom
feat/xchandles-recipients

Conversation

@Rigidity

@Rigidity Rigidity commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #871. Phase 1 of #867.

Recipients can now be written as @handle everywhere Sage takes one. Handles go through one backend pipeline built on the verified resolver from #870, so RPC, WalletConnect and Sage Apps callers get the same checks as the UI. A handle which changed hands can't silently redirect a transaction.

Pipeline (crates/sage/src/utils/recipients.rs)

  • resolve_recipients sits next to Sage::parse_address and is used by:
    • send_xch, send_cat, their bulk versions, multi_send and clawbacks
    • transfer_nfts, transfer_dids and transfer_options
    • bulk_mint_nfts royalty and target addresses
    • make_offer's receive address
    • create_transaction send and mint actions
  • Requests without a handle behave exactly as before, including their errors. Surrounding whitespace is ignored, as it is in the UI preview.
  • Handles are resolved concurrently and deduplicated. Every recipient which can't be used is reported at once, so bulk sends get per-recipient errors.
  • The summary gains handles: each handle with the verified address it resolved to, and whether that came from the offline cache.
  • HandleRecord gains:
    • changed_from: the address a send has to confirm the new one against, if any. The frontends read this instead of deriving the rule themselves.
    • updated_height: the later of the slot's and the resolved singleton's confirmation heights, so the latest height at which the address could have changed. The slot is also recreated by activity on neighboring handles, so this can be later than the handle's own last update.
    • resolved_confirmation_height.

Changed-hands protection

  • A handle is rejected if it resolves to a different address than the one it changed from (an unacknowledged change in the store), or than the one last sent to.
  • Requests take confirmed_handles: [{ handle, address }]. Confirming the current address acknowledges the change, and also pins it: if the handle resolves anywhere else by the time the request runs, it's rejected.
  • A transaction built without auto_submit remembers its handle recipients, keyed by its coin spends. submit resolves them again right before submitting, through sign_coin_spends or submit_transaction, and refuses if any moved. The record is kept in memory, limited to 64 transactions, and cleared when switching wallets.
  • After a successful submission, the address is recorded as last sent and the change is acknowledged, using the helpers from Build the trusted XCHandles foundation and verified resolver #870. Creating an offer counts as sending to its receive address.

Errors

  • New recipient error kind with a RecipientError per failed recipient:
    • kinds: invalid, unsupported_network, not_registered, unverified, unavailable and changed
    • fields: handle, address and previous_address
  • RPC returns these as a JSON body, with 409 when a handle changed and 400 otherwise.
  • The Tauri error carries the same list as recipients.
  • WalletConnect requests which fail this way get the list as JSON in the error's data.

Offers with handle NFTs

  • view_offer returns handle_nfts for offered NFTs whose dn (Classify NFT metadata updaters and support the XCHandles metadata format #911) is written as @handle. Names without the @ aren't treated as claims, so an NFT that happens to be named like a handle doesn't block offers while the API is down. dn is only used to pick which handle to look up; the registry must list the NFT as the handle's owner.
  • Claims are checked concurrently, at most 8 per offer. Any beyond that are reported as unavailable, which blocks taking the offer.
  • When it does, /handle/{handle}/pending-transfer is checked. Statuses are verified, pending_transfer, not_associated and unavailable.
  • take_offer checks again, and refuses if a transfer is pending or the check couldn't be done.

Sage Apps

  • wallet.sendXch to a handle always asks the user for approval, even for apps allowed to auto-submit.
  • The host resolves the handle when building the approval (prepare_approval). The card shows the verified address and the height it was last updated at, and requires a tick to continue if the handle changed.
  • The approved address is sent back as confirmedAddress and pinned.
  • Bridge errors only carry a code and message, so failures use the codes recipient_changed and recipient_unresolved.

UI

  • The Send page, including bulk send, the transfer dialog for NFTs, DIDs and options, and the mint royalty field accept @handle and show a live preview: verified, cached or offline, changed, not registered or expired, and unavailable, along with the height the handle was last updated at. The preview calls resolve_handle, and the backend checks again regardless.
  • The WalletConnect chia_send approval shows the same preview.
  • withHandleConfirmation wraps those requests. When the backend reports a changed handle, a dialog shows the old and new addresses and requires a tick before retrying with the new address confirmed. Declining is passed to dApps as a cancellation with the recipient list.
  • The confirmation dialog lists each handle with its resolved address and last updated height.
  • The Offer page and WalletConnect take-offer dialog warn about handle NFTs, and the Take button is disabled for a pending or unchecked transfer.
  • VITE_DISABLE_XCHANDLES only hides handles from placeholders and hints. The backend accepts handles regardless, so previews, the confirmation dialog's Handles section and offer warnings are always shown when a handle is involved.

CLI

  • sage wallet send accepts @handle, shows what each handle resolves to and its last updated height, and takes --confirm-handle <ADDRESS> to confirm a changed handle.
  • Recipient errors are printed one per line, with the exact --confirm-handle command for a changed handle.

Testing

  • sage-rpc tests against a local mock XCHandles API, so they never reach the real one:
    • sending to a handle and recording the last-sent address after submission
    • changed mappings needing confirmation, including a confirmation of the stale address being rejected
    • a handle changing between review and submission, with the transaction refused, including on retry
    • mixed bulk recipients covering not registered, unavailable, invalid handle, wrong-network address, stale cache and too-old cache
    • unchanged raw-address errors, and an unsupported network
    • surrounding whitespace, changed_from and updated_height on a summary handle, and the error for a change found at submission
    • offers with an NFT not associated with its handle, a verified one, a pending transfer, and a check that can't be done
    • an offered NFT named like a handle without the @, which isn't a claim and doesn't block taking the offer
  • Unit tests for the change rule and the pending-transaction record. Bridge tests check that the confirmed address only applies to handles. A CLI test covers the recipient error message.
  • A vitest for withHandleConfirmation: confirm, decline, and errors which aren't handle changes.
  • cargo clippy --workspace --all-features --all-targets and cargo fmt are clean, and cargo test --workspace passes.
  • pnpm lint, prettier, tsc -b and vitest pass. The approval app typechecks.
  • The handle preview was checked in a dev build on testnet11. The other UI flows haven't been tested by hand yet.

Notes for reviewers

  • Resolver order changed (Build the trusted XCHandles foundation and verified resolver #919 code). resolve_handle now asks the API first and fetches the chain tip once it answers, instead of both at once. Peers aren't queried while the API is down, and the peer simulator, which can't serve block headers, no longer drops the connection in tests. The cost is one extra peer round-trip on a successful lookup.
  • Pending-transfer trust. The pending-transfer answer comes from the API and isn't verified on chain. A false positive only blocks a trade, but an API which hides a pending transfer isn't caught.
  • A change found at submission shows both addresses and says the transaction has to be created again, since its coin spends were built for the old address. The new resolution is stored, so the next attempt asks for confirmation.
  • The Make Offer page has no receive-address field, so handles there are API-only.
  • No new read endpoints. None were added to RPC, WalletConnect or the bridge; that's Expose XCHandles endpoints through RPC, WalletConnect, and the Sage Apps bridge #913.
  • Translations. .po catalogs aren't updated, so the next release needs pnpm extract.
  • Bindings. bindings.ts is regenerated after merging main.
  • Follow-ups are tracked in XCHandles recipient follow-ups #940: recording and showing why a cached mapping was used (the "offline" label is misleading when only verification failed), retrying on another peer, lookup latency, lock holding during lookups, and the 64-transaction limit on the submit-time re-check.

Note

High Risk
Changes payment recipient resolution, submission re-checks, and offer-taking guards—core wallet and fund-safety paths—with new failure modes that all API consumers must handle.

Overview
Adds XCHandles (@handle) as first-class recipients across sends, transfers, offers, RPC, CLI, and Sage Apps, with one shared resolution pipeline so a handle that changed hands cannot redirect funds without explicit user confirmation.

Backend: Recipients starting with @ are verified against the registry; transactions accept optional confirmed_handles to acknowledge a new resolution. Builds without auto-submit remember handle recipients and re-resolve at submit; successful sends record last-sent addresses and acknowledge changes. New recipient errors (JSON on RPC, including 409 when a handle changed) replace opaque failures for bulk sends. Offers gain handle_nfts checks (owner + pending transfer) and block take_offer when unsafe.

Apps & UI: wallet.sendXch to a handle always requires approval with resolved address UI and confirmedAddress pinning; bridge-approval shows change warnings. The main app wraps sends with withHandleConfirmation, shows handle previews in confirmation flows, and adds offer warnings for handle NFTs.

Other: CLI --confirm-handle, TransactionSummary.handles, resolver now hits the API before fetching chain tip (#919), plus extensive RPC/integration tests with a mock XCHandles API.

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

Adds a shared recipient pipeline around Sage::parse_address. Recipients written as
@handle are verified through the resolver, a handle which resolves to a different
address than it changed from or was last sent to is rejected until the new address
is confirmed, and transactions which aren't submitted straight away are checked
again before they are. Submitting records the address last sent to.

Offers whose offered NFTs claim to be handle NFTs are checked against the registry
and the pending-transfer endpoint, and can't be taken while a transfer is pending.
The host resolves a handle recipient when the approval is requested, so the
approval shows the verified address and asks the user to acknowledge a handle
which changed. The approved address is pinned for the send. Handles always
require approval, even for apps that may auto submit.
Send (including bulk), NFT/DID/option transfers, NFT mint royalties and
WalletConnect sends accept @handle recipients behind VITE_DISABLE_XCHANDLES.
Handles are previewed as they're entered, a handle which changed asks the user
to confirm the new address showing both addresses, and confirmations show each
handle with the address it was resolved to. Offers with a handle NFT whose
transfer is pending, or which can't be checked, can't be taken.
# Conflicts:
#	crates/sage/src/endpoints/offers.rs
#	src/bindings.ts

@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 8d6a32c. Configure here.

Comment thread crates/sage/src/endpoints/xchandles.rs
@Rigidity
Rigidity marked this pull request as draft October 26, 2026 04:58
- Only treat offered NFT names written as @handle as handle claims, and
  check them concurrently, capped at 8 per offer
- Ignore surrounding whitespace in recipients, as the preview does
- Preview handle recipients and show offer warnings even when XCHandles
  is hidden, since the backend still resolves them
- Report the address a handle changed from in HandleRecord.changed_from,
  instead of each frontend deriving it
- Explain that a handle which changed after a transaction was created
  needs the transaction to be created again
- Add --confirm-handle to sage wallet send, and show recipient errors
  readably
HandleRecord gains resolved_confirmation_height and updated_height, the
later of the slot's and the resolved singleton's confirmation heights.
It's shown in the handle preview, the confirmation dialog, the Sage Apps
send approval card and the CLI send preview.
@Rigidity Rigidity mentioned this pull request Oct 26, 2026
8 tasks
@Rigidity
Rigidity marked this pull request as ready for review October 26, 2026 18:08
@Rigidity
Rigidity merged commit ecaecc4 into main Oct 26, 2026
11 of 13 checks passed
@Rigidity
Rigidity deleted the feat/xchandles-recipients branch October 26, 2026 18:08
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.

Resolve XCHandles safely in every recipient flow

1 participant