Building the TypeScript resolver for NEP-641 (currently listed as TBD)

NEP-641 is Final, and the last line of its Reference Implementation section reads:

TypeScript resolver (wallet-selector / NEAR Connect integration): TBD.

I would like to take that on. Starting this week, building in public, repo up in the next couple of days and I will report progress in this thread.

Before I get going I checked whether anyone already has. I could not find a GitHub repo or an npm package implementing it. Closest adjacent work I found is the near-sign-verify package by elliotBraem, which handles NEP-413 signature auth but not 641 resolution. If someone is working on this and I have missed it, please say so now and I will contribute there instead.

Why I care about this one

I came at 641 sideways. I was looking for a way to do selective disclosure on Intents, something an account holder could give an accountant or a counterparty to prove one transaction happened without opening up the entire account. There is nothing like that today, and 641 turns out to be most of the foundation already: the signed envelope, the five-axis replay binding, the delegation model, the NEP-413 bridge, and a payload that is deliberately opaque and would happily carry a disclosure claim.

But an attestation is worthless if the recipient cannot check it, and that recipient is in a browser, not a Rust toolchain. Right now RpcResolver only exists in Rust. So the TypeScript resolver is the thing standing in front of every offchain-auth use case, not only mine. Login flows, MPC signing approvals, confidential swap intents, all of them need a browser-side verifier before they are real.

Scope

Porting what crates/signatures/nep641 already does, in this order, publishing as each stage becomes independently useful:

  1. OffchainMessage construction and the canonical hash, with vectors matching the Rust output:
SHA3-256(b"NEAR_NEP641_OFFCHAIN_MESSAGE/V1" || borsh(msg))

  1. AccessKeyAuthorization and the NEP-413 mapping, including the recipient string format and nonce as canonical hash. This is the path most existing wallets need

  2. RpcResolver: block pinning, concurrent key and contract resolution, full-access precedence

  3. Recursive pending resolution with the per-edge expect equality checks, plus depth and count caps

The spec’s caller-side algorithm is detailed enough that most of this is a careful port rather than a design exercise, which is a compliment to the NEP.

Three questions

  1. Where should it live? wallet-selector, NEAR Connect, standalone package, or inside near/intents. I lean standalone first so anything can depend on it without pulling in a framework, then integrate. Happy to be told otherwise.

  2. Test vectors. Are there canonical ones from the Rust implementation I should match against, or should I generate them and propose them as fixtures? I would rather they be shared than mine.

  3. Borsh and SHA3 in the browser. The Drawbacks section flags this. Any preference on dependencies, or should I keep it zero-dependency and inline what is needed?

One thing I know I cannot solve alone

Resolution needs live RPC against a pinned block, so you cannot verify from bytes alone. For login flows that is fine. For the audit case it is not, because what the recipient actually wants is a file they can keep and check next year. Local WASM execution is in Future Possibilities.

Not blocking on this, and it is a later problem than the resolver. But if anyone has thought about whether a narrower offline-verifiable subset is possible, I would like to hear it before I design the disclosure layer on top.

If either of the NEP authors is reading, I would particularly like to know whether the scope above matches what you had in mind for the TS side.

Repo and first commits shortly. Will keep this thread updated.

For context: I run Zcash India, where selective disclosure has been the central unsolved problem for years. That is what sent me looking here.

Jatin, Zcash India

1 Like

A standalone package first sounds like the most practical approach. It would keep the resolver framework-agnostic while allowing wallet-selector, NEAR Connect, Intents, and individual wallets to depend on the same implementation later.

Shared canonical test vectors generated from the Rust implementation would also be valuable. Cross-language consistency seems especially important here, so keeping those fixtures in a common or clearly referenced location would help wallet and application developers verify compatibility.

One additional consideration may be a documented error taxonomy for cases such as pinned-block failures, unresolved keys, replay-binding mismatches, and resolution limits. That could help wallets present clearer errors instead of treating every verification failure as a generic rejection.

