Repository Public
1import { describe, expect, it } from 'vitest';2import fc from 'fast-check';3import { ed25519 } from '@noble/curves/ed25519.js';4import { hmac } from '@noble/hashes/hmac.js';5import { sha512 } from '@noble/hashes/sha2.js';6import { Address, beginCell, Cell, comment, external, internal, loadMessage, loadMessageRelaxed, SendMode, storeMessage, storeStateInit } from '@ton/core';7import { mnemonicToPrivateKey, signVerify } from '@ton/crypto';8import { WalletContractV4 } from '@ton/ton';9import {10 buildTonTransfer, canonicalBytes, DEFAULT_POLICY, intentHash, isValidAddress, NETWORKS, normalizeAddress, pluginFor, toHex, TON_MAINNET_ZEROSTATE, TON_MAX_VALIDITY_SEC,11 TON_SUBWALLET_ID, TON_V4R2_CODE_BOC, TON_V4R2_CODE_HASH, tonAccountId, tonAddressOf, tonCommentBytes, tonFormatAddress, tonNormalizeAddress, tonParseAddress, tonParseBoc,12 tonPlugin, tonRawAddress, tonReadExternal, tonSerializeBoc, tonStateInit, tonV4R2Code, tonWalletData,13 type Intent, type KeyHandle, type NetworkDef,14} from '../src/index.ts';1516const net = NETWORKS.ton as NetworkDef;17const hexToBytes = (h: string) => Uint8Array.from(Buffer.from(h, 'hex'));18const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';19// BIP-39 seed of the public test mnemonic (empty passphrase) — the published BIP-39 test vector20const SEED = hexToBytes('5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4');2122/** SLIP-0010 ed25519 (hardened only), independent of the signer's implementation. */23function slip10(seed: Uint8Array, path: string): Uint8Array {24 let I = hmac(sha512, new TextEncoder().encode('ed25519 seed'), seed);25 for (const seg of path.split('/').slice(1)) {26 if (!seg.endsWith("'")) throw new Error('hardened only');27 const data = new Uint8Array(37);28 data.set(I.subarray(0, 32), 1);29 new DataView(data.buffer).setUint32(33, (Number(seg.slice(0, -1)) | 0x80000000) >>> 0);30 I = hmac(sha512, I.subarray(32), data);31 }32 return I.slice(0, 32);33}34const PRIV = slip10(SEED, tonPlugin.path(net));35const PUB = ed25519.getPublicKey(PRIV);36const key: KeyHandle = { curve: 'ed25519', publicKey: PUB, signMessage: (m) => ed25519.sign(m, PRIV) };37/** the official SDK's wallet v4R2 for the same public key */38const sdkWallet = WalletContractV4.create({ workchain: 0, publicKey: Buffer.from(PUB) });39const FROM = 'UQAzWZa6nM5mJev91wGc7VCSfBoIsYRqKJpV78N8Add9-RKY';40const OTHER_PUB = ed25519.getPublicKey(slip10(SEED, "m/44'/607'/1'"));41const TO = tonAddressOf(OTHER_PUB);4243const now = 1_800_000_000;44const base: Intent = {45 v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', createdAt: now, expiresAt: now + 120, policyVersion: 3,46 network: 'ton', chainRef: TON_MAINNET_ZEROSTATE, asset: 'native', symbol: 'TON', decimals: 9, amount: '1500000000', from: FROM, to: TO, maxFee: '5000000',47 x: { seqno: '7', validUntil: String(now + 300), bounce: '1', sendMode: '3', stateInit: '0' },48};49const withX = (x: Record<string, string>, over: Partial<Intent> = {}): Intent => ({ ...base, ...over, x: { ...base.x!, ...x } });50const deploy = (over: Partial<Intent> = {}) => withX({ seqno: '0', stateInit: '1', bounce: '0' }, over);5152/** The same transfer built by the official SDK (@ton/ton WalletContractV4 + @ton/core) from the same fields, signed by the same key. */53async function sdk(i: Intent): Promise<{ signingHash: Buffer; body: Cell; message: Cell; normalized: Buffer }> {54 const x = i.x!;55 let signingHash: Buffer = Buffer.alloc(0);56 const body = await sdkWallet.createTransfer({57 seqno: Number(x.seqno), timeout: Number(x.validUntil), sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,58 signer: async (c: Cell) => { signingHash = c.hash(); return Buffer.from(ed25519.sign(c.hash(), PRIV)); },59 messages: [internal({ to: Address.parse(i.to), value: BigInt(i.amount), bounce: x.bounce === '1', body: i.memo })],60 });61 // what the SDK's provider sends: external({ to, init: needed ? init : null, body }) → storeMessage → toBoc()62 const message = beginCell().store(storeMessage(external({ to: sdkWallet.address, init: x.stateInit === '1' ? sdkWallet.init : null, body }))).endCell();63 const normalized = beginCell().store(storeMessage(external({ to: sdkWallet.address, body }), { forceRef: true })).endCell().hash();64 return { signingHash, body, message, normalized };65}6667describe('ton: registration', () => {68 it('is registered with the pinned identity, derivation, limits and two sources', () => {69 expect(net).toMatchObject({ id: 'ton', family: 'ton', coinType: 607, memo: 'text', chainRef: '17a3a92992aabea785a7a090985a265cd31f323d849da51239737e321fb05569', native: { id: 'native', symbol: 'TON', decimals: 9 }, tokens: [] });70 // the same hash as in the network's global config (base64 there)71 expect(Buffer.from('F6OpKZKqvqeFp6CQmFomXNMfMj2EnaUSOXN+Mh+wVWk=', 'base64').toString('hex')).toBe(net.chainRef);72 expect(net.activation).toBeUndefined();73 expect(net.rpc).toHaveLength(2);74 expect(new Set(net.rpc.map((u) => new URL(u).host)).size).toBe(2);75 expect(pluginFor(net)).toBe(tonPlugin);76 expect([tonPlugin.family, tonPlugin.curve, tonPlugin.path(net)]).toEqual(['ton', 'ed25519', "m/44'/607'/0'"]);77 expect(() => tonPlugin.path({ ...net, coinType: 148 })).toThrow(/607/);78 expect([DEFAULT_POLICY.perTx['ton:TON'], DEFAULT_POLICY.daily['ton:TON'], DEFAULT_POLICY.maxFee.ton]).toEqual(['45000000000', '90000000000', '50000000']);79 });80});8182describe('ton: wallet contract and addresses', () => {83 it('embeds the wallet v4R2 code of the official SDK, with the well-known code hash', () => {84 expect(toHex(tonV4R2Code().hash)).toBe(TON_V4R2_CODE_HASH);85 expect(sdkWallet.init.code.hash().toString('hex')).toBe(TON_V4R2_CODE_HASH);86 expect(TON_V4R2_CODE_HASH).toBe('feb5ff6820e2ff0d9483e7e0d62c817d846789fb4ae580c878866d959dabd5c0');87 expect(Buffer.from(tonSerializeBoc(tonV4R2Code())).equals(sdkWallet.init.code.toBoc())).toBe(true);88 expect(sdkWallet.walletId).toBe(TON_SUBWALLET_ID);89 expect(TON_V4R2_CODE_BOC.length).toBeGreaterThan(900);90 });91 it('derives the same address as WalletContractV4.create for the public test mnemonic at m/44\'/607\'/0\'', () => {92 const st = tonStateInit(PUB);93 expect(toHex(st.hash)).toBe(beginCell().store(storeStateInit(sdkWallet.init)).endCell().hash().toString('hex'));94 expect(toHex(st.refs[1]!.hash)).toBe(sdkWallet.init.data.hash().toString('hex'));95 expect(toHex(tonAccountId(PUB))).toBe(sdkWallet.address.hash.toString('hex'));96 const addr = tonPlugin.address(net, PUB);97 expect(addr).toBe(sdkWallet.address.toString({ bounceable: false, urlSafe: true, testOnly: false }));98 expect(addr).toBe(FROM); // pinned: "UQ…" = non-bounceable99 expect(tonRawAddress(addr)).toBe('0:335996ba9cce6625ebfdd7019ced50927c1a08b1846a289a55efc37c01d77df9');100 expect(tonWalletData(st.refs[1]!)).toEqual({ seqno: 0, subwalletId: TON_SUBWALLET_ID, publicKey: PUB, plugins: false });101 });102 it('is NOT the address a TON-native wallet (Tonkeeper) derives from the same words: the key derivation differs', async () => {103 const native = await mnemonicToPrivateKey(MNEMONIC.split(' ')); // TON's own scheme (PBKDF2 over the phrase, no BIP-39 seed, no path)104 const theirs = WalletContractV4.create({ workchain: 0, publicKey: native.publicKey }).address.toString({ bounceable: false });105 expect(theirs).not.toBe(FROM);106 expect(Buffer.from(PUB).equals(native.publicKey)).toBe(false);107 });108 it('formats and parses like the official SDK (property)', () => {109 fc.assert(fc.property(fc.uint8Array({ minLength: 32, maxLength: 32 }).filter((b) => b.some((v) => v !== 0)), (id) => {110 const a = new Address(0, Buffer.from(id));111 const canonical = a.toString({ bounceable: false, urlSafe: true });112 expect(tonFormatAddress(id)).toBe(canonical);113 expect(tonFormatAddress(id, { bounceable: true })).toBe(a.toString({ bounceable: true, urlSafe: true }));114 for (const form of [canonical, a.toString({ bounceable: true, urlSafe: true }), a.toString({ bounceable: true, urlSafe: false }), a.toString({ bounceable: false, urlSafe: false }), a.toRawString(), a.toRawString().toUpperCase().replace('0:', '0:')]) {115 expect(tonNormalizeAddress(form)).toBe(canonical);116 expect(toHex(tonParseAddress(form))).toBe(toHex(id));117 }118 expect(Address.parse(tonFormatAddress(id)).equals(a)).toBe(true);119 }), { numRuns: 200 });120 });121 it('accepts EQ…, UQ… and raw destinations and normalizes them to one form; refuses everything else', () => {122 const a = Address.parse(TO);123 const eq = a.toString({ bounceable: true }), uq = a.toString({ bounceable: false });124 expect([eq.slice(0, 2), uq.slice(0, 2)]).toEqual(['EQ', 'UQ']);125 for (const form of [eq, uq, a.toRawString()]) expect(normalizeAddress('ton', form)).toBe(TO);126 expect(normalizeAddress('ton', ` ${eq} `)).toBe(TO);127 expect(isValidAddress('ton', eq)).toBe(true);128 const flip = (s: string, k: number) => s.slice(0, k) + (s[k] === 'A' ? 'B' : 'A') + s.slice(k + 1);129 const bad: [string, RegExp][] = [130 [a.toString({ bounceable: true, testOnly: true }), /testnet/], // kQ…131 [a.toString({ bounceable: false, testOnly: true }), /testnet/], // 0Q…132 [new Address(-1, a.hash).toString(), /workchain 0/], // masterchain, user-friendly133 [`-1:${a.hash.toString('hex')}`, /workchain 0/],134 [`1:${a.hash.toString('hex')}`, /workchain 0/],135 [flip(eq, 20), /checksum/],136 [flip(eq, 47), /checksum|encoding/],137 [eq.slice(0, 47), /48 characters/],138 [eq + 'A', /48 characters/],139 [`0:${a.hash.toString('hex').slice(2)}`, /48 characters/],140 [`0:${'0'.repeat(64)}`, /zero address/],141 [new Address(0, Buffer.alloc(32)).toString(), /zero address/],142 ['-_'.repeat(12) + '+/'.repeat(12), /mixed base64/],143 ['GB3JDWCQJCWMJ3IILWIGDTQJJC5567PGVEVXSCVPEQOTDN64VJBDQBYX', /48 characters/],144 ['', /48 characters/],145 ];146 for (const [addr, re] of bad) { expect(() => tonPlugin.normalizeAddress(net, addr), addr).toThrow(re); expect(isValidAddress('ton', addr)).toBe(false); }147 // a valid checksum over an unknown tag byte is still refused148 const raw = Buffer.from(eq.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); raw[0] = 0x12;149 let crc = 0; for (const byte of raw.subarray(0, 34)) { crc ^= byte << 8; for (let k = 0; k < 8; k++) crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; }150 raw[34] = crc >> 8; raw[35] = crc & 0xff;151 expect(() => tonParseAddress(raw.toString('base64url'))).toThrow(/flags/);152 });153});154155describe('ton: cells and bags of cells equal @ton/core', () => {156 /** a random cell DAG built with the SDK: each cell may reference up to four earlier ones (so sub-cells are shared), the last is the root */157 const tree = fc.array(fc.tuple(fc.array(fc.boolean(), { maxLength: 1023 }), fc.array(fc.nat(), { maxLength: 4 })), { minLength: 1, maxLength: 12 }).map((specs) => {158 const cells: Cell[] = [];159 for (const [bits, refs] of specs) {160 const b = beginCell();161 for (const v of bits) b.storeBit(v);162 if (cells.length > 0) for (const r of refs) b.storeRef(cells[r % cells.length]!);163 cells.push(b.endCell());164 }165 return cells[cells.length - 1]!;166 });167 it('hashes, parses and serializes any ordinary cell tree byte-for-byte like the SDK (property)', () => {168 fc.assert(fc.property(tree, fc.boolean(), (c, idx) => {169 const ours = tonParseBoc(c.toBoc({ idx }));170 expect(toHex(ours.hash)).toBe(c.hash().toString('hex'));171 expect(ours.depth).toBe(c.depth());172 expect(Buffer.from(tonSerializeBoc(ours)).equals(c.toBoc())).toBe(true);173 }), { numRuns: 150 });174 });175 it('refuses exotic cells, truncated or corrupted containers and several roots', () => {176 const lib = beginCell().storeUint(2, 8).storeBuffer(Buffer.alloc(32, 7)).endCell({ exotic: true });177 expect(() => tonParseBoc(beginCell().storeRef(lib).endCell().toBoc())).toThrow(/exotic/);178 const good = beginCell().storeUint(5, 13).storeRef(beginCell().storeUint(1, 8).endCell()).endCell().toBoc();179 expect(toHex(tonParseBoc(good).hash)).toBe(Cell.fromBoc(good)[0]!.hash().toString('hex'));180 expect(() => tonParseBoc(good.subarray(0, good.length - 1))).toThrow();181 const corrupt = Buffer.from(good); corrupt[corrupt.length - 6]! ^= 1;182 expect(() => tonParseBoc(corrupt)).toThrow(/checksum/);183 const magic = Buffer.from(good); magic[0] = 0;184 expect(() => tonParseBoc(magic)).toThrow(/not a bag of cells/);185 const backRef = Buffer.from(beginCell().storeRef(beginCell().endCell()).endCell().toBoc({ crc32: false })); backRef[backRef.length - 3] = 0; // root pointing at itself186 expect(() => tonParseBoc(backRef)).toThrow(/reference/);187 expect(() => tonParseBoc(new Uint8Array(0))).toThrow();188 // parsing must not modify the caller's buffer189 const copy = Buffer.from(good); tonParseBoc(good); expect(good.equals(copy)).toBe(true);190 });191});192193describe('ton: the transfer equals the official SDK', () => {194 const cases: [string, Intent][] = [195 ['plain transfer, bounceable', base],196 ['non-bounceable', withX({ bounce: '0' })],197 ['text comment', { ...base, memo: 'invoice 4242' }],198 ['numeric comment (exchange memo)', { ...base, memo: '109284756' }],199 ['multi-byte comment', { ...base, memo: 'café ☕ – 送金 😀' }],200 ['comment of 120 characters (continued in a second cell)', { ...base, memo: 'x'.repeat(120) }],201 ['longest multi-byte comment (three cells)', { ...base, memo: '😀'.repeat(60) }],202 ['first transfer: deploys the wallet (StateInit attached, seqno 0)', deploy()],203 ['first transfer with a long comment', deploy({ memo: 'é'.repeat(120) })],204 ['seqno 0 without StateInit (wallet deployed by someone else)', withX({ seqno: '0' })],205 ['1 nanoton', { ...base, amount: '1' }],206 ['largest amount of the policy and beyond', { ...base, amount: '5000000000000000000' }],207 ['highest seqno', withX({ seqno: '4294967294' })],208 ];209 for (const [name, i] of cases) it(name, async () => {210 const ref = await sdk(i);211 const built = tonPlugin.build(i, net);212 expect(built.signDocs).toHaveLength(1);213 expect(toHex(built.signDocs[0]!)).toBe(ref.signingHash.toString('hex')); // what is signed: the cell hash of the v4 signing message214 const signed = tonPlugin.sign(i, net, key);215 expect(Buffer.from(signed.raw, 'base64').equals(ref.message.toBoc())).toBe(true); // byte-identical bag of cells216 expect(signed.txid).toBe(ref.normalized.toString('hex'));217 const back = tonReadExternal(signed.raw);218 expect([back.cellHash, back.txid, back.stateInit, toHex(back.accountId), toHex(back.body.hash)])219 .toEqual([ref.message.hash().toString('hex'), signed.txid, i.x!.stateInit === '1', sdkWallet.address.hash.toString('hex'), ref.body.hash().toString('hex')]);220 });221222 it('property: any valid field combination is byte-identical', async () => {223 const memo = fc.option(fc.string({ unit: 'grapheme', minLength: 1, maxLength: 40 }).filter((s) => s.length <= 120 && new TextDecoder().decode(new TextEncoder().encode(s)) === s), { nil: undefined });224 await fc.assert(fc.asyncProperty(fc.bigInt({ min: 1n, max: (1n << 120n) - 1n }), fc.integer({ min: 0, max: 0xfffffffe }), fc.integer({ min: 120, max: TON_MAX_VALIDITY_SEC }), fc.boolean(), fc.boolean(), memo,225 fc.uint8Array({ minLength: 32, maxLength: 32 }).filter((b) => b.some((v) => v !== 0)), async (amount, seqno, validity, bounce, init, m, dest) => {226 const i: Intent = { ...base, amount: amount.toString(), to: tonFormatAddress(dest), ...(m === undefined ? {} : { memo: m }),227 x: { seqno: init ? '0' : String(seqno), validUntil: String(now + validity), bounce: bounce ? '1' : '0', sendMode: '3', stateInit: init ? '1' : '0' } };228 const ref = await sdk(i), signed = tonPlugin.sign(i, net, key);229 expect(toHex(tonPlugin.build(i, net).signDocs[0]!)).toBe(ref.signingHash.toString('hex'));230 expect(Buffer.from(signed.raw, 'base64').equals(ref.message.toBoc())).toBe(true);231 expect(signed.txid).toBe(ref.normalized.toString('hex'));232 }), { numRuns: 120 });233 });234235 it('the SDK decodes the signed message to exactly the approved fields, and the signature verifies', () => {236 for (const i of [{ ...base, memo: 'hello TON' }, deploy()]) {237 const signed = tonPlugin.sign(i, net, key);238 const msg = loadMessage(Cell.fromBase64(signed.raw).beginParse());239 expect(msg.info.type).toBe('external-in');240 if (msg.info.type !== 'external-in') throw new Error('unreachable');241 expect([msg.info.dest.toString({ bounceable: false }), msg.info.src, msg.info.importFee]).toEqual([i.from, null, 0n]);242 if (i.x!.stateInit === '1') {243 expect(msg.init!.code!.hash().toString('hex')).toBe(TON_V4R2_CODE_HASH);244 expect(msg.init!.data!.equals(sdkWallet.init.data)).toBe(true);245 expect([msg.init!.splitDepth ?? null, msg.init!.special ?? null, msg.init!.libraries?.size ?? 0]).toEqual([null, null, 0]);246 // the address is the hash of exactly this StateInit247 expect(beginCell().store(storeStateInit(msg.init!)).endCell().hash().equals(msg.info.dest.hash)).toBe(true);248 } else expect(msg.init ?? null).toBeNull();249 const s = msg.body.beginParse();250 const signature = s.loadBuffer(64);251 const signingHash = beginCell().storeSlice(s).endCell().hash(); // the rest of the body is the signing message252 expect(signingHash.toString('hex')).toBe(toHex(tonPlugin.build(i, net).signDocs[0]!));253 expect(ed25519.verify(signature, signingHash, PUB)).toBe(true);254 expect(signVerify(signingHash, signature, Buffer.from(PUB))).toBe(true); // @ton/crypto (tweetnacl), what the contract's check_signature does255 expect(signVerify(signingHash, signature, Buffer.from(OTHER_PUB))).toBe(false);256 expect([s.loadUint(32), s.loadUint(32), s.loadUint(32), s.loadUint(8), s.loadUint(8)]).toEqual([TON_SUBWALLET_ID, Number(i.x!.validUntil), Number(i.x!.seqno), 0, 3]);257 expect([s.remainingBits, s.remainingRefs]).toEqual([0, 1]); // one outgoing message, nothing else258 const out = loadMessageRelaxed(s.loadRef().beginParse());259 if (out.info.type !== 'internal') throw new Error('expected an internal message');260 expect([out.info.dest.toString({ bounceable: false }), out.info.value.coins, out.info.value.other ?? null, out.info.bounce, out.info.bounced, out.info.ihrDisabled, out.info.src ?? null, out.init ?? null])261 .toEqual([i.to, BigInt(i.amount), null, i.x!.bounce === '1', false, true, null, null]);262 expect(out.body.equals(i.memo === undefined ? Cell.EMPTY : comment(i.memo))).toBe(true);263 }264 });265266 it('describes the transfer for the approval screen', () => {267 expect(tonPlugin.build(deploy({ memo: 'hi' }), net).describe).toMatchObject({ operation: 'Transfer (native TON, wallet v4R2)', from: FROM, to: TO, amountNano: '1500000000', seqno: '0', validUntil: String(now + 300), bounce: 'no', sendMode: '3', deploysWallet: 'yes', comment: 'hi' });268 });269});270271describe('ton: signing', () => {272 it('refuses a key that does not control the source wallet, the wrong curve, and a bad signature', () => {273 expect(() => tonPlugin.sign(base, net, { curve: 'ed25519', publicKey: OTHER_PUB, signMessage: key.signMessage })).toThrow(/does not control/);274 expect(() => tonPlugin.sign(base, net, { curve: 'secp256k1', publicKey: PUB, signDigest: () => ({ compact: new Uint8Array(64), recovery: 0, der: new Uint8Array(70) }) })).toThrow(/ed25519/);275 expect(() => tonPlugin.sign(base, net, { curve: 'ed25519', publicKey: PUB })).toThrow(/ed25519/);276 expect(() => tonPlugin.sign(base, net, { ...key, signMessage: () => new Uint8Array(64) })).toThrow(/self-check/);277 expect(() => tonPlugin.sign(base, net, { ...key, signMessage: (m) => ed25519.sign(m, slip10(SEED, "m/44'/607'/1'")) })).toThrow(/self-check/);278 expect(() => tonPlugin.sign({ ...base, from: TO, to: FROM }, net, key)).toThrow(/does not control/);279 });280 it('is deterministic', () => {281 expect(tonPlugin.sign(base, net, key)).toEqual(tonPlugin.sign(structuredClone(base), net, key));282 });283});284285describe('ton: fail closed', () => {286 const eq = Address.parse(TO).toString({ bounceable: true });287 const bad: [string, Intent, RegExp][] = [288 ['another chain identity (testnet zero state)', { ...base, chainRef: '823f81f306ff02694f935cf5021548e3ce2b86b529812af6a12148879e95a128' }, /chain identity/],289 ['another network id', { ...base, network: 'stellar' }, /not a TON intent/],290 ['a jetton', { ...base, asset: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs' }, /only native/],291 ['wrong symbol', { ...base, symbol: 'USDT' }, /metadata/],292 ['wrong decimals', { ...base, decimals: 6 }, /metadata/],293 ['destination in bounceable form (not canonical)', { ...base, to: eq }, /canonical/],294 ['destination in raw form', { ...base, to: tonRawAddress(TO) }, /canonical/],295 ['destination in non-URL-safe base64', { ...base, to: tonFormatAddress(new Uint8Array(32).fill(0xfb)).replace(/-/g, '+').replace(/_/g, '/') }, /canonical/],296 ['testnet destination', { ...base, to: Address.parse(TO).toString({ bounceable: false, testOnly: true }) }, /testnet/],297 ['masterchain destination', { ...base, to: new Address(-1, Address.parse(TO).hash).toString({ bounceable: false }) }, /workchain 0/],298 ['invalid destination', { ...base, to: TO.slice(0, 47) + (TO.endsWith('A') ? 'B' : 'A') }, /TON address/],299 ['source not canonical', { ...base, from: Address.parse(FROM).toString({ bounceable: true }) }, /canonical/],300 ['destination equals source', { ...base, to: FROM }, /equals source/],301 ['zero amount', { ...base, amount: '0' }, /positive/],302 ['non-canonical amount', { ...base, amount: '01' }, /base-unit/],303 ['amount beyond 120 bits', { ...base, amount: (1n << 120n).toString() }, /out of range/],304 ['no fee budget', { ...base, maxFee: '0' }, /fee budget/],305 ['missing parameters', { ...base, x: undefined }, /missing TON parameters/],306 ['missing key', { ...base, x: { seqno: '7', validUntil: String(now + 300), bounce: '1', sendMode: '3' } }, /unknown or missing/],307 ['unknown key', withX({ subwallet: '1' }), /unknown or missing/],308 ['unknown key replacing one', { ...base, x: { seqno: '7', validUntil: String(now + 300), bounce: '1', sendMode: '3', stateinit: '0' } }, /unknown or missing/],309 ['non-canonical seqno', withX({ seqno: '07' }), /canonical/],310 ['negative seqno', withX({ seqno: '-1' }), /canonical/],311 ['seqno out of range', withX({ seqno: '4294967295' }), /out of range/],312 ['valid_until not after creation', withX({ validUntil: String(now) }), /not after/],313 ['valid_until before the approval window ends', withX({ validUntil: String(now + 119) }), /before the approval window/],314 ['valid_until too far', withX({ validUntil: String(now + TON_MAX_VALIDITY_SEC + 1) }), /too far/],315 ['valid_until of the SDK\'s "never expires" convention', withX({ validUntil: '4294967295' }), /out of range/],316 ['send mode 128 (send the whole balance)', withX({ sendMode: '128' }), /sendMode/],317 ['send mode 130', withX({ sendMode: '130' }), /sendMode/],318 ['send mode 35 (destroy the wallet if empty)', withX({ sendMode: '35' }), /sendMode/],319 ['send mode 1', withX({ sendMode: '1' }), /sendMode/],320 ['send mode 0', withX({ sendMode: '0' }), /sendMode/],321 ['bounce not a flag', withX({ bounce: 'true' }), /x\.bounce/],322 ['stateInit not a flag', withX({ stateInit: 'yes' }), /x\.stateInit/],323 ['StateInit on a later message', withX({ stateInit: '1' }), /first message/],324 ['empty comment', { ...base, memo: '' }, /empty comment/],325 ['comment with a lone surrogate', { ...base, memo: 'a\ud800b' }, /Unicode/],326 ['an EVM section', { ...base, evm: { nonce: 1, gasLimit: '21000', maxFeePerGas: '1', maxPriorityFeePerGas: '1' } }, /unexpected chain section/],327 ['intent version', { ...base, v: 2 as unknown as 1 }, /version/],328 ];329 for (const [name, i, re] of bad) it(name, () => {330 expect(() => tonPlugin.build(i, net)).toThrow(re);331 expect(() => tonPlugin.sign(i, net, key)).toThrow(re);332 });333 it('refuses a network definition that is not the pinned main network', () => {334 const testnet = { ...net, chainRef: '823f81f306ff02694f935cf5021548e3ce2b86b529812af6a12148879e95a128' };335 expect(() => buildTonTransfer({ ...base, chainRef: testnet.chainRef }, testnet)).toThrow(/main network/);336 expect(() => buildTonTransfer(base, { ...net, family: 'stellar' })).toThrow(/not a TON intent/);337 expect(() => buildTonTransfer(base, { ...net, native: { ...net.native, decimals: 6 } })).toThrow(/metadata/);338 });339 it('comment bytes: UTF-8, never empty', () => {340 expect(Buffer.from(tonCommentBytes('é☕')).toString('hex')).toBe('c3a9e29895');341 expect(() => tonCommentBytes('')).toThrow();342 });343 it('the intent schema and hash accept the TON section and bind every field', () => {344 expect(canonicalBytes(base).length).toBeGreaterThan(100);345 expect(() => canonicalBytes({ ...base, x: undefined })).toThrow(/chain section/);346 const h = toHex(intentHash(base));347 for (const k of Object.keys(base.x!)) expect(toHex(intentHash(withX({ [k]: k === 'validUntil' ? String(now + 301) : k === 'seqno' ? '8' : k === 'sendMode' ? '1' : base.x![k] === '1' ? '0' : '1' }))), k).not.toBe(h);348 expect(toHex(intentHash({ ...base, memo: 'a' }))).not.toBe(h);349 });350 it('changing any intent field changes the signed bytes or throws', () => {351 const ref = tonPlugin.sign(base, net, key);352 const changed: Partial<Intent>[] = [353 { amount: '1500000001' }, { to: tonFormatAddress(new Uint8Array(32).fill(9)) }, { memo: 'a' }, { from: TO }, { chainRef: 'x' }, { network: 'tron' }, { asset: 'x' }, { symbol: 'XLM' }, { decimals: 8 },354 { x: { ...base.x!, seqno: '8' } }, { x: { ...base.x!, validUntil: String(now + 301) } }, { x: { ...base.x!, bounce: '0' } }, { x: { ...base.x!, sendMode: '2' } }, { x: { ...base.x!, stateInit: '1' } },355 { expiresAt: now + 400 }, { createdAt: now + 400 },356 ];357 for (const c of changed) {358 let out: { raw: string; txid: string } | undefined;359 try { out = tonPlugin.sign({ ...base, ...c }, net, key); } catch { continue; }360 expect(out.raw, JSON.stringify(c)).not.toBe(ref.raw);361 expect(out.txid, JSON.stringify(c)).not.toBe(ref.txid);362 }363 // x.stateInit changes the envelope (not the signature, which never covers the StateInit, nor the normalized id)364 const a = tonPlugin.sign(withX({ seqno: '0' }), net, key), b = tonPlugin.sign(withX({ seqno: '0', stateInit: '1' }), net, key);365 expect(a.raw).not.toBe(b.raw);366 expect(a.txid).toBe(b.txid);367 expect(tonReadExternal(a.raw).cellHash).not.toBe(tonReadExternal(b.raw).cellHash);368 // TON has no fee field on the wire: maxFee is bound by the approval (intent hash) only369 expect(tonPlugin.sign({ ...base, maxFee: '6000000' }, net, key).raw).toBe(ref.raw);370 expect(toHex(intentHash({ ...base, maxFee: '6000000' }))).not.toBe(toHex(intentHash(base)));371 });372});373