Skip to content

Add cold-wallet signing for NEAR transactions - #174

Merged
illuzen merged 5 commits into
mainfrom
near-cold-signing
Oct 1, 2026
Merged

illuzen merged 5 commits into
mainfrom
near-cold-signing

Conversation

@illuzen

@illuzen illuzen commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Sign a NEAR transaction prepared by near-cli-rs (sign-later) with a cold Quantus wallet, over the same ur:quantus-sign-request QR transport Quantus extrinsics already use. This covers any contract call (DEX swaps, lending, staking) rather than only the hot near send / near dao commands.

near contract call-function as-transaction v2.ref-finance.near storage_deposit \
  json-args '{"registration_only": true}' prepaid-gas '30 Tgas' attached-deposit '0.125 NEAR' \
  sign-as vault.alice.near network-config mainnet \
  sign-later --signer-public-key ml-dsa-65:<key> --nonce <n+1> --block-hash <recent> save-to-file unsigned.json

quantus near sign-cold --unsigned-tx @unsigned.json --wallet my_cold --network mainnet --out signed.b64
near transaction send-signed-transaction file-with-base64-signed-transaction signed.b64 network-config mainnet send

Protocol

  • Envelope v2: {v: 2, chain: "near", network, payload: "0x<borsh TransactionV0>"} in the existing UR type. v1 is untouched; AnySignRequest dispatches on v, and wallets that only read v1 refuse v2 by version as before. No signer field: the transaction already names signer_id and the exact public_key.
  • Device: decodes the borsh transaction to display it, signs only if tx.public_key is its own ML-DSA-65 key, signs SHA-256(borsh(tx)) as a pure FIPS 204 signature (empty context — NEAR verifies without one), answers signature[3309] ‖ public_key[1952].
  • CLI verification order: response length → response key == tx.public_key → response key hashes to the cold wallet's stored SS58 → signature verifies. Length/parse failures are rescan-safe; the rest abort.

Changes

  • src/qr/sign_request.rs: NearSignRequest (v2, deny_unknown_fields), AnySignRequest; shared payload hex/size helpers.
  • src/near/protocol.rs: BorshDeserialize on the wire types, Transaction::from_bytes/from_base64 (rejects trailing bytes), SignedTransaction::to_base64, describe_actions(), format_near.
  • src/near/cold.rs (new): sign_transaction_cold, validate_near_response, load_unsigned_transaction (bare base64 or the save-to-file JSON).
  • src/cli/near.rs: quantus near sign-cold --unsigned-tx <b64|@file> --wallet <cold> [--network] [--out] [--send] [--rpc-url].
  • src/cli/cold_signing.rs: QR hand-off factored into present_sign_request / response_source / read_signature_response so both paths share it; cold-sign-sim answers v2 requests with a local ML-DSA-65 wallet.
  • README: NEAR section incl. the cold flow.

Verification

  • ./clippy.sh clean (fmt + taplo + clippy --all-targets --all-features -D warnings); 808 tests pass.
  • Golden test pins our borsh layout and hash to near-cli-rs 0.30.1 sign-later output (be358ec9…), byte-identical on re-encode.
  • Headless end-to-end with the real binary: near-cli-rs built a v2.ref-finance.near storage_deposit tx with an ml-dsa-65: signer key → sign-cold (--cold-request-out/--cold-response-in) → cold-sign-sim → signed base64. near transaction print-transaction signed <ours> decodes it as an ml-dsa-65: signature, and its pub key hash equals our show-key handle.
  • Refusal paths exercised live: wrong device key, right key but wrong --wallet, hot wallet passed to sign-cold, ML-DSA-87 response size.

Follow-ups (not in this PR)

  • Cold-wallet app: decode v2, review screen, sign with empty context, export the ML-DSA-65 public key.
  • CLI: store the NEAR public key in the cold record so near send/dao --wallet <cold> can look up the nonce and route through sign_transaction_cold.

Sign a NEAR transaction prepared by near-cli-rs (sign-later) with a cold
Quantus wallet over the existing ur:quantus-sign-request QR transport.

