Skip to content

feat(near): add NEAR Intents preset (decode + render) and merge converters - #428

Open
shahan-khatchadourian-anchorage wants to merge 11 commits into
mainfrom
shahankhatchadourian/near-a3-intents
Open

feat(near): add NEAR Intents preset (decode + render) and merge converters#428
shahan-khatchadourian-anchorage wants to merge 11 commits into
mainfrom
shahankhatchadourian/near-a3-intents

Conversation

@shahan-khatchadourian-anchorage

@shahan-khatchadourian-anchorage shahan-khatchadourian-anchorage commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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_intents for a signed batch, try_render_single_intent for the pre-signature view), NearIntentsError, NearTokenRegistry/TokenMeta. The public surface is visualsign types plus primitives; defuse-* and near-sdk types stay behind this module.
  • args.rs -- JSON deserialization of execute_intents args ({"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's NearTransaction is an enum:

pub enum NearTransaction {
    OnChain(near_primitives::transaction::Transaction),
    Intent(String), // validated DefusePayload JSON, kept as text
}

from_string tries borsh first (via the existing hex/base64 detection); on failure it validates the input as a DefusePayload JSON 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.rs dispatches on the variant: OnChain renders A2's baseline fields, plus the decoded intents when the action is a FunctionCall to intents.near/execute_intents; Intent renders via try_render_single_intent, titled "NEAR Intent", with no signature section because nothing is signed at that stage.

What the signing screen states

  • Verification and extraction are independent. A bad signature still renders the decoded intents, flagged at the signature line; a body that fails to extract surfaces the reason as a diagnostic rather than silently omitting the envelope.
  • Every field that changes what is authorized renders. The three withdraws' storage_deposit (an unconditional, unrefundable wNEAR debit alongside the withdraw) and msg (switches the call into its _transfer_call form); Transfer's flattened notification, whose msg calls mt_on_transfer on the receiver; and the NEP-616 state_init on both Transfer's notification and AuthCall, 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), matching actions.rs's handling of NEAR's own DeterministicStateInit. min_gas is the only field omitted, consistently across the four variants that carry it.
  • mt_withdraw rejects a token_ids/amounts length mismatch rather than rendering the zip overlap and dropping the rest.
  • Signature outcomes are three, not two. Valid, Invalid, and MalformedEncoding -- a signature that is cryptographically sound but carries Ethereum's v = recovery_id + 27 against the 0-3 this wire format expects. Reporting that as Invalid would render "signature verification failed", which reads as tampering. A separate guard rejects out-of-range recovery ids before verify(), because near-crypto's native ecrecover panics on them rather than returning None, 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.
  • The account-binding fields claim only what was checked. When signer_id has 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 via AddPublicKey. A named account has nothing to compare against, so no claim is made.
  • Deadline expiry is advisory. has_expired() reads the system clock, which is not necessarily trustworthy inside the enclave; the contract enforces the real deadline.
  • Diagnostics carry in both build shapes. With the diagnostics feature a soft finding is a structured Diagnostic field; by default it is the same information as a Warning-labelled text field, leaving the production payload shape unchanged.

Token metadata

SEEDS is a compiled-in table of entries verified against the token contract's own ft_metadata; anything unverified is omitted and falls back to the raw asset id, since a wrong decimals silently misrenders amounts. Request-scoped overrides layer on top via LayeredRegistry. decimals above 38 does not resolve at all: format_units scales by 10^decimals, which exceeds u128 past 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, bs58 and hex land here, where they are first used. near-sdk 5.27+ (what this workspace resolves) fixes the native-linking gap on the panic path, so visualsign-near keeps the workspace's unsafe_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).

@shahan-khatchadourian-anchorage shahan-khatchadourian-anchorage changed the title shahankhatchadourian/near a3 intents feat(near): port NEAR Intents preset (decode + render) and merge converters Jul 30, 2026
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a3-intents branch from 1c0b792 to 6b0daff Compare July 30, 2026 20:25

Copilot AI 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.

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::intents with 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 (including execute_intents decoding for intents.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.

Comment on lines +19 to +21
pub enum NearIntentsError {
#[error("execute_intents args were not valid JSON: {0}")]
ArgsNotJson(String),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +103 to +106
fields.extend(render_signature(standard_name(mp), &check)?);
if let Some(payload) = &extracted {
fields.extend(render_single(payload, registry)?);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a3-intents branch from 6b0daff to 47a9a80 Compare July 31, 2026 10:43
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a3-intents branch from 47a9a80 to 75f7171 Compare July 31, 2026 10:52
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage marked this pull request as ready for review July 31, 2026 10:58
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a3-intents branch from 75f7171 to 557595c Compare July 31, 2026 16:16
@shahan-khatchadourian-anchorage shahan-khatchadourian-anchorage changed the title feat(near): port NEAR Intents preset (decode + render) and merge converters feat(near): add NEAR Intents preset (decode + render) and merge converters Jul 31, 2026
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a3-intents branch from 557595c to aaacf9b Compare July 31, 2026 18:33

@prasanna-anchorage prasanna-anchorage 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.

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})"))?

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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()) {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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,

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a3-intents branch 2 times, most recently from 4f32bde to 9f8fe9e Compare August 1, 2026 01:44
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a3-intents branch from 9f8fe9e to 074e228 Compare August 1, 2026 03:05
shahan-khatchadourian-anchorage added a commit that referenced this pull request Aug 4, 2026
- 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>
shahan-khatchadourian-anchorage added a commit that referenced this pull request Aug 4, 2026
- 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>
shahan-khatchadourian-anchorage added a commit that referenced this pull request Aug 5, 2026
- 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>

@pepe-anchor pepe-anchor 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.

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.

Comment thread src/chain_parsers/visualsign-near/src/tx.rs
Comment thread src/chain_parsers/visualsign-near/src/presets/intents/verify.rs
Comment thread src/chain_parsers/visualsign-near/src/presets/intents/render.rs
Comment thread src/chain_parsers/visualsign-near/src/tx.rs
Comment thread src/chain_parsers/visualsign-near/src/convert.rs
Comment thread src/Cargo.toml
Comment thread src/chain_parsers/visualsign-near/src/presets/intents/render.rs
Comment thread src/chain_parsers/visualsign-near/src/presets/intents/verify.rs
Base automatically changed from shahankhatchadourian/near-a2-chain to main August 6, 2026 11:54
…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>
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a3-intents branch from 98f448b to da68683 Compare August 6, 2026 12:07
shahan-khatchadourian-anchorage added a commit that referenced this pull request Aug 6, 2026
- 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants