Repository Public
1// NEAR Protocol (family "near"): native NEAR only, one Transfer action per transaction.2// Pure module: no network access, no side effects. The borsh Transaction is written by hand from the approved intent and3// proven byte-for-byte against @near-js/transactions in test/near.test.ts.4//5// References: nearcore core/primitives/src/transaction.rs (Transaction V0, SignedTransaction, Action), near-account-id6// (account id rules), near-seed-phrase / NEAR wallets (SLIP-0010 ed25519, m/44'/397'/0').7// The wallet's account is the implicit account of its key: account id = lowercase hex of the 32-byte ed25519 public key.8//9// Intent parameters (x):10// nonce the transaction nonce (access key nonce + 1), canonical decimal uint6411// blockHash base58 hash of a recent final block. It is the only thing that binds the transaction to a chain (NEAR12// transactions carry no chain id) and it bounds its validity (86 400 blocks after that block).13//14// Fee: a NEAR transaction does not state its fee; the protocol charges gas at the block's gas price. The price is clamped by15// nearcore to 20 × the minimum price (chain/chain/src/types.rs BlockEconomicsConfig::max_gas_price), so the worst case is16// known: nearFeeCeiling(to). build() refuses an intent whose maxFee is below that ceiling; the API verifies on both sources17// that the live protocol parameters stay within the constants below before it proposes anything.18import { ed25519 } from '@noble/curves/ed25519.js';19import { sha256 } from '@noble/hashes/sha2.js';20import { base58 } from '@scure/base';21import { baseUnits } from '../amounts.ts';22import type { FamilyPlugin, KeyHandle, Signed } from '../families.ts';23import type { Intent } from '../intent.ts';24import type { NetworkDef } from '../networks.ts';2526/** genesis block hash of NEAR mainnet (chain_id "mainnet"), as reported by every node in `status.genesis_hash` */27export const NEAR_MAINNET_GENESIS = 'EPnLgE7iEq9s7yTkos96M3cWymH5avBAPm3qx3NXqR8H';28/** highest gas price the protocol allows, in yoctoNEAR per gas: 20 × 10^8 (nearcore BlockEconomicsConfig) */29export const NEAR_MAX_GAS_PRICE = 2_000_000_000n;30/** upper bounds on the gas of a transfer (send + execution). Protocol 88: 0.446 Tgas to a named account, 8.35 Tgas to an implicit one. */31export const NEAR_GAS_BOUND_NAMED = 1_000_000_000_000n, NEAR_GAS_BOUND_IMPLICIT = 10_000_000_000_000n;3233const UINT64_MAX = (1n << 64n) - 1n, UINT128_MAX = (1n << 128n) - 1n;34const X_KEYS = ['blockHash', 'nonce'];35const enc = new TextEncoder();36const hexOf = (b: Uint8Array) => Array.from(b, (v) => v.toString(16).padStart(2, '0')).join('');3738/** implicit account id of an ed25519 public key: its 32 bytes in lowercase hex */39export function nearImplicitAccount(publicKey: Uint8Array): string {40 if (publicKey.length !== 32) throw new Error('ed25519 public key must be 32 bytes');41 return hexOf(publicKey);42}4344/**45 * Validate a NEAR account id and say what it is. Accepted: an implicit account (64 lowercase hex characters) or a named46 * account (2–64 characters; lowercase letters and digits in parts separated by single ".", "-" or "_"; near-account-id rules).47 * Refused: upper case, the reserved "system" account, and the other derived account kinds (Ethereum-style "0x…" and the48 * 42-character "0s…" / "0u…" forms): this wallet does not send to them.49 */50export function nearAccountKind(id: string): 'implicit' | 'named' {51 if (typeof id !== 'string') throw new Error('invalid NEAR account id');52 if (id.length < 2 || id.length > 64) throw new Error('invalid NEAR account id: it must be 2 to 64 characters long');53 let sep = true; // true at the start and right after a separator: an alphanumeric must follow54 for (let k = 0; k < id.length; k++) {55 const c = id.charCodeAt(k);56 const alnum = (c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39);57 if (alnum) { sep = false; continue; }58 if ((c === 0x2e || c === 0x2d || c === 0x5f) && !sep) { sep = true; continue; }59 if (c >= 0x41 && c <= 0x5a) throw new Error('invalid NEAR account id: upper-case letters are not allowed');60 throw new Error('invalid NEAR account id: only lowercase letters, digits and single ".", "-" or "_" separators are allowed');61 }62 if (sep) throw new Error('invalid NEAR account id: it cannot end with a separator');63 if (/^[0-9a-f]{64}$/.test(id)) return 'implicit';64 if (id === 'system') throw new Error('"system" is a reserved NEAR account');65 if (/^0[a-z][0-9a-f]{40}$/.test(id)) throw new Error('Ethereum-style and other derived NEAR accounts (0x…, 0s…, 0u…) are not supported');66 return 'named';67}6869/** Worst-case network fee of a transfer to `to`, in yoctoNEAR: gas bound × highest gas price the protocol allows. */70export function nearFeeCeiling(to: string): bigint {71 return (nearAccountKind(to) === 'implicit' ? NEAR_GAS_BOUND_IMPLICIT : NEAR_GAS_BOUND_NAMED) * NEAR_MAX_GAS_PRICE;72}7374/** Canonical base58 of exactly 32 bytes (a NEAR block or transaction hash). */75export function nearDecodeHash(s: string): Uint8Array {76 if (typeof s !== 'string' || !/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(s)) throw new Error('invalid NEAR hash');77 let raw: Uint8Array;78 try { raw = base58.decode(s); } catch { throw new Error('invalid NEAR hash'); }79 if (raw.length !== 32 || base58.encode(raw) !== s) throw new Error('invalid NEAR hash');80 return raw;81}8283// ---- borsh writer (little-endian) ----84function concat(...a: Uint8Array[]): Uint8Array {85 const out = new Uint8Array(a.reduce((n, p) => n + p.length, 0));86 let o = 0;87 for (const p of a) { out.set(p, o); o += p.length; }88 return out;89}90const u32 = (n: number): Uint8Array => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; };91const u64 = (v: bigint): Uint8Array => { if (v < 0n || v > UINT64_MAX) throw new Error('uint64 out of range'); const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, v, true); return b; };92const u128 = (v: bigint): Uint8Array => {93 if (v < 0n || v > UINT128_MAX) throw new Error('uint128 out of range');94 const b = new Uint8Array(16), d = new DataView(b.buffer);95 d.setBigUint64(0, v & UINT64_MAX, true); d.setBigUint64(8, v >> 64n, true);96 return b;97};98const str = (s: string): Uint8Array => { const b = enc.encode(s); return concat(u32(b.length), b); };99const KEY_TYPE_ED25519 = 0, ACTION_TRANSFER = 3;100101export interface NearBuilt {102 /** borsh Transaction (V0) */103 tx: Uint8Array;104 /** sha256(tx): the 32 bytes ed25519 signs; its base58 form is the transaction hash */105 hash: Uint8Array;106 txid: string;107 describe: Record<string, string>;108}109110/** Rebuild the exact borsh Transaction from the intent alone. Throws on anything that is not a plain NEAR transfer. */111export function buildNearTx(i: Intent, net: NetworkDef): NearBuilt {112 if (i.v !== 1) throw new Error('unsupported intent version');113 if (net.family !== 'near' || i.network !== net.id) throw new Error('not a NEAR intent');114 if (net.chainRef !== NEAR_MAINNET_GENESIS) throw new Error('unknown NEAR network: only mainnet is supported');115 if (i.chainRef !== net.chainRef) throw new Error('chain identity mismatch (genesis hash)');116 if (i.evm || i.btc || i.sol) throw new Error('unexpected chain section in a NEAR intent');117 if (i.memo !== undefined) throw new Error('NEAR transfers do not carry a memo');118 if (i.asset !== 'native' || net.native.id !== 'native') throw new Error('only native NEAR can be sent on NEAR');119 if (i.symbol !== net.native.symbol || i.decimals !== net.native.decimals || i.decimals !== 24) throw new Error('asset metadata mismatch');120 const x = i.x;121 if (!x) throw new Error('missing NEAR parameters');122 const keys = Object.keys(x).sort();123 if (keys.length !== X_KEYS.length || keys.some((k, n) => k !== X_KEYS[n])) throw new Error('unknown or missing NEAR parameter (expected blockHash, nonce)');124125 if (nearAccountKind(i.from) !== 'implicit') throw new Error('source is not an implicit NEAR account');126 const publicKey = Uint8Array.from(i.from.match(/../g)!, (h) => parseInt(h, 16));127 const kind = nearAccountKind(i.to);128 if (i.from === i.to) throw new Error('destination equals source');129 const amount = baseUnits(i.amount);130 if (amount <= 0n) throw new Error('amount must be positive');131 if (amount > UINT128_MAX) throw new Error('amount out of range');132 const ceiling = nearFeeCeiling(i.to);133 if (ceiling > baseUnits(i.maxFee)) throw new Error('fee above approved maximum: the worst-case network fee exceeds maxFee');134135 if (x.nonce === undefined || !/^[1-9]\d{0,19}$/.test(x.nonce)) throw new Error('x.nonce must be a canonical positive decimal integer');136 const nonce = BigInt(x.nonce);137 if (nonce > UINT64_MAX) throw new Error('x.nonce out of range');138 const blockHash = nearDecodeHash(x.blockHash ?? '');139140 const tx = concat(141 str(i.from), // signer_id142 new Uint8Array([KEY_TYPE_ED25519]), publicKey, // public_key143 u64(nonce), // nonce144 str(i.to), // receiver_id145 blockHash, // block_hash146 u32(1), // actions: exactly one147 new Uint8Array([ACTION_TRANSFER]), u128(amount), // Transfer { deposit }148 );149 const hash = sha256(tx), txid = base58.encode(hash);150 return {151 tx, hash, txid,152 describe: {153 network: net.name, operation: kind === 'implicit' ? 'Transfer (native NEAR, to an implicit account)' : 'Transfer (native NEAR)', from: i.from, to: i.to,154 amountYocto: amount.toString(), nonce: nonce.toString(), blockHash: x.blockHash!, feeCeilingYocto: ceiling.toString(), txid,155 },156 };157}158159/** borsh SignedTransaction: the transaction followed by its ed25519 signature */160export function nearSignedTx(tx: Uint8Array, signature: Uint8Array): Uint8Array {161 if (signature.length !== 64) throw new Error('bad signature length');162 return concat(tx, new Uint8Array([KEY_TYPE_ED25519]), signature);163}164165/**166 * Transaction hash (base58) and signer account of a base64 SignedTransaction produced by this module; used by the API to167 * track what it relays. Only the outer shape is checked here (the network verifies the rest).168 */169export function nearSignedTxInfo(rawBase64: string): { txid: string; signerId: string } {170 if (typeof rawBase64 !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(rawBase64)) throw new Error('not a NEAR signed transaction');171 const raw = Uint8Array.from(atob(rawBase64), (c) => c.charCodeAt(0));172 // shortest transaction this module writes: 4 + 64 (signer) + 33 (key) + 8 (nonce) + 4 + 2 (receiver) + 32 (block) + 4 + 17 (one transfer)173 if (raw.length < 168 + 65 || raw[raw.length - 65] !== KEY_TYPE_ED25519) throw new Error('not a NEAR signed transaction');174 if (new DataView(raw.buffer, raw.byteOffset).getUint32(0, true) !== 64) throw new Error('not a NEAR signed transaction');175 const signerId = String.fromCharCode(...raw.subarray(4, 68));176 if (nearAccountKind(signerId) !== 'implicit' || signerId !== hexOf(raw.subarray(69, 101)) || raw[68] !== KEY_TYPE_ED25519) throw new Error('not a NEAR signed transaction');177 return { txid: base58.encode(sha256(raw.subarray(0, raw.length - 65))), signerId };178}179180export const nearPlugin: FamilyPlugin = {181 family: 'near',182 curve: 'ed25519',183 path(net: NetworkDef): string {184 if (net.coinType !== 397) throw new Error('NEAR derivation requires coin type 397');185 return "m/44'/397'/0'";186 },187 address: (_net: NetworkDef, publicKey: Uint8Array) => nearImplicitAccount(publicKey),188 normalizeAddress(_net: NetworkDef, addr: string): string {189 nearAccountKind(addr);190 return addr;191 },192 build(i: Intent, net: NetworkDef) {193 const b = buildNearTx(i, net);194 return { signDocs: [b.hash], describe: b.describe };195 },196 sign(i: Intent, net: NetworkDef, key: KeyHandle): Signed {197 const b = buildNearTx(i, net);198 if (key.curve !== 'ed25519' || !key.signMessage || key.publicKey.length !== 32) throw new Error('NEAR needs an ed25519 key');199 if (nearImplicitAccount(key.publicKey) !== i.from) throw new Error('signing key does not control the source account');200 const sig = key.signMessage(b.hash);201 if (sig.length !== 64 || !ed25519.verify(sig, b.hash, key.publicKey)) throw new Error('signature self-check failed');202 return { raw: btoa(String.fromCharCode(...nearSignedTx(b.tx, sig))), txid: b.txid };203 },204};205