- Envelope v2 {v: 2, chain: "near", network, payload: borsh(TransactionV0)}
  alongside the untouched v1; AnySignRequest dispatches on version.
- NEAR wire types gain BorshDeserialize plus from_bytes/from_base64 and a
  per-action description for display on the signing device.
- quantus near sign-cold --unsigned-tx <b64|@file> --wallet <cold>
  [--out] [--send]: verifies the response key is tx.public_key, hashes to
  the cold wallet's stored address, and verifies the empty-context ML-DSA-65
  signature over SHA-256(borsh(tx)) before assembling the SignedTransaction.
- developer cold-sign-sim answers v2 requests with a local ML-DSA-65 wallet.
- Golden test pins the borsh layout and hash to near-cli-rs 0.30.1 output.
@illuzen illuzen added the bot-review Request automated review from review-bot label Oct 1, 2026
@illuzen
illuzen requested a review from n13 October 1, 2026 06:33

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewer model: GPT-6 Sol

Request changes: the new cold-signing flow can show a misleading transaction preview and emit requests its own v2 decoder refuses.

  1. [P1] Escape untrusted FunctionCall arguments in the signing preview. At src/near/protocol.rs:253-258, String::from_utf8_lossy preserves terminal controls such as ESC, carriage return, and newline. Both src/near/cold.rs:163-164 and the simulator print this string directly. A prepared transaction with control bytes in its arguments can erase or spoof the displayed receiver, deposit, or action lines while the original bytes are signed. Render control bytes visibly (or show hex for non-text arguments), and cover this with a regression test.

  2. [P2] Include the gas allowance when previewing AddKey. At src/near/protocol.rs:266-272, a FunctionCall permission displays its receiver and methods but drops p.allowance. An unlimited key and a limited key therefore produce the same review text despite granting different spending authority. Show the allowance, including the unlimited case.

  3. [P2] Reject oversized v2 requests before QR handoff. At src/near/cold.rs:169-170, any decoded transaction is encoded and displayed, but src/qr/sign_request.rs:46-49 rejects payloads above 8 KiB. A valid prepared call with sufficiently large arguments (the ML-DSA-65 key already consumes 1,952 bytes) reaches a QR that the v2 decoder, including cold-sign-sim, cannot accept. Validate the encoded transaction size before displaying it, then either coordinate a larger v2 limit across clients or document and report the supported limit clearly.

Validation: git diff --check passed; nightly rustfmt check of changed Rust files passed; 31 focused NEAR tests and 9 request-envelope tests passed. CI format, Ubuntu build/test, analysis, and security jobs passed; macOS and examples were still running at review time. A physical v2 cold-wallet integration test remains unavailable because device/app support is deferred in the PR description.

Function-call arguments are shown as hex unless every character renders
in place, so control or bidi characters in a prepared transaction cannot
rewrite the lines the user is approving. AddKey previews include the
gas allowance, unlimited or not. NearSignRequest::new refuses a
transaction over the 8 KiB payload limit every decoder enforces, before
anything is displayed.
@illuzen

