Repository Public
1// Sui (family "sui"): native SUI only, one fixed transaction shape.2// Pure module: no network access, no side effects. The BCS `TransactionData` (V1) is written by hand from the approved intent3// and proven byte-for-byte against @mysten/sui in test/sui.test.ts.4//5// The only transaction this module can produce is the standard "pay SUI" programmable transaction:6// inputs [ Pure(u64 amount), Pure(address recipient) ]7// commands [ SplitCoins(GasCoin, [Input 0]), TransferObjects([NestedResult(0, 0)], Input 1) ]8// paid by the sender's own SUI coins (they are merged into the gas coin by the protocol, the amount is split from it).9//10// References: sui-types `TransactionData` / `ProgrammableTransaction` (crates/sui-types/src/transaction.rs), intent signing11// (crates/shared-crypto/src/intent.rs), address and signature formats (docs.sui.io "Signatures", "Cryptography").12// Intent parameters (x), all required, nothing else accepted:13// payment the gas payment coins, "objectId:version:digest" joined by "," (1..SUI_MAX_GAS_COINS, 0x-hex id, decimal14// version, base58 digest) — exact object references: the transaction is void once any of them has changed15// gasPrice price per gas unit in MIST (the epoch's reference gas price), decimal u6416// gasBudget the most the transaction may charge, in MIST, decimal u64, <= intent.maxFee17// expiration last epoch in which the transaction may execute (TransactionExpiration::Epoch), decimal u6418import { ed25519 } from '@noble/curves/ed25519.js';19import { blake2b } from '@noble/hashes/blake2.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/** Digest of the mainnet genesis checkpoint (checkpoint 0). Its first four bytes, in hex, are the "chain identifier" 35834a8a. */27export const SUI_MAINNET_GENESIS = '4btiuiMPvEENsttpZC7CZ53DruC3MAgfznDbASZ7DR6S';28/** most coins this wallet puts in one gas payment (the protocol allows 256) */29export const SUI_MAX_GAS_COINS = 32;30/** protocol ceiling for a gas budget (max_tx_gas), in MIST: 50 SUI */31export const SUI_MAX_GAS_BUDGET = 50_000_000_000n;32const U64_MAX = (1n << 64n) - 1n;33const X_KEYS = ['expiration', 'gasBudget', 'gasPrice', 'payment'];34const enc = new TextEncoder();3536const hexOf = (b: Uint8Array) => Array.from(b, (v) => v.toString(16).padStart(2, '0')).join('');37const b64 = (b: Uint8Array) => btoa(String.fromCharCode(...b));38const unb64 = (s: string): Uint8Array => {39 if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) throw new Error('invalid base64');40 return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));41};42function concat(...a: Uint8Array[]): Uint8Array {43 const out = new Uint8Array(a.reduce((n, p) => n + p.length, 0));44 let o = 0;45 for (const p of a) { out.set(p, o); o += p.length; }46 return out;47}48const blake2b256 = (m: Uint8Array) => blake2b(m, { dkLen: 32 });4950/** Canonical Sui address / object id: "0x" + 64 lowercase hex digits. Returns its 32 bytes; throws on anything else. */51export function suiAddressBytes(addr: string): Uint8Array {52 if (typeof addr !== 'string' || !/^0x[0-9a-f]{64}$/.test(addr)) throw new Error('invalid Sui address: expected 0x followed by 64 lowercase hex digits');53 const out = new Uint8Array(32);54 for (let k = 0; k < 32; k++) out[k] = parseInt(addr.slice(2 + 2 * k, 4 + 2 * k), 16);55 return out;56}57/** Address of an ed25519 public key: blake2b-256(0x00 || key), the flag byte 0x00 naming the ed25519 scheme. */58export function suiAddressFromPublicKey(publicKey: Uint8Array): string {59 if (publicKey.length !== 32) throw new Error('ed25519 public key must be 32 bytes');60 return '0x' + hexOf(blake2b256(concat(new Uint8Array([0x00]), publicKey)));61}62/**63 * A destination this wallet accepts: a full 32-byte address. Short forms ("0x2") are refused rather than padded, and so are the64 * addresses below 2^16, which the protocol reserves for its own packages and system objects (0x1, 0x2, 0x5, 0x6, …): coins sent65 * there cannot be recovered.66 */67function checkDestination(addr: string): Uint8Array {68 const b = suiAddressBytes(addr);69 if (b.subarray(0, 30).every((v) => v === 0)) throw new Error('reserved Sui address (zero address or system object)');70 return b;71}7273export interface SuiObjectRef { objectId: string; version: bigint; digest: string }74const canonicalU64 = (name: string, s: string | undefined, min: bigint): bigint => {75 if (s === undefined || !/^(0|[1-9]\d{0,19})$/.test(s)) throw new Error(`${name} must be a canonical decimal integer`);76 const v = BigInt(s);77 if (v < min || v > U64_MAX) throw new Error(`${name} out of range`);78 return v;79};80function digestBytes(d: string): Uint8Array {81 if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(d)) throw new Error('invalid object digest');82 let raw: Uint8Array;83 try { raw = base58.decode(d); } catch { throw new Error('invalid object digest'); }84 if (raw.length !== 32 || base58.encode(raw) !== d) throw new Error('invalid object digest');85 return raw;86}87/** Parse x.payment. Strict: canonical ids, versions and digests, no duplicates, 1..SUI_MAX_GAS_COINS entries. */88export function suiParsePayment(s: string | undefined): SuiObjectRef[] {89 if (typeof s !== 'string' || s.length === 0) throw new Error('x.payment is missing');90 const parts = s.split(',');91 if (parts.length > SUI_MAX_GAS_COINS) throw new Error('too many gas payment coins');92 const seen = new Set<string>();93 return parts.map((p) => {94 const f = p.split(':');95 if (f.length !== 3) throw new Error('x.payment entries must be objectId:version:digest');96 suiAddressBytes(f[0]!);97 digestBytes(f[2]!);98 if (seen.has(f[0]!)) throw new Error('duplicate gas payment coin');99 seen.add(f[0]!);100 return { objectId: f[0]!, version: canonicalU64('x.payment version', f[1], 1n), digest: f[2]! };101 });102}103export const suiFormatPayment = (refs: SuiObjectRef[]): string => refs.map((r) => `${r.objectId}:${r.version}:${r.digest}`).join(',');104105// ---- BCS writer (little-endian integers, ULEB128 lengths) ----106const u64le = (v: bigint): Uint8Array => { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, v, true); return b; };107const uleb = (n: number): Uint8Array => { const out: number[] = []; do { const b = n & 0x7f; n >>>= 7; out.push(n ? b | 0x80 : b); } while (n); return Uint8Array.from(out); };108109export interface SuiBuilt {110 /** BCS TransactionData: the bytes a node executes and the bytes the signature commits to */111 tx: Uint8Array;112 /** blake2b-256(intent prefix [0, 0, 0] || tx): the 32 bytes ed25519 signs */113 signingDigest: Uint8Array;114 /** transaction digest (base58): the transaction id */115 txid: string;116 describe: Record<string, string>;117}118119/** Transaction digest of BCS TransactionData bytes: base58(blake2b-256("TransactionData::" || tx)). */120export const suiTxDigest = (tx: Uint8Array): string => base58.encode(blake2b256(concat(enc.encode('TransactionData::'), tx)));121122/** Rebuild the exact TransactionData from the intent alone. Throws on anything that is not a plain SUI payment. */123export function buildSuiTx(i: Intent, net: NetworkDef): SuiBuilt {124 if (i.v !== 1) throw new Error('unsupported intent version');125 if (net.family !== 'sui' || i.network !== net.id) throw new Error('not a Sui intent');126 if (net.chainRef !== SUI_MAINNET_GENESIS) throw new Error('unknown Sui network: only mainnet is supported');127 if (i.chainRef !== net.chainRef) throw new Error('chain identity mismatch (genesis checkpoint digest)');128 if (i.evm || i.btc || i.sol) throw new Error('unexpected chain section in a Sui intent');129 if (i.memo !== undefined) throw new Error('Sui transfers do not carry a memo');130 if (i.asset !== 'native' || net.native.id !== 'native') throw new Error('only native SUI can be sent on Sui in this version');131 if (i.symbol !== net.native.symbol || i.decimals !== net.native.decimals || i.decimals !== 9) throw new Error('asset metadata mismatch');132 const x = i.x;133 if (!x) throw new Error('missing Sui parameters');134 const keys = Object.keys(x).sort();135 if (keys.length !== X_KEYS.length || keys.some((k, n) => k !== X_KEYS[n])) throw new Error('unknown or missing Sui parameter (expected expiration, gasBudget, gasPrice, payment)');136137 const sender = suiAddressBytes(i.from), recipient = checkDestination(i.to);138 if (i.from === i.to) throw new Error('destination equals source');139 const amount = baseUnits(i.amount);140 if (amount <= 0n) throw new Error('amount must be positive');141 if (amount > U64_MAX) throw new Error('amount out of range');142143 const gasPrice = canonicalU64('x.gasPrice', x.gasPrice, 1n);144 const gasBudget = canonicalU64('x.gasBudget', x.gasBudget, 1n);145 if (gasBudget > baseUnits(i.maxFee)) throw new Error('fee above approved maximum');146 if (gasBudget > SUI_MAX_GAS_BUDGET) throw new Error('gas budget above the protocol maximum');147 if (gasBudget < gasPrice) throw new Error('gas budget below the price of one gas unit');148 const expiration = canonicalU64('x.expiration', x.expiration, 0n);149 const payment = suiParsePayment(x.payment);150 if (payment.some((p) => p.objectId === i.to || p.objectId === i.from)) throw new Error('a gas payment coin cannot be the sender or the destination');151152 const tx = concat(153 Uint8Array.of(0x00), // TransactionData::V1154 Uint8Array.of(0x00), // TransactionKind::ProgrammableTransaction155 uleb(2), // inputs156 Uint8Array.of(0x00), uleb(8), u64le(amount), // CallArg::Pure(bcs(u64 amount))157 Uint8Array.of(0x00), uleb(32), recipient, // CallArg::Pure(bcs(address recipient))158 uleb(2), // commands159 Uint8Array.of(0x02, 0x00), uleb(1), Uint8Array.of(0x01, 0x00, 0x00), // SplitCoins(GasCoin, [Input(0)])160 Uint8Array.of(0x01), uleb(1), Uint8Array.of(0x03, 0x00, 0x00, 0x00, 0x00), Uint8Array.of(0x01, 0x01, 0x00), // TransferObjects([NestedResult(0, 0)], Input(1))161 sender,162 uleb(payment.length), ...payment.flatMap((p) => [suiAddressBytes(p.objectId), u64le(p.version), uleb(32), digestBytes(p.digest)]), // GasData.payment163 sender, // GasData.owner: the sender pays its own gas (no sponsor)164 u64le(gasPrice), u64le(gasBudget),165 Uint8Array.of(0x01), u64le(expiration), // TransactionExpiration::Epoch166 );167 const signingDigest = blake2b256(concat(Uint8Array.of(0, 0, 0), tx)); // Intent { scope: TransactionData, version: V0, app: Sui }168 const txid = suiTxDigest(tx);169 return {170 tx, signingDigest, txid,171 describe: {172 network: net.name, operation: 'Pay SUI (split from the gas coin, transfer to the recipient)', from: i.from, to: i.to, amountMist: amount.toString(),173 gasBudgetMist: gasBudget.toString(), gasPriceMist: gasPrice.toString(), gasCoins: String(payment.length), expirationEpoch: expiration.toString(), txid,174 },175 };176}177178/** Serialized signature: flag 0x00 (ed25519) || 64-byte signature || 32-byte public key. */179export function suiSerializeSignature(signature: Uint8Array, publicKey: Uint8Array): Uint8Array {180 if (signature.length !== 64 || publicKey.length !== 32) throw new Error('bad signature or key length');181 return concat(Uint8Array.of(0x00), signature, publicKey);182}183184/** Split the broadcast payload "base64(tx):base64(signature)" produced by sign(); checks the shape, never the content. */185export function suiParseRaw(raw: string): { tx: Uint8Array; signature: Uint8Array; txid: string } {186 const parts = typeof raw === 'string' ? raw.split(':') : [];187 if (parts.length !== 2) throw new Error('not a Sui signed transaction');188 const tx = unb64(parts[0]!), signature = unb64(parts[1]!);189 if (tx.length < 100 || signature.length !== 97 || signature[0] !== 0x00) throw new Error('not a Sui signed transaction');190 return { tx, signature, txid: suiTxDigest(tx) };191}192193export const suiPlugin: FamilyPlugin = {194 family: 'sui',195 curve: 'ed25519',196 path(net: NetworkDef): string {197 if (net.coinType !== 784) throw new Error('Sui derivation requires coin type 784');198 return "m/44'/784'/0'/0'/0'";199 },200 address: (_net: NetworkDef, publicKey: Uint8Array) => suiAddressFromPublicKey(publicKey),201 normalizeAddress(_net: NetworkDef, addr: string): string {202 // no checksum on Sui: hex case carries no information, so upper case is accepted and lowered; nothing is padded203 if (typeof addr !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(addr)) throw new Error('invalid Sui address: expected 0x followed by 64 hex digits');204 const a = addr.toLowerCase();205 checkDestination(a);206 return a;207 },208 build(i: Intent, net: NetworkDef) {209 const b = buildSuiTx(i, net);210 return { signDocs: [b.signingDigest], describe: b.describe };211 },212 sign(i: Intent, net: NetworkDef, key: KeyHandle): Signed {213 const b = buildSuiTx(i, net);214 if (key.curve !== 'ed25519' || !key.signMessage || key.publicKey.length !== 32) throw new Error('Sui needs an ed25519 key');215 if (suiAddressFromPublicKey(key.publicKey) !== i.from) throw new Error('signing key does not control the source address');216 const sig = key.signMessage(b.signingDigest);217 if (sig.length !== 64 || !ed25519.verify(sig, b.signingDigest, key.publicKey)) throw new Error('signature self-check failed');218 return { raw: `${b64(b.tx)}:${b64(suiSerializeSignature(sig, key.publicKey))}`, txid: b.txid };219 },220};221