2 Likes

Thanks Anzus, that is useful on all three.

Standalone it is, then. That was my lean and you have made the case better than I did, so I will treat it as settled and design for framework-agnostic from here rather than leaving it open.

On shared vectors, agreed, and I would rather they live upstream than in my repo. I have 21 generated from the reference implementation plus both doctest hashes. I am opening a PR to contribute them to crates/signatures/nep641 so they sit next to the code that defines them rather than in a downstream package. Cross-language consistency is exactly the argument for putting them there, so thank you for making it, I will link it in the PR.

The error taxonomy is the one I had not planned and I think you are right. Worth noting why the gap exists: the spec deliberately leaves errors unstructured on the contract side, since panics are NEAR’s native view-call failure channel and resolvers are told to treat every failure uniformly as “branch did not resolve”. That is a reasonable choice for the protocol and a bad experience for a wallet.

But the resolver knows things the contract cannot express. Which node in the graph failed, whether it was the access-key path or the contract call, and which specific check failed. The resolution algorithm has distinct failure points already: expect payload mismatch, max depth exceeded, sub-authorization count exceeded, the six access-key verification cases, block consistency, and the both-interpretations-failed case. Those are caller-side observations, so classifying them adds structure without contradicting the spec.

I will build it around those failure points as a typed error surface in stage 3, when RpcResolver lands and there is something real to classify. Structured error plus the original panic message preserved, so wallets can branch on the type and still surface the underlying text when they want to. If you have specific cases Gem Wallet would want to distinguish that are not in that list, tell me now and I will design for them rather than retrofit.

2 Likes

Thanks for the detailed explanation. I’m not a developer, so I don’t want to guess at implementation-specific requirements on our behalf.

From a support and user-experience perspective, the useful distinction would be whether the issue is temporary and can be retried, related to the account or its permissions, or caused by an invalid or unsupported request. Clearer categories would make it easier to give users a meaningful message instead of a generic failure.

I can share any specific cases our developers identify once they have reviewed it.

This is amazing! Thanks for driving it. It will be definitely useful for dApps.

P.S. while I am trying to resolve the permissions for the author to post links on the forum, here is the repo he shared with me, which already has the NEP-641 serializers: GitHub - jatinsahijwani/nep641-ts: TypeScript implementation of NEP-641: offchain authorizations for NEAR smart contracts · GitHub

1 Like

Stage 1 update

Repo: GitHub - jatinsahijwani/nep641-ts: TypeScript implementation of NEP-641: offchain authorizations for NEAR smart contracts · GitHub

offchainMessageHash reproduces the Rust byte for byte across 21 generated vectors plus both doctest hashes. 255 tests, each commit independently checked out and verified rather than only passing in sequence.

Three things came out of the port that other implementers will want. All written up in docs/SPEC-NOTES.md.

1. The borsh npm package silently truncates u64 timestamps. serialize("u64", 1785914880123456789) produces 00cdd70c… instead of 15cdd70c…. The literal is already rounded before the encoder sees it, and the package accepts it without complaint. Every realistic nanosecond timestamp sits past Number.MAX_SAFE_INTEGER, so this is the default behaviour rather than an edge case. The result is a well-formed message with a plausible hash that no contract will accept. Guarded at the serialization boundary here, but any JS implementation passing a plain number is silently wrong.

2. Trailing zeros in the subsecond field are stripped. .100000000 renders as .1. An implementation that always emits nine digits passes every test the reference ships and still disagrees with it for any millisecond-precision timestamp, which is everything a JavaScript client produces since Date.now() leaves the low six digits at zero.

3. An empty path is absent from the JSON while still contributing a zero count to the Borsh preimage. Both halves in the same message.

On my earlier question about dependencies: settled on @noble/hashes, @noble/curves, @scure/base and borsh, with borsh isolated behind a single module so it can be inlined later if that is preferred. Verified by bundling that the output carries no Buffer and no process.

