feat(near): add NEAR Intents preset (decode + render) and merge converters - #428
feat(near): add NEAR Intents preset (decode + render) and merge converters#428shahan-khatchadourian-anchorage wants to merge 11 commits into
Conversation
1c0b792 to
6b0daff
Compare
There was a problem hiding this comment.
Pull request overview
Adds NEAR Intents support to visualsign-near by introducing an intents “preset” (decode + render) and merging the crate’s previously separate input-format handling into a single NearTransaction enum that discriminates between on-chain borsh transactions and pre-signature intents JSON envelopes.
Changes:
- Introduces
presets::intentswith JSON args decoding, token metadata/formatting, rendering for supported intent variants, and partial signature verification plumbing. - Merges NEAR parsing into
NearTransaction::{OnChain, Intent}and updates the converter to dispatch/render accordingly (includingexecute_intentsdecoding forintents.near). - Adds crate feature wiring for diagnostics passthrough (
visualsign/diagnostics) and updates tests to cover the new paths.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/chain_parsers/visualsign-near/src/tx.rs | Parses either borsh NEAR transactions or validates/stores DefusePayload JSON for intent signing flows. |
| src/chain_parsers/visualsign-near/src/convert.rs | Dispatches conversion based on NearTransaction variant and decodes/renders execute_intents calls. |
| src/chain_parsers/visualsign-near/src/lib.rs | Exposes the new presets module. |
| src/chain_parsers/visualsign-near/src/presets/mod.rs | Adds the presets module root and exports intents. |
| src/chain_parsers/visualsign-near/src/presets/intents/mod.rs | Defines the intents preset API, registry types, errors, and integration tests. |
| src/chain_parsers/visualsign-near/src/presets/intents/args.rs | Deserializes execute_intents JSON args into MultiPayload list. |
| src/chain_parsers/visualsign-near/src/presets/intents/render.rs | Renders intents, envelope fields, and signature status into VisualSign fields. |
| src/chain_parsers/visualsign-near/src/presets/intents/tokens.rs | Provides seeded NEP-141 token metadata and unit formatting helpers. |
| src/chain_parsers/visualsign-near/src/presets/intents/verify.rs | Adds signature verification + payload extraction helpers (partial, used by rendering). |
| src/chain_parsers/visualsign-near/Cargo.toml | Adds thiserror and introduces a diagnostics feature wired to visualsign/diagnostics. |
| src/Cargo.lock | Pulls in thiserror resolution for the NEAR crate. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub enum NearIntentsError { | ||
| #[error("execute_intents args were not valid JSON: {0}")] | ||
| ArgsNotJson(String), |
There was a problem hiding this comment.
Fixed -- renamed NearIntentsError::ArgsNotJson to InputNotJson with a generic message. The variant is shared between execute_intents args decoding and try_render_single_intent's payload JSON parse, and the old message named only the former.
| fields.extend(render_signature(standard_name(mp), &check)?); | ||
| if let Some(payload) = &extracted { | ||
| fields.extend(render_single(payload, registry)?); | ||
| } |
There was a problem hiding this comment.
Fixed -- verify_and_extract now returns Result<DefusePayload<..>, String> instead of collapsing extraction failure to None, and section() renders that error as an "extraction" diagnostic alongside the signature status, so a failed extraction is visible instead of silently omitting the envelope/intents.
6b0daff to
47a9a80
Compare
47a9a80 to
75f7171
Compare
75f7171 to
557595c
Compare
557595c to
aaacf9b
Compare
prasanna-anchorage
left a comment
There was a problem hiding this comment.
Went through the preset and built it locally against the real defuse/v0.4.2 crates -- 50 tests pass, clippy --all-targets -D warnings clean. The module boundary is well drawn: defuse-*/near-sdk types genuinely stay behind presets::intents, and splitting verify() from extract_defuse_payload() so a bad signature still renders the decoded intents is the right call. storage_deposit and msg rendering on all three withdraw variants, with regression tests naming why, is exactly the level of care this surface needs.
Two findings below I reproduced by running them, not by reading -- both are cases where the signing screen shows the user less than what they'd be authorizing. Third is a cheap robustness fix.
Smaller note, not worth its own thread: deadline.has_expired() reads the system clock (hence the new chrono clock feature). Inside the enclave the wall clock isn't necessarily trustworthy or even set, so a stale-deadline payload could render with no warning. Not a blocker -- the warning is advisory and the contract enforces the real deadline -- but worth knowing the field is best-effort.
| let mut fields = vec![create_text_field("Standard", standard)?.signable_payload_field]; | ||
| match check { | ||
| SignatureCheck::Valid { recovered_key } => fields.push( | ||
| create_text_field("Signature", &format!("valid (recovered {recovered_key})"))? |
There was a problem hiding this comment.
This renders Signature: valid (recovered <key>), and three lines later render_envelope renders Signer: <signer_id>. Adjacent on a signing screen, those read as one claim: this account signed this. But they're independent -- verify() recovers whatever key signed the bytes, and nothing checks that key is bound to signer_id. verify.rs says so explicitly ("NOT a proof of account binding"); the rendered output doesn't.
I reproduced it. Payload claiming signer_id: alice.near, signed with a key that has no relationship to that account, withdrawing 5 NEAR to attacker.near:
Signed Intent 1 of 1
Standard raw_ed25519
Signature valid (recovered ed25519:4uA4xtFiedwCyGLuDLn49Pz4AdNnKJFpEuH4LgESHjwK)
Signer alice.near
Verifying Contract intents.near
Deadline 2999-01-01T00:00:00+00:00
To attacker.near
Amount 5 NEAR
Everything there is true and the payload is exactly what the parser was handed -- the problem is the word valid unqualified next to an account the signature says nothing about. The on-chain contract does enforce key-to-account binding, so this isn't exploitable against the protocol; the risk is a reviewer or a wallet UI treating a green "valid" as attestation that alice.near authorized it.
The parser can't resolve account keys (no chain access), so the fix is wording, not verification -- make the field say only what was checked:
create_text_field(
"Signature",
&format!("valid for key {recovered_key} (key-to-account binding not verified)"),
)?Or split into a Signed By Key field plus a diagnostic when the recovered key can't be tied to signer_id. Either way I'd rather the payload under-claim than let valid carry an implication the code deliberately doesn't check.
There was a problem hiding this comment.
Reworded per your suggestion — the field now reads valid for key <key> (key-to-account binding not verified) instead of bare valid (recovered <key>). States exactly what was checked and nothing more.
There was a problem hiding this comment.
Implemented here in #428 (not #429), since render_signature/render_envelope live in this PR and needed the restructuring. SignatureCheck::Valid now also carries implied_account_id (via defuse_crypto::PublicKey::to_implicit_account_id()), and render_signature compares it against signer_id when the id has a recognized implicit-account shape (NEAR's 64-hex, or defuse's 0x+40-hex EVM convention) — a real match/mismatch check, not a hedge. Named accounts (e.g. alice.near) still get the original hedge wording, since that binding genuinely isn't checkable offline. A mismatch renders as a diagnostic (a hard finding), not folded into the same wording as the confirmed case. Test coverage covers the full matrix: confirmed/mismatch for both shapes, the named-account hedge, and the shape-detection helper's edge cases directly.
There was a problem hiding this comment.
Reworked the wording once more after checking the contract's rule. Account::has_public_key (defuse/src/contract/accounts/account/mod.rs:88-91) accepts a key that either derives the account id or sits in the account's on-chain public_keys set — so a non-match is expected for any account that authorized a key via AddPublicKey, and an implicit account that removed its implicit key would take the warning on every payload it signs. A match isn't proof either: is_implicit_public_key_removed() can make the contract reject a key that does derive the id.
Kept the diagnostic, dropped the verdict. Both arms now state only the observation — valid for key <k> (derives signer_id <id>), or that it doesn't derive signer_id and the account may still have authorized it on-chain, which this parser can't check.
| create_text_field("Token", w.token.as_str())?.signable_payload_field, | ||
| create_text_field("To", w.receiver_id.as_str())?.signable_payload_field, | ||
| ]; | ||
| for (id, amount) in w.token_ids.iter().zip(w.amounts.iter()) { |
There was a problem hiding this comment.
zip stops at the shorter iterator, and MtWithdraw carries token_ids: Vec<TokenId> and amounts: Vec<U128> as two independent vectors with no length agreement enforced at deserialize time. A payload with more token_ids than amounts renders only the overlap -- the extra withdrawals are silently dropped from the signing view.
Reproduced with {"token_ids":["gold","silver","bronze"],"amounts":["5"]}:
Token mt.near
To attacker.near
MT Token gold x5
=> MT Token lines rendered: 1 (token_ids declared: 3)
The user sees one token move; the intent names three. This is the same class of gap the storage_deposit regression test was written to prevent -- something material to what's being authorized not reaching the screen -- and here it's attacker-controlled input rather than an oversight.
Suggest treating a length mismatch as malformed rather than rendering a partial view:
if w.token_ids.len() != w.amounts.len() {
return Err(/* malformed mt_withdraw: N token_ids vs M amounts */);
}Erroring out is defensible since the contract would reject it anyway; a diagnostic plus rendering every token_id (amount shown as unknown where missing) would also work. What's not safe is the current silent truncation. Worth a regression test alongside the storage_deposit ones.
Unrelated and much smaller, same function: min_gas is on MtWithdraw and isn't rendered. Probably fine to omit, just flagging it's dropped.
There was a problem hiding this comment.
Fixed — render_mt_withdraw now rejects a token_ids/amounts length mismatch as VisualSignError::ValidationError instead of silently rendering the zip() overlap. Added a regression test using your exact repro (3 token_ids, 1 amount). Left min_gas unrendered as you suggested.
| let signature = match payload { | ||
| MultiPayload::Erc191(signed) => &signed.signature, | ||
| MultiPayload::Tip191(signed) => &signed.signature, | ||
| _ => return false, |
There was a problem hiding this comment.
The guard itself is correct -- I checked defuse/v0.4.2 and Erc191 and Tip191 are the only variants that route to PublicKey::Secp256k1 / ecrecover (multi.rs:82-83), so the two arms above cover exactly the panicking paths today.
My concern is the _. This function exists because a panic in a signing service is unacceptable, but the catch-all silently pins that guarantee to the current variant list of a git-tagged dependency. If a later bump adds a secp256k1-backed standard, this falls through to false, reaches verify(), and the panic is back -- with nothing failing at compile time to warn you.
Since the whole point is panic-avoidance, I'd make the compiler enforce it by listing the variants explicitly:
MultiPayload::Nep413(_)
| MultiPayload::RawEd25519(_)
| MultiPayload::WebAuthn(_)
| MultiPayload::TonConnect(_)
| MultiPayload::Sep53(_) => return false,Then a new variant is a build break at exactly the place someone needs to think about it, instead of a latent crash. A short comment on why each listed variant is panic-safe (ed25519/p256, no recovery id) would make the next bump mechanical.
There was a problem hiding this comment.
Done — replaced the _ => false catch-all with the explicit list of the 5 non-secp256k1 MultiPayload variants (Nep413, RawEd25519, WebAuthn, TonConnect, Sep53), with a short comment on why each is panic-safe (ed25519/P256, no recovery id). A future defuse-core bump adding a secp256k1-backed standard now fails to compile here instead of silently reaching the panic path.
4f32bde to
9f8fe9e
Compare
9f8fe9e to
074e228
Compare
- verify.rs: SignatureCheck gains MalformedEncoding(String), distinct from Invalid. has_invalid_secp256k1_recovery_id (renamed invalid_secp256k1_recovery_id_reason) now returns the specific reason instead of a bool, with a callout when the recovery id is 27/28 (Ethereum's v = recovery_id + 27 convention, as real wallets like MetaMask emit). Previously any out-of-range recovery id -- including this realistic one -- rendered as "signature verification failed", which reads as tampering/fraud on a signing screen even though the cryptography is fine; only the encoding convention differs. render.rs (introduced in #428) needs a matching match arm since the enum gained a variant it didn't know about -- normal for a stacked PR touching an earlier PR's file. - Test coverage: the existing recovery-id test only used 29, an arbitrary value outside every real convention, so it never touched the realistic v=27/28 case. Split into two tests (29 and 27), asserting MalformedEncoding with the right message content for each. - The three ERC-191 verification tests called build_erc191_vector() fresh in-process instead of reading the committed _vector_erc191.input fixture (only the drift-check test read the file). That meant the pinned fixture never reached the code under test -- a generator regression would silently rewrite the file under UPDATE_TESTDATA=1 and the verification tests would still agree. Added ERC191_VECTOR (include_bytes!, matching the existing raw_ed25519 pattern) and switched those three tests to decode it directly; build_erc191_vector() now backs only the drift check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- verify.rs: SignatureCheck gains MalformedEncoding(String), distinct from Invalid. has_invalid_secp256k1_recovery_id (renamed invalid_secp256k1_recovery_id_reason) now returns the specific reason instead of a bool, with a callout when the recovery id is 27/28 (Ethereum's v = recovery_id + 27 convention, as real wallets like MetaMask emit). Previously any out-of-range recovery id -- including this realistic one -- rendered as "signature verification failed", which reads as tampering/fraud on a signing screen even though the cryptography is fine; only the encoding convention differs. render.rs (introduced in #428) needs a matching match arm since the enum gained a variant it didn't know about -- normal for a stacked PR touching an earlier PR's file. - Test coverage: the existing recovery-id test only used 29, an arbitrary value outside every real convention, so it never touched the realistic v=27/28 case. Split into two tests (29 and 27), asserting MalformedEncoding with the right message content for each. - The three ERC-191 verification tests called build_erc191_vector() fresh in-process instead of reading the committed _vector_erc191.input fixture (only the drift-check test read the file). That meant the pinned fixture never reached the code under test -- a generator regression would silently rewrite the file under UPDATE_TESTDATA=1 and the verification tests would still agree. Added ERC191_VECTOR (include_bytes!, matching the existing raw_ed25519 pattern) and switched those three tests to decode it directly; build_erc191_vector() now backs only the drift check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Test coverage for the recovery-id guard: the existing test only used 29, an arbitrary value outside every real convention, so it never touched the realistic v=27/28 case (Ethereum's v = recovery_id + 27, as wallets like MetaMask emit). Split into two tests, 29 and 27, each asserting MalformedEncoding with the right message content. The MalformedEncoding outcome itself belongs to the code it distinguishes and lives in #428, keeping this PR test-only. - The three ERC-191 verification tests called build_erc191_vector() fresh in-process instead of reading the committed _vector_erc191.input fixture (only the drift-check test read the file). That meant the pinned fixture never reached the code under test -- a generator regression would silently rewrite the file under UPDATE_TESTDATA=1 and the verification tests would still agree. Added ERC191_VECTOR (include_bytes!, matching the existing raw_ed25519 pattern) and switched those three tests to decode it directly; build_erc191_vector() now backs only the drift check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Peer Review Summary — NEAR stack #428 (COMMENT)\n\n8 findings: 2 MEDIUM, 3 LOW, 3 INFO. Detailed inline comments below.\n\nKey concern: No size limit on JSON intent input (DoS). Potential panic on truncated signature.
AI Review on behalf of @pepe-anchor. Please flag any inaccuracies.
…erters Ports the NEAR Intents protocol decoder into chain_parsers/visualsign-near/src/presets/intents/: envelope parsing, all 11 intent renderers, execute_intents dispatch, and the pre-signature single-intent render. crate:: references become super:: since the module moves from a standalone crate root into a nested presets::intents module. near_env_shim.rs does not carry over -- this session's earlier investigation found near-sdk 5.27+ (what this workspace resolves) already fixes the native-linking gap it worked around, so visualsign-near keeps the workspace's unsafe_code = "forbid" with no carve-out. verify.rs ports only its core functions (verify_and_extract, the secp256k1 recovery-id guard), since render.rs calls them directly to compile; dedicated signed-vector test coverage for verification is scoped to the next PR in the stack. The converter merge (the one non-mechanical step): tx.rs's NearTransaction becomes an enum, OnChain(Transaction) or Intent(String), discriminating borsh-vs-JSON input -- borsh decode attempted first, falling through to DefusePayload JSON validation only on failure, input that is neither rejected. This replaces two separate converters (one per input format, each its own custom chain) with one type. convert.rs re-adds the execute_intents decode branch A2 omitted, now calling the real preset, plus a new branch rendering the Intent variant. Security review of this port found a signing-integrity gap: FtWithdraw/NftWithdraw/MtWithdraw carry an optional storage_deposit (an unconditional, unrefundable wNEAR debit alongside the withdraw) and msg (switches the call into its _transfer_call form) that were never surfaced -- a user could sign a withdraw and see only the nominal amount while authorizing an invisible additional debit. Fixed here: both fields now render on all three withdraw variants, with regression tests covering presence and absence. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Rename NearIntentsError::ArgsNotJson to InputNotJson with a generic message: the variant is shared between execute_intents args decoding and the pre-signature single-payload JSON parse, and the old message named only the former, misleading callers of the latter. - Surface extraction failures instead of silently omitting the envelope/intents: verify_and_extract now keeps extract_defuse_payload's error message (Result instead of a collapsing-to-None Option), and section() renders it as an "extraction" warning alongside the signature status, so a user sees why nothing rendered rather than a silent gap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- render.rs: reword the "Signature: valid" field so it doesn't imply account binding -- verify() only recovers the signing key, never checks it against signer_id. Now reads "valid for key <key> (key-to-account binding not verified)". - render.rs: reject an mt_withdraw whose token_ids/amounts lengths disagree instead of silently rendering only the zip()-overlap. Added a regression test using the reported repro (3 token_ids, 1 amount). - verify.rs: replace the `_ => false` catch-all in has_invalid_secp256k1_recovery_id with an explicit list of the five non-secp256k1 MultiPayload variants, so a future defuse-core bump adding a new secp256k1-backed standard fails to compile here instead of silently falling through to the ecrecover panic path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Revises the #428 fix (a blanket "key-to-account binding not verified" hedge on every signature) per a follow-up review comment on #429: for NEAR-implicit (64-hex) and defuse's EVM-style ("0x" + 40-hex) signer_ids, the binding actually IS checkable offline, since both conventions derive the account id directly from the signing key (defuse_crypto::PublicKey::to_implicit_account_id()). - verify.rs: SignatureCheck::Valid now also carries implied_account_id, computed once via to_implicit_account_id() alongside recovered_key. - render.rs: adds looks_like_implicit_account() to distinguish key-derived ids from human-chosen named accounts (e.g. alice.near), where binding still genuinely requires chain access and stays a hedge. render_signature now takes the payload's signer_id and, when it has a recognized shape, does a real comparison: match renders as "account binding confirmed", mismatch renders as a diagnostic (a hard finding, not a hedge) rather than silently keeping the old generic wording. - Tests cover the full matrix: confirmed/mismatch for both shapes, the named-account hedge, and looks_like_implicit_account's shape edge cases directly (wrong length, non-hex characters, missing prefix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`render_envelope` formats the payload nonce with `hex::encode` in the lib, so `hex` has to be a normal dependency. As a dev-dependency it resolves only for the unit-test build of the lib; `cargo build -p visualsign-near` and the workspace clippy pass both fail with E0433. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rizes
`Transfer` carries an optional flattened `NotifyOnTransfer`: its `msg`
turns the internal transfer into an `mt_on_transfer` call on `receiver_id`
(the internal counterpart of the withdraws' `_transfer_call` form), and its
`state_init` initializes the receiver's contract via NEP-616 in the same
receipt. `AuthCall` carries the same `state_init`. None of it reached the
signing screen, so `{"intent":"transfer","receiver_id":...,"tokens":{...},
"msg":"...","state_init":{...}}` rendered as just `To` + `Amount`.
Both now render: the notification's `msg` as `Message`, and an attached
`state_init` as `State Init: (not fully decoded)`, matching how
`actions.rs` treats NEAR's own `DeterministicStateInit` action. Every
intent variant is now field-complete except `min_gas`.
Also states the signature/account-binding outcome literally. The defuse
contract accepts a key that either derives the account id *or* sits in the
account's on-chain key set (`Account::has_public_key`), so a derivation
match is not proof of authorization (an account may remove its implicit
key) and a non-match is expected for any account that added keys via
`AddPublicKey`. The fields now say which of the two was observed and name
the on-chain check that neither settles.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`format_units` scales by `10^decimals`, which exceeds `u128` above 38. `resolve` is the only path that hands it a value from outside the seed table, so it now drops metadata above that bound: the amount renders in its honest unresolved form (raw base units plus the asset id) instead of overflowing, or -- with overflow checks off -- dividing by a wrapped-around scale and misrendering the amount being signed. Mirrors the `checked_pow` guard in `visualsign-solana`'s `format_token_amount`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`UnknownAction` and `MalformedAssetId` have no construction site. Both conditions are caught by serde before the render sees them: an unknown `intent` tag and a malformed asset id fail inside `Intent`/`TokenId` deserialization, surfacing as `InputNotJson` or as `section()`'s `extraction` diagnostic. Once deserialization succeeds, `render_intent` matches the enum exhaustively and asset ids that resolve to no metadata fall back to their raw form, so neither state is reachable. `dead_code` does not fire on public enum variants, so nothing flags them; as public API they force external exhaustive matches to handle cases that cannot occur, and they read as documentation of validation the crate does not perform. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gnature A secp256k1 signature carrying Ethereum's `v = recovery_id + 27` (what MetaMask emits) is cryptographically sound; only its recovery-id convention disagrees with the 0-3 this wire format expects. Reporting that as `Invalid` renders "signature verification failed", which on a signing screen reads as tampering. `SignatureCheck` gains `MalformedEncoding(String)`, and the guard returns the reason rather than a bool -- naming the v=27/28 case specifically -- so `render_signature` can surface which convention arrived. The arm emits the reason as a `signature` warning and no `Signature` field, since nothing about the check passed. Its render test asserts both halves, matching the coverage the sibling `Invalid` arm already has. Rejecting rather than normalizing: the contract would reject it too, so accepting it here would render a payload the chain will not execute. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
render_token_diff refused nothing: an empty diff rendered as no fields at all, and a zero-delta entry rendered as "Receive 0 <token>" -- a line claiming a movement that does not happen. The contract refuses both cases itself (DefuseError::InvalidIntent), so neither intent can execute. looks_like_implicit_account now takes the prefix through visualsign::encodings::split_hex_prefix, matching the workspace's unified hex handling, and the derivation comparison ignores hex-digit case -- to_implicit_account_id emits lowercase, so without that a differently-cased spelling of the same account would read as a failed derivation rather than a match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The combined decode error interpolated the borsh cause into the middle of the sentence naming the two accepted input formats, splitting a phrase callers match on -- including the gRPC integration test that asserts a rejected input names both formats. Both causes now follow the summary instead of interrupting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98f448b to
da68683
Compare
- Test coverage for the recovery-id guard: the existing test only used 29, an arbitrary value outside every real convention, so it never touched the realistic v=27/28 case (Ethereum's v = recovery_id + 27, as wallets like MetaMask emit). Split into two tests, 29 and 27, each asserting MalformedEncoding with the right message content. The MalformedEncoding outcome itself belongs to the code it distinguishes and lives in #428, keeping this PR test-only. - The three ERC-191 verification tests called build_erc191_vector() fresh in-process instead of reading the committed _vector_erc191.input fixture (only the drift-check test read the file). That meant the pinned fixture never reached the code under test -- a generator regression would silently rewrite the file under UPDATE_TESTDATA=1 and the verification tests would still agree. Added ERC191_VECTOR (include_bytes!, matching the existing raw_ed25519 pattern) and switched those three tests to decode it directly; build_erc191_vector() now backs only the drift check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Third PR of the NEAR chain-support stack (stacked on #426). Adds the NEAR Intents preset -- decode + render for both the signed-envelope and pre-signature flows -- and merges the crate's two input-format handlers into one.
Stack: A1 (#425) -> A2 (#426) -> A3 (this) -> A4 (#429) -> B (identity + wiring) -> C (docs site) -> D (wallet-signed token metadata).
The preset
chain_parsers/visualsign-near/src/presets/intents/:mod.rs-- entry points (try_decode_execute_intentsfor a signed batch,try_render_single_intentfor the pre-signature view),NearIntentsError,NearTokenRegistry/TokenMeta. The public surface isvisualsigntypes plus primitives;defuse-*andnear-sdktypes stay behind this module.args.rs-- JSON deserialization ofexecute_intentsargs ({"signed": [...]}).tokens.rs-- seeded NEP-141 metadata and amount formatting.render.rs-- all 11 intent variants, plus the envelope and signature sections.verify.rs-- signature verification and payload extraction, reported independently of each other.The converter merge
tx.rs'sNearTransactionis an enum:from_stringtries borsh first (via the existing hex/base64 detection); on failure it validates the input as aDefusePayloadJSON envelope, and rejects anything that is neither. Borsh bytes are never valid JSON, so the discrimination is unambiguous. One chain identity, format-discriminated, rather than a converter per input format under its own custom chain name.convert.rsdispatches on the variant:OnChainrenders A2's baseline fields, plus the decoded intents when the action is aFunctionCalltointents.near/execute_intents;Intentrenders viatry_render_single_intent, titled "NEAR Intent", with no signature section because nothing is signed at that stage.What the signing screen states
storage_deposit(an unconditional, unrefundable wNEAR debit alongside the withdraw) andmsg(switches the call into its_transfer_callform);Transfer's flattened notification, whosemsgcallsmt_on_transferon the receiver; and the NEP-616state_initon bothTransfer's notification andAuthCall, which initializes the receiver's contract in the same receipt.state_init's code and data have no cheap field-level render, so it is flagged(not fully decoded), matchingactions.rs's handling of NEAR's ownDeterministicStateInit.min_gasis the only field omitted, consistently across the four variants that carry it.mt_withdrawrejects atoken_ids/amountslength mismatch rather than rendering thezipoverlap and dropping the rest.Valid,Invalid, andMalformedEncoding-- a signature that is cryptographically sound but carries Ethereum'sv = recovery_id + 27against the 0-3 this wire format expects. Reporting that asInvalidwould render "signature verification failed", which reads as tampering. A separate guard rejects out-of-range recovery ids beforeverify(), because near-crypto's nativeecrecoverpanics on them rather than returningNone, and a signing service must treat malformed input as invalid, never as a crash. The variant list is explicit, so a future secp256k1-backed standard is a build break rather than a latent panic.signer_idhas an implicit-account shape, the recovered key's implied account id is compared against it and the result stated literally. Neither outcome settles authorization: the contract accepts a key that either derives the account id or sits in the account's on-chain key set (Account::has_public_key,defuse/v0.4.2), so a derivation match can still be rejected on-chain and a non-match is expected for any account that added keys viaAddPublicKey. A named account has nothing to compare against, so no claim is made.has_expired()reads the system clock, which is not necessarily trustworthy inside the enclave; the contract enforces the real deadline.diagnosticsfeature a soft finding is a structuredDiagnosticfield; by default it is the same information as aWarning-labelled text field, leaving the production payload shape unchanged.Token metadata
SEEDSis a compiled-in table of entries verified against the token contract's ownft_metadata; anything unverified is omitted and falls back to the raw asset id, since a wrongdecimalssilently misrenders amounts. Request-scoped overrides layer on top viaLayeredRegistry.decimalsabove 38 does not resolve at all:format_unitsscales by10^decimals, which exceedsu128past that point, so an out-of-range value would either overflow or -- with overflow checks off -- divide by a wrapped-around scale and misrender the amount being signed.Dependencies
defuse-core/defuse-crypto/defuse-deadline,near-sdk,chrono,bs58andhexland here, where they are first used. near-sdk 5.27+ (what this workspace resolves) fixes the native-linking gap on the panic path, sovisualsign-nearkeeps the workspace'sunsafe_code = "forbid"with no carve-out and no shim.Verification
cargo build -p visualsign-near-- clean.cargo test -p visualsign-near-- 94/94 pass, under both default and--features diagnostics.cargo clippy -p visualsign-near --all-targets -- -D warnings,cargo fmt --check-- clean.cargo clippy -p parser_app --no-default-features --features near-- clean (narrow-build variant).make -C src lint-- clean;make -C src test-- clean (workspace suite, including the 9 gRPC integration tests).