illuzen commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 12b5202:

  1. FunctionCall args — render_args shows the text only when every char renders in place (no is_control(), no bidi/zero-width format chars U+200B–200F, U+202A–202E, U+2060–2064, U+2066–2069, U+FEFF); anything else, or non-UTF-8, is shown as 0x… hex. Both sign-cold and cold-sign-sim go through Action::describe, so both are covered. Regression test function_call_args_with_terminal_controls_are_shown_as_hex covers ESC/CR/LF/TAB, RLO, ZWSP and raw bytes.
  2. AddKey allowance — function-call permissions now print allowance <n> NEAR or allowance unlimited (add_key_describe_shows_the_allowance).
  3. Oversized v2 requests — MAX_PAYLOAD_BYTES (8 KiB) is now public and NearSignRequest::new returns Result, refusing anything over it with a message naming the limit; sign_transaction_cold builds the request before printing the summary, so nothing is shown for a transaction no decoder will accept. The cold-wallet app side (NEAR cold signing: review and sign NEAR transactions with the ML-DSA-65 key quantus-apps#677) enforces the same 8 KiB in NearSigningRequest.decode, so the two clients agree. Test near_request_refuses_a_transaction_its_decoder_would_refuse.

./clippy.sh (nightly fmt, taplo, clippy -D warnings) clean; 33 NEAR + 10 envelope tests pass.

@illuzen illuzen added the bot-review Request automated review from review-bot label Oct 1, 2026
Method names, account ids, and beneficiaries follow the same rule as call arguments: text that contains terminal controls or bidi marks is shown as hex, so it cannot rewrite the lines a signer is approving.

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewer model: GPT-6 Sol

Request changes: the NEAR cold-signing preview still makes distinct signed transactions look identical to the signer.

  1. [P1] Identify deployed contract code in the preview (src/near/protocol.rs:252). DeployContract shows only the byte length. Two different WASM blobs of the same size therefore produce the same action line in both sign-cold and cold-sign-sim, although one can replace an account's contract with different behavior. Show a cryptographic digest of a.code alongside its length so the signer can compare it with the intended artifact; test that same-length, different code has different previews.

  2. [P2] Preserve the exact FunctionCall gas limit (src/near/protocol.rs:253-258). Dividing a.gas by 1,000,000,000,000 truncates the value: 1,999,999,999,999 gas appears as 1 TGas, and positive gas below 1 TGas appears as 0 TGas. The signer cannot verify the actual execution limit or cost from this line. Display the exact gas amount or a lossless fractional TGas value, with a nonintegral regression case.

The earlier argument escaping, AddKey allowance, and request-size findings are addressed. The latest commit also escapes method names and account fields before terminal display.

Validation at 25b33f1: git diff --check and cargo +nightly fmt --check passed; SKIP_CIRCUIT_BUILD=1 cargo test --locked --lib near:: (34 tests), qr::sign_request::tests (10), and cli::cold_signing::tests (7) passed. CI for this head was still running when checked. Physical cold-wallet v2 integration remains outside this PR.

@n13 n13 removed the bot-review Request automated review from review-bot label Oct 1, 2026
A DeployContract line now includes the SHA-256 of the WASM, so two blobs of the same length do not look identical. FunctionCall gas is shown as an exact TGas value, including a fractional remainder, instead of truncating to a whole TGas.
@illuzen
illuzen requested a review from n13 October 1, 2026 07:17

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewer model: GPT-6 Sol

Approve at c6e9ab6: no blocking findings remain. The two issues from the previous review are fixed: DeployContract previews identify the code with a SHA-256 digest, and FunctionCall gas is shown without truncation. The earlier argument-display, AddKey allowance, and request-size findings are also addressed.

Validation: git diff --check and cargo +nightly fmt --check passed. SKIP_CIRCUIT_BUILD=1 cargo test --locked --lib near:: passed 36 tests; qr::sign_request::tests passed 10; cli::cold_signing::tests passed 7. At review time, GitHub's format, Ubuntu build/test, analysis, and security checks passed; macOS and examples were still running. Physical v2 cold-wallet integration remains untested because device/app support is deferred to the follow-up described in the PR.

Matches the cold wallet, which now rejects any other label, so the CLI
never emits a request the device refuses, and never accepts a scanned
one carrying a lookalike label.
@illuzen

illuzen commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

870e24c (rebased onto the two preview commits already on the branch): NEAR_NETWORKS = ["mainnet", "testnet"], enforced in NearSignRequest::new and so in decode too — the cold wallet (Quantus-Network/quantus-apps#677) now refuses any other label, and this keeps the CLI from emitting a request the device rejects or accepting a scanned lookalike. Test near_request_accepts_only_exact_network_labels. ./clippy.sh clean; 36 NEAR + 11 envelope tests pass.

@illuzen illuzen added the bot-review Request automated review from review-bot label Oct 1, 2026
@illuzen
illuzen merged commit 72a89a8 into main Oct 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot-review Request automated review from review-bot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants