Repository Public
1// TON (family "ton"): native Toncoin only, one outgoing message per transfer, standard wallet contract v4R2.2// Pure module: no network access, no side effects. Cells, the bag-of-cells container and the wallet messages are written by hand3// from the approved intent and proven against @ton/core + @ton/ton (cell hashes and BoC bytes) in test/ton.test.ts.4//5// Keys: ed25519 by SLIP-0010 at m/44'/607'/0' from the wallet's single BIP-39 seed. TON's own 24-word scheme is NOT used, so this6// address is not the one Tonkeeper derives from a TON-native phrase, and this wallet cannot be restored there from its words.7//8// Address = hash of the wallet's StateInit (v4R2 code, data = seqno 0 | subwallet id | public key | no plugins), workchain 0.9// One canonical text form everywhere in an intent: non-bounceable, URL-safe ("UQ…"). Whether the message bounces is NOT taken10// from the address text: it is the explicit, approved parameter x.bounce.11//12// Intent parameters (x), all required:13// seqno wallet sequence number the message is valid for (canonical decimal, uint32)14// validUntil unix time after which the wallet contract rejects the message15// bounce "1" (destination is an initialized contract) | "0" (it is not: funds must not be bounced back)16// sendMode "3" only: pay the forwarding fee separately + ignore action errors (what every standard wallet sends)17// stateInit "1" attach the wallet's code and data (first outgoing transfer: deploys the contract; seqno must be 0) | "0"18// The optional text comment is intent.memo.19//20// What ed25519 signs: the representation hash of the v4 signing message21// subwallet_id:uint32 valid_until:uint32 seqno:uint32 op:uint8(0) mode:uint8 ^(internal message).22// The StateInit and the external envelope are not covered by the signature (protocol property); they are rebuilt here from the23// signing key and the intent. TON signatures carry no network id and no fee field: the fee is charged from the balance by24// the network's own price table, so intent.maxFee is a budget checked by the API, not a value this module can put on the wire.25import { ed25519 } from '@noble/curves/ed25519.js';26import { sha256 } from '@noble/hashes/sha2.js';27import { baseUnits } from '../amounts.ts';28import type { FamilyPlugin, KeyHandle, Signed } from '../families.ts';29import type { Intent } from '../intent.ts';30import type { NetworkDef } from '../networks.ts';3132/** masterchain zero-state root hash of the TON main network (hex); the file hash is 5e994fcf…a0f6e */33export const TON_MAINNET_ZEROSTATE = '17a3a92992aabea785a7a090985a265cd31f323d849da51239737e321fb05569';34/** default subwallet id of v3/v4 wallets on workchain 0 */35export const TON_SUBWALLET_ID = 698983191;36/** representation hash of the wallet v4R2 code cell */37export const TON_V4R2_CODE_HASH = 'feb5ff6820e2ff0d9483e7e0d62c817d846789fb4ae580c878866d959dabd5c0';38/** wallet v4R2 code, bag of cells (base64) — the constant shipped in @ton/ton's WalletContractV4; its hash is checked on first use */39export const TON_V4R2_CODE_BOC = 'te6ccgECFAEAAtQAART/APSkE/S88sgLAQIBIAIDAgFIBAUE+PKDCNcYINMf0x/THwL4I7vyZO1E0NMf0x/T//QE0VFDuvKhUVG68qIF+QFUEGT5EPKj+AAkpMjLH1JAyx9SMMv/UhD0AMntVPgPAdMHIcAAn2xRkyDXSpbTB9QC+wDoMOAhwAHjACHAAuMAAcADkTDjDQOkyMsfEssfy/8QERITAubQAdDTAyFxsJJfBOAi10nBIJJfBOAC0x8hghBwbHVnvSKCEGRzdHK9sJJfBeAD+kAwIPpEAcjKB8v/ydDtRNCBAUDXIfQEMFyBAQj0Cm+hMbOSXwfgBdM/yCWCEHBsdWe6kjgw4w0DghBkc3RyupJfBuMNBgcCASAICQB4AfoA9AQw+CdvIjBQCqEhvvLgUIIQcGx1Z4MesXCAGFAEywUmzxZY+gIZ9ADLaRfLH1Jgyz8gyYBA+wAGAIpQBIEBCPRZMO1E0IEBQNcgyAHPFvQAye1UAXKwjiOCEGRzdHKDHrFwgBhQBcsFUAPPFiP6AhPLassfyz/JgED7AJJfA+ICASAKCwBZvSQrb2omhAgKBrkPoCGEcNQICEekk30pkQzmkD6f+YN4EoAbeBAUiYcVnzGEAgFYDA0AEbjJftRNDXCx+AA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA4PABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AAG7SB/oA1NQi+QAFyMoHFcv/ydB3dIAYyMsFywIizxZQBfoCFMtrEszMyXP7AMhAFIEBCPRR8qcCAHCBAQjXGPoA0z/IVCBHgQEI9FHyp4IQbm90ZXB0gBjIywXLAlAGzxZQBPoCFMtqEssfyz/Jc/sAAgBsgQEI1xj6ANM/MFIkgQEI9Fnyp4IQZHN0cnB0gBjIywXLAlAFzxZQA/oCE8tqyx8Syz/Jc/sAAAr0AMntVA==';40/** the only send mode this wallet signs: PAY_GAS_SEPARATELY (1) + IGNORE_ERRORS (2). 32 (destroy) and 128 (send everything) are never signed. */41export const TON_SEND_MODE = 3;42/** longest validity window (validUntil - createdAt) the signer accepts, in seconds */43export const TON_MAX_VALIDITY_SEC = 600;4445const X_KEYS = ['bounce', 'sendMode', 'seqno', 'stateInit', 'validUntil']; // sorted46const UINT32_MAX = 0xffff_ffffn, COINS_MAX = (1n << 120n) - 1n;47const MAX_CELL_BITS = 1023, MAX_CELL_REFS = 4;48const TAG_BOUNCEABLE = 0x11, TAG_NON_BOUNCEABLE = 0x51, TAG_TESTNET = 0x80;49const enc = new TextEncoder();5051const hexOf = (b: Uint8Array) => Array.from(b, (v) => v.toString(16).padStart(2, '0')).join('');52const b64 = (b: Uint8Array) => { let s = ''; for (const v of b) s += String.fromCharCode(v); return btoa(s); };53function unb64(s: string): Uint8Array {54 if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s) || s.length % 4 !== 0) throw new Error('invalid base64');55 const raw = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));56 if (b64(raw) !== s) throw new Error('non-canonical base64');57 return raw;58}59function concat(...a: Uint8Array[]): Uint8Array {60 const out = new Uint8Array(a.reduce((n, p) => n + p.length, 0));61 let o = 0;62 for (const p of a) { out.set(p, o); o += p.length; }63 return out;64}6566// ---- cells ----6768/** An ordinary (non-exotic, level 0) cell with its representation hash. */69export interface TonCell {70 /** data bytes; unused low bits of the last byte are zero */71 readonly data: Uint8Array;72 readonly bitLen: number;73 readonly refs: readonly TonCell[];74 readonly hash: Uint8Array;75 readonly depth: number;76}7778/** data bytes with the completion tag (a 1 bit, then zeros) when the length is not a multiple of 8 */79function padded(data: Uint8Array, bitLen: number): Uint8Array {80 const out = Uint8Array.from(data.subarray(0, (bitLen + 7) >> 3));81 if (bitLen % 8 !== 0) out[out.length - 1] = (out[out.length - 1]! & (0xff << (8 - (bitLen % 8)))) | (0x80 >> (bitLen % 8));82 return out;83}8485function makeCell(data: Uint8Array, bitLen: number, refs: readonly TonCell[]): TonCell {86 if (bitLen < 0 || bitLen > MAX_CELL_BITS || refs.length > MAX_CELL_REFS || data.length < (bitLen + 7) >> 3) throw new Error('cell overflow');87 const body = padded(data, bitLen);88 const clean = body.slice();89 if (bitLen % 8 !== 0) clean[clean.length - 1]! &= 0xff << (8 - (bitLen % 8)); // without the completion tag90 const depth = refs.length === 0 ? 0 : 1 + Math.max(...refs.map((r) => r.depth));91 if (depth > 1024) throw new Error('cell tree too deep');92 // representation: d1 (refs; ordinary, level 0), d2 (floor(bits/8) + ceil(bits/8)), padded data, depths, hashes93 const d = new Uint8Array([refs.length, (bitLen >> 3) + ((bitLen + 7) >> 3)]);94 const depths = new Uint8Array(refs.length * 2);95 refs.forEach((r, k) => { depths[2 * k] = r.depth >> 8; depths[2 * k + 1] = r.depth & 0xff; });96 const hash = sha256(concat(d, body, depths, ...refs.map((r) => r.hash)));97 return { data: clean, bitLen, refs: [...refs], hash, depth };98}99100class CellBuilder {101 private buf = new Uint8Array(128);102 private len = 0;103 private refs: TonCell[] = [];104 get bits(): number { return this.len; }105 get refCount(): number { return this.refs.length; }106 get avail(): number { return MAX_CELL_BITS - this.len; }107 bit(v: boolean | number): this {108 if (this.len >= MAX_CELL_BITS) throw new Error('cell overflow');109 if (v) this.buf[this.len >> 3]! |= 0x80 >> (this.len & 7);110 this.len++;111 return this;112 }113 uint(v: bigint | number, n: number): this {114 const x = BigInt(v);115 if (x < 0n || x >> BigInt(n) !== 0n) throw new Error(`value does not fit in ${n} bits`);116 for (let k = n - 1; k >= 0; k--) this.bit(Number((x >> BigInt(k)) & 1n));117 return this;118 }119 bytes(b: Uint8Array): this { for (const v of b) this.uint(v, 8); return this; }120 /** Grams / Coins: VarUInteger 16 */121 coins(v: bigint): this {122 if (v < 0n || v > COINS_MAX) throw new Error('amount out of range');123 if (v === 0n) return this.uint(0, 4);124 const n = Math.ceil(v.toString(2).length / 8);125 return this.uint(n, 4).uint(v, n * 8);126 }127 /** MsgAddressInt addr_std$10, no anycast, workchain 0 */128 addr(hash: Uint8Array): this {129 if (hash.length !== 32) throw new Error('bad account id');130 return this.uint(0b100, 3).uint(0, 8).bytes(hash);131 }132 ref(c: TonCell): this {133 if (this.refs.length >= MAX_CELL_REFS) throw new Error('cell overflow');134 this.refs.push(c);135 return this;136 }137 /** append another cell's bits and references */138 append(c: TonCell): this {139 for (let k = 0; k < c.bitLen; k++) this.bit(c.data[k >> 3]! & (0x80 >> (k & 7)));140 for (const r of c.refs) this.ref(r);141 return this;142 }143 cell(): TonCell { return makeCell(this.buf.slice(0, (this.len + 7) >> 3), this.len, this.refs); }144}145146function crc32c(b: Uint8Array): Uint8Array {147 let crc = 0xffffffff;148 for (const v of b) { crc ^= v; for (let k = 0; k < 8; k++) crc = crc & 1 ? (crc >>> 1) ^ 0x82f63b78 : crc >>> 1; }149 crc = (crc ^ 0xffffffff) >>> 0;150 return new Uint8Array([crc & 0xff, (crc >>> 8) & 0xff, (crc >>> 16) & 0xff, crc >>> 24]); // little-endian151}152const BOC_MAGIC = [0xb5, 0xee, 0x9c, 0x72];153154/**155 * Serialize one root cell as a bag of cells (serialized_boc#b5ee9c72, no index, CRC32-C). Cell order is the one @ton/core156 * emits (reverse post-order, references walked last to first, identical cells stored once), so the bytes are identical.157 */158export function tonSerializeBoc(root: TonCell): Uint8Array {159 const post: TonCell[] = [], seen = new Set<string>();160 const visit = (c: TonCell) => {161 const h = hexOf(c.hash);162 if (seen.has(h)) return;163 seen.add(h);164 for (let k = c.refs.length - 1; k >= 0; k--) visit(c.refs[k]!);165 post.push(c);166 };167 visit(root);168 const order = post.reverse();169 if (order.length > 255) throw new Error('too many cells');170 const index = new Map(order.map((c, k) => [hexOf(c.hash), k]));171 const cells = order.map((c) => concat(new Uint8Array([c.refs.length, (c.bitLen >> 3) + ((c.bitLen + 7) >> 3)]), padded(c.data, c.bitLen), new Uint8Array(c.refs.map((r) => index.get(hexOf(r.hash))!))));172 const total = cells.reduce((n, c) => n + c.length, 0);173 const offBytes = total < 0x100 ? 1 : total < 0x10000 ? 2 : 3;174 const size = new Uint8Array(offBytes);175 for (let k = 0; k < offBytes; k++) size[k] = (total >> (8 * (offBytes - 1 - k))) & 0xff;176 // flags byte: has_idx 0, has_crc32c 1, has_cache_bits 0, flags 00, ref size 001177 const body = concat(new Uint8Array([...BOC_MAGIC, 0x41, offBytes, order.length, 1, 0]), size, new Uint8Array([0]), ...cells);178 return concat(body, crc32c(body));179}180181/**182 * Parse a bag of cells with exactly one root made only of ordinary cells (anything else throws). Used for the pinned wallet183 * code and, by the API, for account data and message bodies returned by its data sources.184 */185export function tonParseBoc(src: Uint8Array): TonCell {186 let o = 0;187 const need = (n: number) => { if (n < 0 || o + n > src.length) throw new Error('truncated bag of cells'); };188 const u = (n: number): number => { need(n); let v = 0; for (let k = 0; k < n; k++) v = v * 256 + src[o++]!; return v; };189 if (src.length < 10 || BOC_MAGIC.some((m, k) => src[k] !== m)) throw new Error('not a bag of cells');190 o = 4;191 const flags = u(1), hasIdx = (flags & 0x80) !== 0, hasCrc = (flags & 0x40) !== 0, refSize = flags & 7;192 if ((flags & 0x38) !== 0 || refSize < 1 || refSize > 2) throw new Error('unsupported bag of cells');193 const offBytes = u(1);194 if (offBytes < 1 || offBytes > 4) throw new Error('unsupported bag of cells');195 const count = u(refSize), roots = u(refSize), absent = u(refSize), total = u(offBytes);196 if (roots !== 1 || absent !== 0 || count < 1 || count > 4096) throw new Error('unsupported bag of cells');197 const rootIdx = u(refSize);198 if (rootIdx !== 0) throw new Error('unsupported bag of cells');199 if (hasIdx) { need(count * offBytes); o += count * offBytes; }200 if (src.length !== o + total + (hasCrc ? 4 : 0)) throw new Error('bag of cells length mismatch');201 if (hasCrc && crc32c(src.subarray(0, src.length - 4)).some((v, k) => v !== src[src.length - 4 + k])) throw new Error('bag of cells checksum mismatch');202 const raw: { data: Uint8Array; bitLen: number; refs: number[] }[] = [];203 for (let k = 0; k < count; k++) {204 const d1 = u(1), d2 = u(1);205 if (d1 > MAX_CELL_REFS) throw new Error('unsupported cell (exotic, with level or stored hashes)');206 const bytes = (d2 + 1) >> 1;207 need(bytes);208 const data = Uint8Array.from(src.subarray(o, o + bytes)); // a copy even when src is a Node Buffer (whose slice() is a view)209 o += bytes;210 let bitLen = bytes * 8;211 if (d2 & 1) { // not byte-aligned: strip the completion tag212 const last = data[bytes - 1]!;213 if (last === 0) throw new Error('bad cell padding');214 const tz = 31 - Math.clz32(last & -last);215 bitLen = bytes * 8 - tz - 1;216 data[bytes - 1] = last & ~(1 << tz);217 }218 const refs: number[] = [];219 for (let r = 0; r < d1; r++) { const idx = u(refSize); if (idx <= k || idx >= count) throw new Error('bad cell reference'); refs.push(idx); }220 raw.push({ data, bitLen, refs });221 }222 if (o !== src.length - (hasCrc ? 4 : 0)) throw new Error('bag of cells length mismatch');223 const built: TonCell[] = new Array(count);224 for (let k = count - 1; k >= 0; k--) built[k] = makeCell(raw[k]!.data, raw[k]!.bitLen, raw[k]!.refs.map((r) => built[r]!));225 return built[0]!;226}227228/** read `n` bits (n <= 48) at bit offset `at` of a cell */229function readUint(c: TonCell, at: number, n: number): number {230 if (at < 0 || n > 48 || at + n > c.bitLen) throw new Error('cell underflow');231 let v = 0;232 for (let k = at; k < at + n; k++) v = v * 2 + (c.data[k >> 3]! & (0x80 >> (k & 7)) ? 1 : 0);233 return v;234}235function readBytes(c: TonCell, at: number, n: number): Uint8Array {236 const out = new Uint8Array(n);237 for (let k = 0; k < n; k++) out[k] = readUint(c, at + 8 * k, 8);238 return out;239}240241// ---- wallet v4R2 ----242243let codeCell: TonCell | undefined;244/** the wallet v4R2 code cell; throws unless the embedded constant hashes to the well-known code hash */245export function tonV4R2Code(): TonCell {246 if (!codeCell) {247 const c = tonParseBoc(unb64(TON_V4R2_CODE_BOC));248 if (hexOf(c.hash) !== TON_V4R2_CODE_HASH) throw new Error('wallet v4R2 code does not match its pinned hash');249 codeCell = c;250 }251 return codeCell;252}253254const pubkeyOf = (publicKey: Uint8Array) => { if (!(publicKey instanceof Uint8Array) || publicKey.length !== 32) throw new Error('ed25519 public key must be 32 bytes'); return publicKey; };255/** initial data of a v4R2 wallet: seqno 0, subwallet id, public key, empty plugin dictionary */256const walletData = (publicKey: Uint8Array) => new CellBuilder().uint(0, 32).uint(TON_SUBWALLET_ID, 32).bytes(pubkeyOf(publicKey)).bit(0).cell();257/** StateInit: no split_depth, no special, code, data, no libraries */258export const tonStateInit = (publicKey: Uint8Array): TonCell => new CellBuilder().uint(0b00110, 5).ref(tonV4R2Code()).ref(walletData(publicKey)).cell();259/** 32-byte account id of the v4R2 wallet of a public key (workchain 0) */260export const tonAccountId = (publicKey: Uint8Array): Uint8Array => tonStateInit(publicKey).hash;261262/** Decode the persistent data of a deployed v4R2 wallet (as returned by a data source). */263export function tonWalletData(data: TonCell): { seqno: number; subwalletId: number; publicKey: Uint8Array; plugins: boolean } {264 if (data.bitLen !== 321) throw new Error('not wallet v4 data');265 const plugins = readUint(data, 320, 1) === 1;266 if (data.refs.length !== (plugins ? 1 : 0)) throw new Error('not wallet v4 data');267 return { seqno: readUint(data, 0, 32), subwalletId: readUint(data, 32, 32), publicKey: readBytes(data, 64, 32), plugins };268}269270// ---- addresses ----271272function crc16xmodem(b: Uint8Array): number {273 let crc = 0;274 for (const byte of b) {275 crc ^= byte << 8;276 for (let k = 0; k < 8; k++) crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;277 }278 return crc;279}280281/** User-friendly address of a workchain-0 account: URL-safe base64 of tag | workchain | account id | CRC16. */282export function tonFormatAddress(accountId: Uint8Array, opts: { bounceable?: boolean } = {}): string {283 if (accountId.length !== 32) throw new Error('bad account id');284 const body = new Uint8Array(36);285 body[0] = opts.bounceable ? TAG_BOUNCEABLE : TAG_NON_BOUNCEABLE;286 body[1] = 0; // workchain 0287 body.set(accountId, 2);288 const crc = crc16xmodem(body.subarray(0, 34));289 body[34] = crc >> 8; body[35] = crc & 0xff; // big-endian290 return b64(body).replace(/\+/g, '-').replace(/\//g, '_');291}292293/**294 * Decode a destination: user-friendly "EQ…" / "UQ…" (base64 or base64url) or raw "0:<64 hex>". Returns the 32-byte account id.295 * Refused: testnet-only addresses, any workchain other than 0, bad checksums, and the all-zero account.296 */297export function tonParseAddress(addr: string): Uint8Array {298 if (typeof addr !== 'string') throw new Error('invalid TON address');299 let id: Uint8Array;300 const raw = /^(-?\d+):([0-9a-fA-F]{64})$/.exec(addr);301 if (raw) {302 if (raw[1] !== '0') throw new Error('only workchain 0 (basechain) addresses are supported');303 id = Uint8Array.from(raw[2]!.match(/../g)!, (h) => parseInt(h, 16));304 } else {305 if (!/^[A-Za-z0-9_\-+/]{48}$/.test(addr)) throw new Error('invalid TON address: expected 48 characters (EQ… / UQ…) or 0:<64 hex>');306 if (/[-_]/.test(addr) && /[+/]/.test(addr)) throw new Error('invalid TON address: mixed base64 alphabets');307 let b: Uint8Array;308 try { b = unb64(addr.replace(/-/g, '+').replace(/_/g, '/')); } catch { throw new Error('invalid TON address encoding'); }309 if (b.length !== 36) throw new Error('invalid TON address length');310 const crc = crc16xmodem(b.subarray(0, 34));311 if (b[34] !== crc >> 8 || b[35] !== (crc & 0xff)) throw new Error('invalid TON address checksum');312 const tag = b[0]!;313 if (tag & TAG_TESTNET) throw new Error('this is a testnet-only TON address');314 if (tag !== TAG_BOUNCEABLE && tag !== TAG_NON_BOUNCEABLE) throw new Error('invalid TON address flags');315 if (b[1] !== 0) throw new Error('only workchain 0 (basechain) addresses are supported');316 id = b.slice(2, 34);317 }318 if (id.every((v) => v === 0)) throw new Error('the zero address cannot receive funds');319 return id;320}321322/** canonical form used in intents: non-bounceable, URL-safe */323export const tonNormalizeAddress = (addr: string): string => tonFormatAddress(tonParseAddress(addr));324/** raw "0:<hex>" form, for data-source queries */325export const tonRawAddress = (addr: string): string => '0:' + hexOf(tonParseAddress(addr));326/** the wallet's own address (shown non-bounceable so that a first deposit to the not-yet-deployed contract is not bounced back) */327export const tonAddressOf = (publicKey: Uint8Array): string => tonFormatAddress(tonAccountId(publicKey));328function canonicalId(addr: string, what: string): Uint8Array {329 const id = tonParseAddress(addr);330 if (tonFormatAddress(id) !== addr) throw new Error(`${what} address is not in canonical form (non-bounceable UQ…)`);331 return id;332}333334// ---- messages ----335336/** UTF-8 bytes of a text comment; throws on an empty or ill-formed string */337export function tonCommentBytes(memo: string): Uint8Array {338 if (typeof memo !== 'string' || memo.length === 0) throw new Error('empty comment');339 const bytes = enc.encode(memo);340 if (new TextDecoder('utf-8', { fatal: true }).decode(bytes) !== memo) throw new Error('comment is not valid Unicode text');341 return bytes;342}343/** text comment body: op 0 then the UTF-8 text, continued in a chain of references when it does not fit ("snake") */344function commentCell(bytes: Uint8Array): TonCell {345 const fill = (b: CellBuilder, rest: Uint8Array): TonCell => {346 const fit = Math.floor(b.avail / 8);347 if (rest.length <= fit) return b.bytes(rest).cell();348 return b.bytes(rest.subarray(0, fit)).ref(fill(new CellBuilder(), rest.subarray(fit))).cell();349 };350 return fill(new CellBuilder().uint(0, 32), bytes);351}352353export interface TonTransfer { to: Uint8Array; amount: bigint; comment?: Uint8Array; seqno: number; validUntil: number; bounce: boolean }354355/** int_msg_info$0 ihr_disabled:1 bounce bounced:0 src:addr_none dest value no-extra ihr_fee:0 fwd_fee:0 created_lt:0 created_at:0, no init, body */356function internalMessage(t: TonTransfer): TonCell {357 if (t.amount <= 0n) throw new Error('amount must be positive');358 const b = new CellBuilder().bit(0).bit(1).bit(t.bounce).bit(0).uint(0, 2).addr(t.to).coins(t.amount).bit(0).coins(0n).coins(0n).uint(0, 64).uint(0, 32).bit(0);359 if (!t.comment) return b.bit(0).cell(); // empty body, stored in place360 const body = commentCell(t.comment);361 // same placement rule as the reference encoder: in place when it fits, otherwise in a reference362 if (b.avail - 1 >= body.bitLen && b.refCount + body.refs.length <= MAX_CELL_REFS) return b.bit(0).append(body).cell();363 return b.bit(1).ref(body).cell();364}365366/** The v4 signing message. Its representation hash is what ed25519 signs. */367export function tonSigningMessage(t: TonTransfer): TonCell {368 return new CellBuilder().uint(TON_SUBWALLET_ID, 32).uint(t.validUntil, 32).uint(t.seqno, 32).uint(0, 8).uint(TON_SEND_MODE, 8).ref(internalMessage(t)).cell();369}370/** message body: signature, then the signing message */371export function tonSignedBody(signing: TonCell, signature: Uint8Array): TonCell {372 if (signature.length !== 64) throw new Error('bad signature length');373 return new CellBuilder().bytes(signature).append(signing).cell();374}375const extInfo = (accountId: Uint8Array) => new CellBuilder().uint(0b10, 2).uint(0, 2).addr(accountId).coins(0n); // ext_in_msg_info$10 src:addr_none dest import_fee:0376377/** External message to the wallet, optionally carrying its StateInit; laid out exactly as @ton/core's storeMessage does. */378export function tonExternalMessage(accountId: Uint8Array, body: TonCell, stateInit?: TonCell): TonCell {379 const b = extInfo(accountId);380 if (stateInit) {381 b.bit(1);382 if (b.avail - 2 < stateInit.bitLen + body.bitLen) b.bit(1).ref(stateInit);383 else b.bit(0).append(stateInit);384 } else b.bit(0);385 if (b.avail - 1 < body.bitLen || b.refCount + body.refs.length > MAX_CELL_REFS) return b.bit(1).ref(body).cell();386 return b.bit(0).append(body).cell();387}388/**389 * Normalized hash of an external-in message (TEP-467): no source, import fee 0, no StateInit, body in a reference. It is the390 * same whether or not the StateInit was attached and however a node re-packs the envelope; explorers and indexers use it.391 */392export const tonNormalizedHash = (accountId: Uint8Array, body: TonCell): Uint8Array => extInfo(accountId).bit(0).bit(1).ref(body).cell().hash;393394/** Read back an external message built by this module (or the reference SDK): destination, body, whether a StateInit is attached, both hashes. */395export function tonReadExternal(rawBase64: string): { accountId: Uint8Array; body: TonCell; stateInit: boolean; cellHash: string; txid: string } {396 const m = tonParseBoc(unb64(rawBase64));397 // ext_in_msg_info$10, src addr_none$00, dest addr_std$10 without anycast in workchain 0, import_fee 0398 if (readUint(m, 0, 7) !== 0b1000100 || readUint(m, 7, 8) !== 0 || readUint(m, 271, 4) !== 0) throw new Error('not an external message to a basechain wallet');399 const accountId = readBytes(m, 15, 32);400 let at = 275, ref = 0, stateInit = false;401 if (readUint(m, at++, 1)) {402 stateInit = true;403 if (readUint(m, at++, 1)) ref++;404 else { if (readUint(m, at, 5) !== 0b00110) throw new Error('unsupported StateInit'); at += 5; ref += 2; }405 }406 let body: TonCell;407 if (readUint(m, at++, 1)) {408 if (at !== m.bitLen || m.refs.length !== ref + 1) throw new Error('malformed external message');409 body = m.refs[ref]!;410 } else {411 const b = new CellBuilder();412 for (let k = at; k < m.bitLen; k++) b.bit(readUint(m, k, 1));413 for (const r of m.refs.slice(ref)) b.ref(r);414 body = b.cell();415 }416 return { accountId, body, stateInit, cellHash: hexOf(m.hash), txid: hexOf(tonNormalizedHash(accountId, body)) };417}418419const canonicalInt = (name: string, s: string | undefined, min: bigint, max: bigint): bigint => {420 if (s === undefined || !/^(0|[1-9]\d{0,19})$/.test(s)) throw new Error(`x.${name} must be a canonical decimal integer`);421 const v = BigInt(s);422 if (v < min || v > max) throw new Error(`x.${name} out of range`);423 return v;424};425const flag = (name: string, s: string | undefined): boolean => {426 if (s !== '0' && s !== '1') throw new Error(`x.${name} must be "0" or "1"`);427 return s === '1';428};429430export interface TonBuilt {431 /** account id of the sending wallet */432 from: Uint8Array;433 /** the v4 signing message */434 signing: TonCell;435 /** its representation hash: the 32 bytes ed25519 signs */436 hash: Uint8Array;437 /** the wallet's StateInit must be attached (first outgoing transfer) */438 stateInit: boolean;439 describe: Record<string, string>;440}441442/** Rebuild the exact signing message from the intent alone. Throws on anything that is not a plain Toncoin transfer. */443export function buildTonTransfer(i: Intent, net: NetworkDef): TonBuilt {444 if (i.v !== 1) throw new Error('unsupported intent version');445 if (net.family !== 'ton' || i.network !== net.id) throw new Error('not a TON intent');446 if (net.chainRef !== TON_MAINNET_ZEROSTATE) throw new Error('unknown TON network: only the main network is supported');447 if (i.chainRef !== net.chainRef) throw new Error('chain identity mismatch (masterchain zero state)');448 if (i.evm || i.btc || i.sol) throw new Error('unexpected chain section in a TON intent');449 if (i.asset !== 'native' || net.native.id !== 'native') throw new Error('only native Toncoin can be sent on TON in this version');450 if (i.symbol !== net.native.symbol || i.decimals !== net.native.decimals || i.decimals !== 9) throw new Error('asset metadata mismatch');451 const x = i.x;452 if (!x) throw new Error('missing TON parameters');453 const keys = Object.keys(x).sort();454 if (keys.length !== X_KEYS.length || keys.some((k, n) => k !== X_KEYS[n])) throw new Error('unknown or missing TON parameter (expected bounce, sendMode, seqno, stateInit, validUntil)');455456 const from = canonicalId(i.from, 'source'), to = canonicalId(i.to, 'destination');457 if (i.from === i.to) throw new Error('destination equals source');458 const amount = baseUnits(i.amount);459 if (amount <= 0n) throw new Error('amount must be positive');460 if (amount > COINS_MAX) throw new Error('amount out of range');461 if (baseUnits(i.maxFee) <= 0n) throw new Error('fee budget must be positive');462463 const seqno = canonicalInt('seqno', x.seqno, 0n, UINT32_MAX - 1n);464 const validUntil = canonicalInt('validUntil', x.validUntil, 1n, UINT32_MAX - 1n);465 if (validUntil <= BigInt(i.createdAt)) throw new Error('valid_until is not after the intent creation time');466 if (validUntil < BigInt(i.expiresAt)) throw new Error('valid_until ends before the approval window');467 if (validUntil > BigInt(i.createdAt) + BigInt(TON_MAX_VALIDITY_SEC)) throw new Error('valid_until too far in the future');468 if (x.sendMode !== String(TON_SEND_MODE)) throw new Error(`x.sendMode must be "${TON_SEND_MODE}" (pay fees separately, ignore errors)`);469 const bounce = flag('bounce', x.bounce), stateInit = flag('stateInit', x.stateInit);470 if (stateInit && seqno !== 0n) throw new Error('the wallet StateInit can only be attached to its first message (seqno 0)');471 const comment = i.memo === undefined ? undefined : tonCommentBytes(i.memo);472473 const signing = tonSigningMessage({ to, amount, comment, seqno: Number(seqno), validUntil: Number(validUntil), bounce });474 return {475 from, signing, hash: signing.hash, stateInit,476 describe: {477 network: net.name, operation: 'Transfer (native TON, wallet v4R2)', from: i.from, to: i.to, amountNano: amount.toString(), seqno: seqno.toString(),478 validUntil: validUntil.toString(), bounce: bounce ? 'yes' : 'no', sendMode: x.sendMode, deploysWallet: stateInit ? 'yes' : 'no', comment: i.memo ?? '', signingHash: hexOf(signing.hash),479 },480 };481}482483export const tonPlugin: FamilyPlugin = {484 family: 'ton',485 curve: 'ed25519',486 path(net: NetworkDef): string {487 if (net.coinType !== 607) throw new Error('TON derivation requires coin type 607');488 return "m/44'/607'/0'";489 },490 address: (_net: NetworkDef, publicKey: Uint8Array) => tonAddressOf(publicKey),491 normalizeAddress: (_net: NetworkDef, addr: string) => tonNormalizeAddress(addr),492 build(i: Intent, net: NetworkDef) {493 const b = buildTonTransfer(i, net);494 return { signDocs: [b.hash], describe: b.describe };495 },496 sign(i: Intent, net: NetworkDef, key: KeyHandle): Signed {497 const b = buildTonTransfer(i, net);498 if (key.curve !== 'ed25519' || !key.signMessage || key.publicKey.length !== 32) throw new Error('TON needs an ed25519 key');499 // the address commits to the code, the subwallet id and this public key500 if (tonAddressOf(key.publicKey) !== i.from) throw new Error('signing key does not control the source wallet');501 const sig = key.signMessage(b.hash);502 if (sig.length !== 64 || !ed25519.verify(sig, b.hash, key.publicKey)) throw new Error('signature self-check failed');503 const body = tonSignedBody(b.signing, sig);504 const message = tonExternalMessage(b.from, body, b.stateInit ? tonStateInit(key.publicKey) : undefined);505 return { raw: b64(tonSerializeBoc(message)), txid: hexOf(tonNormalizedHash(b.from, body)) };506 },507};508