One open question still. I used camelCase domain types with the snake_case mapping deferred to the JSON boundary in stage 2. Borsh is positional so this cannot affect the hash, but if the package ends up integrated with wallet-selector or NEAR Connect I would rather match whatever convention those expect. Any preference from people closer to those?

Stage 2 next, the NEP-413 mapping.

1 Like

Thanks a lot for the help.

1 Like

I believe it is the right call - JS ecosystem is settled on camelCase for field names, and the wire-format must be converted at the boundary level instead of poisoning the whole codebase.

1 Like

@frol thanks, that settles it. camelCase inward, wire format confined to the serialization boundary.

@Anzus_GemWallet your three categories are the right abstraction, better than a developer-level list would have been, because they are the decision a wallet actually has to make. Every failure point in the spec maps onto one of them:

  • Temporary: RPC unavailable, pinned block missing, block consistency mismatch
  • Account or permissions: key absent, key not FullAccess, no w_resolve_auth method, delegation declined by the contract
  • Invalid request: signature failure, chain_id / signer_id / path mismatch, future timestamp, expect mismatch, depth or count caps exceeded

Full mapping is going into docs/ERRORS.md as a design note now, so it is reviewable before stage 3 builds against it.

One honest limit worth flagging early, because it shapes what you can actually show a user. The resolver can classify its own checks precisely since it performs them. Contract-side failures come back as free-form panic strings, and the spec deliberately keeps them that way. So for anything a contract rejects, you get a category based on what the resolver knows plus the raw message passed through untouched. Some contract failures will land in a general bucket, and I would rather say that now than imply the taxonomy is exhaustive.

Still happy to take specific cases from your developers. Cheap to design for now, expensive to retrofit later.

Stage 2 next, plus the vectors PR upstream.

1 Like

Vectors PR is up: test(nep641): add canonical test vectors for OffchainMessage hashing by jatinsahijwani · Pull Request #350 · near/intents · GitHub

@Anzus_GemWallet this is the shared cross-language fixture set you asked for. It adds a published vector set for OffchainMessage hashing plus a Rust test asserting the implementation reproduces every one, so the fixtures cannot drift from the code. Coverage includes empty and non-empty path, subsecond timestamps with and without trailing zeros, and the existing doctest cases.

They sit in crates/signatures/nep641 rather than in my package, so any implementation in any language can check against the same set.

Stage 2 in progress, the NEP-413 mapping.

1 Like

Stage 2 update

AccessKeyAuthorization, the NEP-413 mapping and prehash, ed25519 and secp256k1 verification, and implicit account derivation. 507 tests against 135 generated vectors, fixtures regenerate byte for byte.

One finding is worth more attention than the rest.

ed25519 verification in the reference is cofactorless, and the spec does not say so. Ed25519::verify runs an explicit is_weak() check and then dalek’s ordinary verify(), which recomputes -[k]A + [s]B and compares compressed R bytes. @noble/curves’ high-level verify, the obvious choice for a TypeScript port, uses the cofactored equation instead.

They disagree on a public key carrying an order-8 torsion component. Take A' = A + T and sign honestly against it: the cofactorless difference is R - [k]T, so the byte comparison fails and the reference rejects. Multiplying through by the cofactor kills [k]T, so a cofactored verifier accepts. A' is not itself low order, so the weak-key check does not catch it either.

The direction matters. A port that calls the library function is the more permissive one, so it accepts authorizations every NEP-641 contract rejects. I ported the equation by hand over noble’s point API instead. The test asserts that noble accepts the torsion signature and that this package rejects it, so if noble ever changes its equation the test says so rather than quietly agreeing.

The spec’s full specification of verification is “All native NEAR Protocol key types SHOULD be supported; access_key and signature curves MUST match.” That leaves the equation choice open, and the two answers differ on real inputs.

