Skip to content

feat(near): add baseline NEAR transaction parser - #426

Merged
shahan-khatchadourian-anchorage merged 8 commits into
mainfrom
shahankhatchadourian/near-a2-chain
Aug 6, 2026
Merged

feat(near): add baseline NEAR transaction parser#426
shahan-khatchadourian-anchorage merged 8 commits into
mainfrom
shahankhatchadourian/near-a2-chain

Conversation

@shahan-khatchadourian-anchorage

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

Copy link
Copy Markdown
Contributor

Second PR of the NEAR chain-support stack (stacked on #425). Implements baseline NEAR transaction parsing in this crate's conventions.

Stack: A1 (#425) -> A2 (this) -> A3 (intents preset) -> A4 (signature verification) -> B (identity + wiring) -> C (docs site) -> D (wallet-signed token metadata).

What this PR does

Adds baseline NEAR chain decoding to chain_parsers/visualsign-near:

  • tx.rs: borsh-decodes a NEAR Transaction/SignedTransaction from hex or base64 input.
  • actions.rs: renders each Action variant (Transfer, FunctionCall with a fail-closed JSON args decoder for ft_transfer/ft_transfer_call/ft_withdraw, generic label fallback for the rest).
  • convert.rs: assembles the final payload (Network/From/To + per-action fields) via NearVisualSignConverter.
  • fmt.rs/networks.rs: yoctoNEAR/Tgas decimal formatting, Mainnet/Testnet display.

Two design decisions worth calling out:

  • tx.rs's input decoding uses this repo's shared visualsign::encodings::SupportedEncodings::detect + decode_hex convention (matching visualsign-solana's own from_string) rather than hand-rolling 0x-prefix stripping -- this repo's convention is to reuse the shared mechanism rather than hand-roll per chain. Added a regression test for the 0x-prefixed case.
  • convert.rs omits execute_intents decoding for now -- that code depends on presets/intents/, which lands in A3, compiled unconditionally there (no feature gate in the final design, since every other preset in this repo is unconditional too).

A1's placeholder NearParserError enum is removed as dead code in favor of visualsign::vsptrait::TransactionParseError directly; the now-unused thiserror dependency is dropped with it.

Review fixes folded in

  • NearVisualSignConverter::with_network(NearNetwork): external callers (e.g. parser_app) can now construct a testnet converter without making the network field public.
  • Two stale module doc comments corrected: fmt.rs claimed a base58 helper that doesn't exist in this file; networks.rs claimed "system-account recognition" that was never implemented.
  • payload_type switched from the generic "VisualSign" to "NearTx", matching every other chain crate's convention (visualsign-solana: "SolanaTx", visualsign-tron: "TronTx").
  • Multi-action transactions now get an "Action" boundary label ahead of Transfer's/FunctionCall's own fields when there's more than one action -- without it, e.g. two transfers in one transaction rendered two identically-labelled "Amount" fields with nothing distinguishing which action each belonged to.
  • Dependency manifest trimmed to only what this PR's own code uses; everything else moves to the PR that actually introduces its first usage.

Verification

  • cargo test -p visualsign-near -- 24/24 pass.
  • cargo clippy -p visualsign-near --all-targets -- -D warnings, cargo fmt --check -- clean.
  • cargo check/clippy -p parser_app --no-default-features --features near -- clean (narrow-build variant).
  • make -C src lint, make -C src test -- clean (default features; stock builds unaffected).
  • Internal code review and security review both run against this diff; no findings above minor/informational (one dead-dependency cleanup already folded in).

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

Ports a baseline NEAR chain parser into the workspace’s VisualSign conventions by adding NEAR transaction envelope decoding, per-action rendering, and a NearVisualSignConverter that assembles NEAR transactions into SignablePayload fields.

Changes:

  • Added NEAR transaction decoding from hex/base64 (including 0x-prefixed hex) into a NearTransaction wrapper.
  • Implemented baseline NEAR action rendering (Transfer + FunctionCall with fail-closed decoding for select NEP-141 methods) and payload assembly via NearVisualSignConverter.
  • Removed now-dead NEAR-specific error type and dropped the thiserror dependency.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/chain_parsers/visualsign-near/src/tx.rs Adds NEAR borsh decoding from hex/base64 into NearTransaction + regression tests.
src/chain_parsers/visualsign-near/src/actions.rs Renders NEAR Action variants into VisualSign fields; includes selective JSON args decoding for common FT methods.
src/chain_parsers/visualsign-near/src/convert.rs Converts NearTransaction into SignablePayload with network/from/to headers and per-action fields.
src/chain_parsers/visualsign-near/src/fmt.rs Adds yoctoNEAR + Tgas fixed-decimal formatting helpers and tests.
src/chain_parsers/visualsign-near/src/networks.rs Introduces NearNetwork enum and display names + tests.
src/chain_parsers/visualsign-near/src/lib.rs Exposes NEAR modules and re-exports NearVisualSignConverter/NearTransaction; removes dead error enum.
src/chain_parsers/visualsign-near/Cargo.toml Drops thiserror dependency from the NEAR chain crate.
src/Cargo.lock Removes thiserror from the NEAR crate dependency list.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +26 to +32
impl NearVisualSignConverter {
/// Construct a converter for mainnet (the default for wallet display).
#[must_use]
pub fn new() -> Self {
Self::default()
}
}

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.

Added NearVisualSignConverter::with_network(NearNetwork) so external callers can construct a testnet converter without making the field public. See 6f3ba4e.

@@ -0,0 +1,65 @@
//! yoctoNEAR, Tgas, base58 formatting helpers.

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 -- the doc comment claimed a base58 helper that doesn't exist in this file (bs58 is used later, in the intents preset's tests, not here). Updated to "yoctoNEAR, Tgas formatting helpers." See 6f3ba4e.

@@ -0,0 +1,39 @@
//! NearNetwork enum and system-account recognition.

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 -- removed the stale "system-account recognition" claim; this file only defines NearNetwork and its display names. See 6f3ba4e.

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

Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/chain_parsers/visualsign-near/src/convert.rs:19

  • payload_type is used elsewhere as a chain-specific tag (e.g. "EthereumTx", "SolanaTx", "TronTx"). Using the generic string "VisualSign" here makes NEAR payloads inconsistent and harder to identify downstream (CLI output, consumers that switch on payload_type). Consider switching this constant to a NEAR-specific tag (e.g. "NearTx").
/// Payload version emitted for NEAR transactions.
const PAYLOAD_VERSION: i64 = 0;
/// Payload type tag emitted for NEAR transactions.
const PAYLOAD_TYPE: &str = "VisualSign";

src/chain_parsers/visualsign-near/src/convert.rs:62

  • When a NEAR transaction contains multiple actions, Transfer renders only an "Amount" field and FunctionCall renders "Method"/"Deposit"/"Gas" without an action-kind field. In multi-action transactions this can produce repeated/ambiguous labels (e.g. multiple "Amount" fields) with no clear action boundary. Consider injecting an "Action" label for these variants when tx.actions().len() > 1.
        for action in tx.actions() {
            fields.extend(render_action(action)?);
        }

@shahan-khatchadourian-anchorage shahan-khatchadourian-anchorage changed the title feat(near): port baseline NEAR transaction parser feat(near): add baseline NEAR transaction parser Jul 31, 2026

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

Read through the baseline and exercised it locally against real borsh-encoded transactions -- a 2.5 NEAR transfer renders Network / From / To / Amount 2.5 NEAR with title Transfer, ft_transfer renders Method / Recipient / Amount / Memo / Deposit / Gas with 30 Tgas correct, a genuinely signed SignedTransaction round-trips through the signed-first path, and trailing garbage is rejected (borsh's Not all bytes read covers it, so from_string needs no explicit length check). 24 tests pass, clippy --all-targets -D warnings clean.

I also byte-decoded TRANSFER_HEX against the borsh wire format independently of near-primitives: 120 bytes, signer/pubkey/nonce/receiver/block_hash/1 action, deposit exactly 10^24 yocto, zero trailing. That was the one thing in the PR a compiler can't check, and it's correct.

Three comments below. Only the second is a design question; the others are small.

let tx = NearTransaction::from_string(TRANSFER_HEX).expect("decode hex");
assert_eq!(tx.inner.signer_id().as_str(), "alice.near");
assert_eq!(tx.inner.receiver_id().as_str(), "bob.near");
assert_eq!(tx.inner.actions().len(), 1);

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.

These assertions cover signer, receiver, and the action variant, but never the deposit -- so the amount encoded in TRANSFER_HEX is untested.

That matters more here than it looks: this fixture is the only hand-encoded wire bytes in the crate (everything else builds Action values through near-primitives, which can't disagree with itself). The amount is also the single field a signer is most likely to be defrauded on. As written, the fixture's deposit bytes could be edited to any value and this test stays green.

I decoded the bytes by hand and they are right -- exactly 10^24 yoctoNEAR -- so this is just pinning down what's already correct:

let near_primitives::action::Action::Transfer(transfer) = &tx.inner.actions()[0] else {
    panic!("expected Transfer");
};
assert_eq!(transfer.deposit.as_yoctonear(), 1_000_000_000_000_000_000_000_000);

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.

Added the missing assertion — transfer.deposit.as_yoctonear() == 1_000_000_000_000_000_000_000_000, matching your independent hand-decode. TRANSFER_HEX's amount is now actually pinned, not just its variant.

fn to_visual_sign_payload(
&self,
transaction: NearTransaction,
_options: VisualSignOptions,

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.

_options is discarded, and the Network field on the next few lines comes from self.network instead -- fixed when the converter is constructed. I think that's the wrong channel, because options.metadata is exactly how the requester already supplies this everywhere else.

Compare the two existing patterns:

  • Ethereum (multi-network) reads it per request: networks::extract_chain_id_from_metadata(options.metadata.as_ref()) in lib.rs:457.
  • Tron (single network) hardcodes create_text_field("Network", "Tron") -- fine, because there's nothing to get wrong.

NEAR is multi-network but takes neither route. The problem is that with_network can't actually be used per request under the current architecture: TransactionConverterRegistry holds one Box<dyn VisualSignConverterAny> per chain (registry.rs:120), registered once at startup (parser/app/src/registry.rs:18-41). So whatever network the instance is built with at boot is baked in for the process lifetime, and a testnet transaction renders a confident NEAR Mainnet header with no request-level way to correct it. An incorrect network label on a signing screen is the kind of thing that reads as authoritative.

The blocker is that ChainMetadata's oneof only has Ethereum and Solana variants (generated/parser.rs:80-85), so there's no Near variant to read yet -- which is presumably why this went to constructor state. Options as I see them, roughly in order of preference:

  1. Add a Near(NearMetadata { network }) variant to the proto and read it from options.metadata here, matching Ethereum. Most consistent, but pulls a codegen change into A2.
  2. Keep with_network as-is for now but drop the Network field from the payload entirely until the metadata path exists -- omitting the field is safer than asserting a possibly-wrong one.
  3. Land as-is with a TODO and an explicit note in stage B that registration must not assume mainnet.

Happy with any of these, and (1) is a reasonable thing to defer to B if you'd rather not grow this PR -- but I'd like the decision recorded rather than left implicit in the constructor. What's the plan for how B wires this?

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.

Went with option 1: added NearMetadata { network_id } as a new variant on ChainMetadata's oneof (proto + codegen regen), and convert.rs now reads options.metadata per-request via a new extract_network_from_metadata — mirrors visualsign-ethereum's extract_chain_id_from_metadata almost line for line. Falls back to whatever network the converter was constructed with when metadata is absent or belongs to another chain.

This means stage B needs no changes: NearVisualSignConverter::new(), registered once at startup, is already the right pattern once this lands — same shape as EthereumVisualSignConverter::new(), a single per-process instance whose network is resolved per-request from metadata rather than baked in at construction. B's existing registration code was accidentally already correct.

Also refactored the one exhaustive match on the oneof (visualsign-ethereum's extract_chain_id_from_metadata) to a let-else, so it's non-exhaustive by construction — adding Near didn't require touching that crate, and neither will any future chain variant.

The REST gateway's ChainMetadataInput JSON discriminator doesn't need adding here either — stage D already has a CHAIN_NEAR variant wired to NearMetadata, written ahead of this proto change landing. It'll just compile once D rebases onto this.

create_address_field("To", tx.receiver_id().as_str(), None, None, None, None)?
.signable_payload_field,
);
let total_actions = tx.actions().len();

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.

A transaction with zero actions produces a complete, valid-looking payload: Network / From / To, titled NEAR Transaction (via the _ => arm in title_for), and no indication that nothing happens. I confirmed this renders rather than erroring.

An empty actions vec is well-formed borsh but not a meaningful thing to sign, and the rendered result is indistinguishable at a glance from a transaction whose actions simply weren't decoded -- which is the more dangerous reading, since the fallback arm in render_action deliberately renders unknown variants as a bare Action label. A signer can't tell "this does nothing" from "we didn't understand this."

Suggest rejecting it outright:

if tx.actions().is_empty() {
    return Err(VisualSignError::("NEAR transaction has no actions".into()));
}

If there's a reason to keep rendering it, an explicit No actions field would at least make the emptiness visible.

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 — to_visual_sign_payload now rejects a NEAR transaction with zero actions (VisualSignError::ValidationError) instead of rendering a normal-looking payload. Added a test (rejects_transaction_with_no_actions) covering it.

@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a2-chain branch 2 times, most recently from 1c2f96e to 88ce10b Compare August 1, 2026 01:44
Base automatically changed from shahankhatchadourian/near-a1-deps to main August 1, 2026 02:05
Comment thread src/chain_parsers/visualsign-near/src/convert.rs
Comment thread src/chain_parsers/visualsign-near/src/actions.rs Outdated
Comment thread src/chain_parsers/visualsign-near/src/convert.rs
Comment thread src/chain_parsers/visualsign-near/src/tx.rs
Comment thread src/chain_parsers/visualsign-near/src/actions.rs
Comment thread src/chain_parsers/visualsign-ethereum/src/networks.rs
Comment thread src/chain_parsers/visualsign-near/src/convert.rs
Comment thread src/chain_parsers/visualsign-near/src/actions.rs
Comment thread src/codegen/src/main.rs
Comment thread src/chain_parsers/visualsign-near/src/convert.rs
Ports the baseline chain-parsing code: borsh Transaction/SignedTransaction
decode via near-primitives (tx.rs), per-action rendering plus a
fail-closed args decoder for ft_transfer/ft_transfer_call/ft_withdraw
(actions.rs), the NearVisualSignConverter (convert.rs), and
yoctoNEAR/Tgas formatting (fmt.rs, networks.rs).

Two deliberate deviations from a verbatim port:
- tx.rs decodes hex/base64 input via the shared
  visualsign::encodings::SupportedEncodings::detect + decode_hex
  convention (matching visualsign-solana) instead of hand-rolled
  0x-prefix stripping.
- convert.rs omits near-intents-feature-gated execute_intents decoding;
  that depends on presets/intents/, which lands in a later PR, compiled
  unconditionally rather than behind a feature flag.

A1's placeholder NearParserError is removed as dead code now that this
PR uses visualsign::vsptrait::TransactionParseError directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add NearVisualSignConverter::with_network so external callers (e.g.
  parser_app) can construct a converter for testnet without making the
  network field public.
- Drop stale doc-comment claims: fmt.rs has no base58 helper (bs58 is
  used later, in the intents preset's tests, not here), and
  networks.rs has no system-account recognition, only the NearNetwork
  enum and its display names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…on fields

- PAYLOAD_TYPE was the generic "VisualSign" string; every other chain
  crate uses a chain-specific tag (visualsign-solana: "SolanaTx",
  visualsign-tron: "TronTx"). Switch to "NearTx" to match.
- render_action() rendered Transfer/FunctionCall with no action-kind
  marker, unlike the fallback branch for undecoded variants (which
  already prints an "Action" field). A multi-action transaction with
  e.g. two transfers rendered two identically-labelled "Amount" fields
  with nothing indicating which action each belongs to. Thread
  total_actions through render_action and prepend the same "Action"
  boundary label when there is more than one action; single-action
  transactions are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- tx.rs: TRANSFER_HEX's deposit was never asserted, only the action
  variant -- the one hand-encoded wire-format fixture in the crate,
  editable to any deposit value and still green. Assert the decoded
  amount (10^24 yoctoNEAR).
- convert.rs: reject a NEAR transaction with zero actions instead of
  rendering a valid-looking payload indistinguishable from a decode
  failure.
- Network selection moves from constructor-baked to per-request:
  add a NearMetadata { network_id } variant to ChainMetadata's proto
  oneof (regenerated via `make generated`), and add
  networks::extract_network_from_metadata mirroring
  visualsign-ethereum's extract_chain_id_from_metadata. convert.rs
  now reads options.metadata first, falling back to whatever network
  the converter was constructed with. Stage B's registration
  (NearVisualSignConverter::new()) needs no changes: it's already the
  same per-process-instance-plus-per-request-metadata pattern
  Ethereum uses.
- visualsign-ethereum/networks.rs: switch
  extract_chain_id_from_metadata's exhaustive match on the oneof to a
  let-else, so adding Near (or any future chain) doesn't require
  touching this crate again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- tx.rs: gate signed-transaction decoding behind
  developer_config.allow_signed_transactions, mirroring
  visualsign-ethereum; decode unsigned first and bind its error instead
  of discarding it. Make the inner Transaction field private with
  new()/inner() accessors, matching the other chain wrappers.
- actions.rs: render each fallback action variant's own fields
  (DeleteAccount's beneficiary, AddKey/DeleteKey's public key and
  permission, Stake/TransferToGasKey/WithdrawFromGasKey/
  DeterministicStateInit's balances) instead of a bare label; refuse
  Delegate (NEP-366 meta-transactions) rather than partially render a
  nested action batch; mark DeployContract/DeployGlobalContract/
  UseGlobalContract's label "(not fully decoded)".
- actions.rs: push a raw-args field when decode_known_method_args
  can't interpret a FunctionCall's args, instead of silently rendering
  just Method+Gas.
- actions.rs: sanitize method_name/memo/msg through a charset_safe
  filter before inserting them into text fields, closing a newline-
  injection path that could spoof extra fields on the signing screen.
- convert.rs/networks.rs: reject an unparseable network_id instead of
  falling back to Mainnet, and reject a signer account whose top-level
  suffix (.testnet/.near) contradicts the resolved network.
- Cargo.toml: move hex to dev-dependencies (only used in test code);
  note near-primitives' ~34-crate transitive surface (serde_yaml/
  unsafe-libyaml, arbitrary) since it has no default-features knob to
  shed it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…or fix

An Opus-model review of commit 6bcc1da against pepe-anchor's original 9
comments found 4 residual gaps before sending the PR back for re-review:

- convert.rs: NearVisualSignConverter never overrode
  to_visual_sign_payload_from_string, so the default impl called
  NearTransaction::from_string (developer_config hardcoded to None) --
  the only production dispatch path. This silently broke the CLI's
  allow_signed_transactions posture entirely, the exact override pepe
  named by name and asked for. Added the override, mirroring
  EthereumVisualSignConverter's, plus converter-level tests for both
  postures.
- actions.rs: DeterministicStateInit dropped state_init (the derived
  account's code/data) with no indication anything was left out, unlike
  every other undecodable-content variant. Appended a "State Init"
  "(not fully decoded)" field, matching the Deploy*/UseGlobalContract
  pattern.
- actions.rs: decode_known_method_args's doc comment still said
  unparsed args render "nothing extra", stale since the raw-data
  fallback fix landed in the same commit.
- actions.rs: FtTransferArgs/FtWithdrawArgs' receiver_id/token are
  attacker-controlled JSON, same as memo/msg/method_name, but weren't
  charset_safe'd before reaching create_address_field -- same
  field-spoofing injection class pepe's comment 4 was about, just not
  the specific fields he named. Added a regression test with an
  embedded newline in both fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`AddKey` collapsed every `FunctionCallPermission` to the fixed label
"Function Call (Restricted)". Both of that permission's bounds widen the
grant when absent -- `method_names: []` allows any method, `allowance:
None` allows unlimited spend -- so a key scoped to one method with a cap
and a key that can call anything on a contract without limit rendered
identically, under a word that contradicts the second.

`push_permission_fields` replaces the label with the fields themselves:
`Permission`, `Contract`, `Allowed Methods` ("Any method" when
unrestricted), and `Allowance` ("Unlimited" when absent). The two gas-key
variants additionally render the `GasKeyInfo` balance and nonce count
they were discarding. `Allowed Methods` joins names with a newline rather
than a comma, since a method name may itself contain a comma.

Also in the display path:

- `TransferToGasKey`/`WithdrawFromGasKey` render the `public_key` naming
  which gas key is funded or drained; an account can hold several.
- `charset_safe` keeps double quotes. `validate_charset` permits `\"`
  precisely so field text can carry embedded JSON, and
  `ft_transfer_call`'s `msg` is such a field. A literal backslash is
  still stripped: it serializes as `\\`, putting a
  `FORBIDDEN_JSON_ESCAPES` substring in the payload and failing the whole
  transaction.
- `FtTransferArgs`/`FtWithdrawArgs` type `receiver_id`/`token` as
  `AccountId`, so an id the chain would reject fails the decode and drops
  to the raw-args field rather than rendering filtered.
- The network-suffix check runs on the receiver as well as the signer,
  and names which account failed.
- `NearMetadata` gets `BORSH_ENUM_DISC_ATTR` alongside its `BORSH_DERIVE`,
  matching its Ethereum and Solana siblings. Generated output is
  unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Display and decode changes from the second review pass:

- Decoding a signed transaction under `allow_signed_transactions` logs a
  `tracing::warn!`. Production callers pass `None`, so reaching that
  branch in production is a misconfiguration and now leaves a trail.
- When both the unsigned and signed decodes fail, the error carries both
  causes instead of only the unsigned one.
- `title_for` applies the `(not fully decoded)` qualifier that the
  corresponding field already carries, so a single-action payload's
  headline cannot claim more than its body. Both sites read the variant
  set from `is_partially_decoded`.
- `CreateAccount` records why it renders its label directly rather than
  through `action_boundary_field`: its label is the whole render, so
  there is nothing for a boundary marker to separate.

`NearMetadata` no longer takes `BORSH_ENUM_DISC_ATTR`. The attribute
applies to enums nested in the named message, and `NearMetadata` holds a
single optional string -- as do `EthereumMetadata` and `SolanaMetadata`,
whose own nested types `Abi` and `Idl` carry the attribute themselves.
The call generates nothing, so it is dropped rather than kept as a
pattern that implies an effect it does not have.

`extract_chain_id_from_metadata` states why its non-Ethereum arm is a
catch-all: this crate reads Ethereum metadata and does not name other
chains' variants.

Tests: appending a byte to a valid transaction is rejected, pinning that
`borsh::from_slice` requires the whole buffer to be consumed; and a
64-hex implicit account renders under either network, pinning that the
account-suffix check does not reach accounts that carry no suffix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage force-pushed the shahankhatchadourian/near-a2-chain branch from 314d912 to f9e649f Compare August 6, 2026 11:49
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage merged commit a5552ae into main Aug 6, 2026
11 checks passed
@shahan-khatchadourian-anchorage
shahan-khatchadourian-anchorage deleted the shahankhatchadourian/near-a2-chain branch August 6, 2026 11:54
shahan-khatchadourian-anchorage added a commit that referenced this pull request Aug 6, 2026
…trip

- cli_plugin.rs: create_metadata no longer silently drops --network.
  Now that #426 added the Near oneof variant, this builds a real
  ChainMetadata::Near(NearMetadata { network_id }) instead of the
  interim "reject the flag" fallback proposed in review -- validated
  via NearNetwork::from_network_id, rejecting unrecognized values
  with a clear error rather than rendering a confident but wrong
  "NEAR Mainnet" on a signing screen. Verified end-to-end via the
  CLI: no flag defaults to mainnet, --network NEAR_TESTNET renders
  "NEAR Testnet", an invalid value errors before rendering anything.
- registry.rs: add Chain::Near to test_chain_from_str/test_chain_as_str,
  the only chain missing round-trip coverage after this PR wired it in.

The e2e PayloadType: "VisualSign" vs "NearTx" mismatch flagged in
review is already fixed on this branch (fix(near): update e2e
fixtures to match NearTx payload_type) -- no change needed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
shahan-khatchadourian-anchorage added a commit that referenced this pull request Aug 6, 2026
Rebasing PR #426's pepe-anchor fixes past #432 (which adds
token_mappings to NearMetadata) leaves two test literals in
convert.rs/networks.rs missing the new field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pepe-anchor pushed a commit that referenced this pull request Aug 7, 2026
…rters (#428)

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:

```rust
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).
pepe-anchor pushed a commit that referenced this pull request Aug 7, 2026
Rebasing PR #426's pepe-anchor fixes past #432 (which adds
token_mappings to NearMetadata) leaves two test literals in
convert.rs/networks.rs missing the new field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pepe-anchor pushed a commit that referenced this pull request Aug 7, 2026
#429)

Fourth PR of the NEAR chain-support stack (stacked on #428). Adds the
signed-vector test coverage for the intents preset's `verify.rs`, which
#428 leaves untested because `render.rs` needs those functions to
compile.

Stack: A1 (#425) -> A2 (#426) -> A3 (#428) -> **A4 (this)** -> B
(identity + wiring) -> C (docs site) -> D (wallet-signed token
metadata).

Entirely `#[cfg(test)]`-scoped: the diff adds a test module to
`verify.rs`, two fixture files, and `k256` as a dev-dependency. No
production code, so no production attack surface.

## Coverage

**raw_ed25519** (`tests/fixtures/_vector_raw_ed25519.input`) -- a signed
vector verbatim from `defuse/v0.4.2`, `core/src/payload/multi.rs:120`.
Proves defuse's canonical verification runs off-chain (pure-Rust crypto
via near-sdk's non-contract-usage mode) and fails closed rather than
panicking on a tampered signature. The vector's inner body carries a
pre-v0.4.x `"deadline":{"timestamp":...}` that the pinned `Deadline`
cannot parse, and the signature covers that body byte-for-byte, so it
exercises verification only: the test asserts extraction fails with the
format reason instead of discarding it, and names
`pipeline_decodes_and_renders_intent_section` as where current-format
ed25519 extraction is covered.

**ERC-191** (`tests/fixtures/_vector_erc191.input`) -- a
deterministically generated vector (fixed test key, RFC6979 signing via
`k256`). The three verification tests read the committed fixture, so a
generator regression shows up as a signature that no longer verifies
rather than as a fixture that rewrites itself and still passes;
`build_erc191_vector()` backs only the drift check. Covers a valid
signature recovering exactly the signing key, and a tampered payload
recovering a *different* key -- ERC-191 "verification" is recovery, so
tampering does not fail verification, which is why account binding is
the comparison against the claimed signer rather than the recovery
itself.

**The recovery-id guard** -- near-crypto's native `ecrecover` panics on
an out-of-range recovery id instead of returning `None`. Both an
arbitrary out-of-range value and Ethereum's v=27 are pinned to
`MalformedEncoding`.

## Real-wallet reference vectors

Both come verbatim from the pinned dependency's own test suites, cited
in-file so provenance is checkable by diffing against those files rather
than by trusting this description. Both wallets signed with the same
private key, so they share one reference public key.

**ERC-191 / MetaMask** (`defuse/v0.4.2`, `erc191/src/lib.rs:72-95`,
documented there as a signature MetaMask produced) is used three ways:

- this crate's ERC-191 signing path must reproduce those exact bytes --
meaningful across implementations because MetaMask/ethereumjs and `k256`
both use deterministic ECDSA (RFC6979), so the same key and message must
produce byte-identical output;
- those same bytes must recover the reference public key through
`decode_args` -> `verify_and_extract`, so the pin covers the production
verification path and not just the signing helper;
- the same signature with its v byte left as the wallet emits it (28)
must report `MalformedEncoding`, which backs the guard with a real
wallet's encoding rather than a synthetic value.

**TIP-191 / TRON** (`defuse/v0.4.2`, `tip191/src/lib.rs:83-95`) gets the
same end-to-end recovery and guard pair. TIP-191 shares the recovery-id
guard with ERC-191 and otherwise has no coverage, and since it is the
same key signed under TRON's prefix, a prehash regression in either
standard is caught.

The private key is a long-published throwaway test key from that
external suite, not a secret generated or exposed here.

## Standards covered

`RawEd25519` and `Erc191` (signed vectors plus the real-wallet pin),
`Tip191` (real-wallet pin). Not covered: `Nep413` -- no upstream vector
exists, so one has to be generated -- along with `Sep53`, `TonConnect`
and `WebAuthn`.

## Verification

- `cargo build --locked -p visualsign-near` -- clean.
- `cargo test -p visualsign-near` -- 106/106 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
--all-targets -- -D warnings` -- clean (narrow-build variant).
- Review re-derived both cryptographic vectors against the pinned
upstream source byte-for-byte, and traced the guard through to the panic
it prevents: bypassing it panics with `InvalidRecoveryId` at both v=27
and v=29, while `RecoveryId::from_i32` accepts 0..=3 and v in {2,3}
returns `Err` without panicking -- so the `< 4` threshold is exact, not
conservative.
- No new crates enter the graph: `k256 0.13.4` was already in
`Cargo.lock`; the only lockfile change is `signature 2.0.0` appearing in
k256's dep list from the newly enabled signing feature.
pepe-anchor pushed a commit that referenced this pull request Aug 7, 2026
…trip

- cli_plugin.rs: create_metadata no longer silently drops --network.
  Now that #426 added the Near oneof variant, this builds a real
  ChainMetadata::Near(NearMetadata { network_id }) instead of the
  interim "reject the flag" fallback proposed in review -- validated
  via NearNetwork::from_network_id, rejecting unrecognized values
  with a clear error rather than rendering a confident but wrong
  "NEAR Mainnet" on a signing screen. Verified end-to-end via the
  CLI: no flag defaults to mainnet, --network NEAR_TESTNET renders
  "NEAR Testnet", an invalid value errors before rendering anything.
- registry.rs: add Chain::Near to test_chain_from_str/test_chain_as_str,
  the only chain missing round-trip coverage after this PR wired it in.

The e2e PayloadType: "VisualSign" vs "NearTx" mismatch flagged in
review is already fixed on this branch (fix(near): update e2e
fixtures to match NearTx payload_type) -- no change needed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
shahan-khatchadourian-anchorage added a commit that referenced this pull request Aug 7, 2026
…erage (#430)

* feat(near): add CHAIN_NEAR identity, registry/CLI wiring, and e2e coverage

Gives NEAR a first-class chain identity, replacing the placeholder
scaffolding from earlier PRs in this stack:

- proto/parser/parser.proto: CHAIN_NEAR = 6, regenerated via
  make -C src generated.
- visualsign::registry::Chain gains a Near variant (as_str/FromStr).
- parser_app: chain_conversion.rs maps CHAIN_NEAR <-> Chain::Near;
  create_registry() registers the NearVisualSignConverter; near joins
  parser_app's default features.
- visualsign-near gains a cli-plugin feature (default-on, mirroring
  every other chain crate) with a NearPlugin implementing
  parser_cli_core::ChainPlugin, and near joins parser_cli's default
  features with the plugin wired into ChainArgs/build_plugins.
- parser_cli_core::chains.rs's chain_string_mapping gains an entry for
  "near"; the test asserting an arbitrary string falls back to
  Chain::Custom now uses a string that is actually still unmapped.
- integration/tests/parser.rs: three e2e tests against the real
  parser_app binary over gRPC -- a native transfer, a pre-signature
  intents envelope (proving the format-discrimination in tx.rs works
  end-to-end under the one CHAIN_NEAR identity), and a rejection case
  for input that is neither.

Verified end-to-end via parser_cli decode --chain near for both input
formats before writing the e2e assertions. Full workspace make lint
and make test pass; the near-only narrow-build variant (parser_app
--no-default-features --features near) passes standalone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(near): update e2e fixtures to match NearTx payload_type

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(near): wire --network into NearMetadata, cover Chain::Near round-trip

- cli_plugin.rs: create_metadata no longer silently drops --network.
  Now that #426 added the Near oneof variant, this builds a real
  ChainMetadata::Near(NearMetadata { network_id }) instead of the
  interim "reject the flag" fallback proposed in review -- validated
  via NearNetwork::from_network_id, rejecting unrecognized values
  with a clear error rather than rendering a confident but wrong
  "NEAR Mainnet" on a signing screen. Verified end-to-end via the
  CLI: no flag defaults to mainnet, --network NEAR_TESTNET renders
  "NEAR Testnet", an invalid value errors before rendering anything.
- registry.rs: add Chain::Near to test_chain_from_str/test_chain_as_str,
  the only chain missing round-trip coverage after this PR wired it in.

The e2e PayloadType: "VisualSign" vs "NearTx" mismatch flagged in
review is already fixed on this branch (fix(near): update e2e
fixtures to match NearTx payload_type) -- no change needed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(near): propagate diagnostics to visualsign-near, cover NearMetadata e2e

parser_cli and parser_app both listed visualsign-solana in the diagnostics
feature but not visualsign-near, the only other chain crate that declares
one. A no-op today, since both also enable visualsign/diagnostics directly,
but it silently drops any diagnostics-gated behaviour visualsign-near adds.

The three NEAR integration tests all sent chain_metadata: None, so the
ChainMetadata -> NearMetadata -> extract_network_from_metadata path was
never exercised through the real gRPC pipeline. Two tests now cover it: a
.testnet transaction with NEAR_TESTNET metadata renders "NEAR Testnet", and
a .near transaction declared as Testnet is rejected naming the offending
account -- the second is what distinguishes a metadata value that reached
the converter from one that merely deserialized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
shahan-khatchadourian-anchorage added a commit that referenced this pull request Aug 7, 2026
Rebasing PR #426's pepe-anchor fixes past #432 (which adds
token_mappings to NearMetadata) leaves two test literals in
convert.rs/networks.rs missing the new field.

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