SPB Git

Repository Public

sui.ts 13.1 KB 221 lines typescript

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
Files main
Menu

Theme