Three others worth knowing:

  • The secp256k1 recovery byte is range-checked even though verification never recovers a key. A blob whose 65th byte is 0x04 fails for that reason alone. A port that slices off the first 64 bytes and hands them to a verifier accepts blobs the reference rejects.
  • deny_unknown_fields is one level deep. It sits on AccessKeyAuthorization and nothing else, so unknown fields inside msg, via and via.extra are all accepted. Serde’s adjacent tagging looks for its tag and content keys and ignores strangers rather than rejecting them. The collision avoidance the NEP relies on is top-level key names and nothing more.
  • Account IDs are validated on read. A port treating them as opaque strings accepts blobs the reference rejects, which matters because a resolver uses “did this parse” to decide which of two errors to report.

The full set is in docs/SPEC-NOTES.md: eleven divergences between the prose and the reference, each cited to file and line at pinned commits, and each verified against generated vectors rather than read from source alone.

None of these are errors in the NEP. But five of them are things an implementer has to get exactly right that are currently discoverable only by reading Rust, and I think each would be worth a sentence in the spec. Happy to open a PR against the NEP with clarifying language if that would be useful.

Stage 3 next: RpcResolver, the chain-state checks, and docs/ERRORS.md.

1 Like

Sure, please, open the PR for the NEP, so we can discuss the specific wording :folded_hands:

Two PRs up since the last update.

Conformance vectors, follow-up to #350

@frol suggested on #350 not to hesitate expanding the test suite, so this adds the stage-2 vectors: 112 across six groups.

Group Count Covers
nep413_payloads 11 NEP-413 payload construction and the SHA-256 prehash that is signed
signed 6 complete authorization blobs on both curves that must verify
verify_cases 14 blobs that parse but must not verify: curve mismatch, tampering, strictness
parse_cases 28 the JSON accept and reject boundary
account_id_cases 48 account ID validation, which happens on deserialization only
implicit_accounts 5 implicit account derivation on both curves

@Anzus_GemWallet these are the shared cross-language fixtures, now covering the access-key path rather than just the envelope hash.

All of them are reference output, regenerated at that checkout rather than carried across from my port, and re-running the generator reproduces the committed file byte-identically. I also mutation-tested the suite, so corrupting one value per group makes the relevant test fail naming the offending vector. A fixture whose stated contract is that it cannot drift should be able to prove it.

verify_cases.ed25519_torsion_public_key is the one worth singling out. It is a public key carrying an order-8 torsion component, signed honestly, and it separates the cofactorless equation the reference uses from the cofactored one most high-level libraries expose by default. An implementation that reproduces every other vector can still disagree on that one.

NEP-641 clarifications

Seven added lines in three places, non-normative per NEP-001:

  • Timestamp representations, after the existing clock-skew paragraph. The subsecond rendering rule, and the fact that the u64 Borsh form makes pre-epoch instants unrepresentable.
  • path in Borsh, after the canonical hash. skip_serializing_if governs JSON only, so an empty path still contributes its zero count to the preimage.
  • The two curve rules, as sub-bullets on step 4 of the verification procedure, mirroring the formatting step 6 already uses.

Each one is pinned by a vector in #350 or #351, so none of it is an assertion about behaviour.

I kept the ed25519 rule non-normative on purpose, lowercase “must match” rather than MUST, since NEP-001 allows clarifications rather than requirement changes on a Final NEP. If it should be a real requirement that is a one-word edit, and I would rather the author make that call than make it for him.

1 Like

Proposal is up as a separate topic, following the Infrastructure Committee format @frol suggested: Proposal: nep641-ts, the TypeScript resolver NEP-641 lists as TBD

$23,000 over twelve weeks for stages 3 and 4, the error taxonomy @Anzus_GemWallet asked for, an integration adapter, and a 1.0 release. The disclosure profile is deliberately out of scope until the confidential model settles.

Progress updates stay in this thread either way.

1 Like