Repository Public
1// Stellar (family "stellar"): native XLM only, one operation per transaction.2// Pure module: no network access, no side effects. The TransactionEnvelope XDR is written by hand from the approved3// intent and proven byte-for-byte against @stellar/stellar-base in test/stellar.test.ts.4//5// References: Stellar-transaction.x / Stellar-ledger-entries.x / Stellar-types.x (stellar-xdr), SEP-0005 (key derivation),6// SEP-0023 (strkey). Intent parameters (x):7// op "payment" (destination exists) | "createAccount" (it does not; amount is the starting balance, >= 1 XLM)8// sequence the transaction sequence number (account sequence + 1), decimal int649// fee the transaction fee bid in stroops, decimal uint32, <= intent.maxFee10// maxTime time bound (unix seconds): the transaction is invalid in any ledger closing after it11// memoType "none" | "text" | "id" — must equal stellarMemoType(intent.memo)12import { ed25519 } from '@noble/curves/ed25519.js';13import { sha256 } from '@noble/hashes/sha2.js';14import { base32nopad } from '@scure/base';15import { baseUnits } from '../amounts.ts';16import type { FamilyPlugin, KeyHandle, Signed } from '../families.ts';17import type { Intent } from '../intent.ts';18import type { NetworkDef } from '../networks.ts';1920export const STELLAR_PUBNET_PASSPHRASE = 'Public Global Stellar Network ; September 2015';21/** 1 XLM: smallest starting balance this wallet will create an account with (2 base reserves at 0.5 XLM). */22export const STELLAR_MIN_CREATE = 10_000_000n;23/** network minimum fee per operation, in stroops */24export const STELLAR_MIN_FEE = 100n;25/** longest validity window (maxTime - createdAt) the signer accepts, in seconds */26export const STELLAR_MAX_VALIDITY_SEC = 900;2728const INT64_MAX = (1n << 63n) - 1n, UINT64_MAX = (1n << 64n) - 1n, UINT32_MAX = 0xffff_ffffn;29const VERSION_ACCOUNT = 6 << 3; // 'G'30const X_KEYS = ['fee', 'maxTime', 'memoType', 'op', 'sequence'];31const enc = new TextEncoder();3233function crc16xmodem(b: Uint8Array): number {34 let crc = 0;35 for (const byte of b) {36 crc ^= byte << 8;37 for (let k = 0; k < 8; k++) crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;38 }39 return crc;40}4142/** strkey "G…" account id of an ed25519 public key */43export function stellarEncodeAccount(publicKey: Uint8Array): string {44 if (publicKey.length !== 32) throw new Error('ed25519 public key must be 32 bytes');45 const body = new Uint8Array(35);46 body[0] = VERSION_ACCOUNT; body.set(publicKey, 1);47 const crc = crc16xmodem(body.subarray(0, 33));48 body[33] = crc & 0xff; body[34] = crc >> 8; // little-endian49 return base32nopad.encode(body);50}5152/** Decode a canonical "G…" account id to its 32-byte key. Muxed (M…), secret (S…), contract (C…) and any other strkey are refused. */53export function stellarDecodeAccount(addr: string): Uint8Array {54 if (typeof addr !== 'string') throw new Error('invalid Stellar address');55 if (/^M[A-Z2-7]{68}$/.test(addr)) throw new Error('muxed Stellar addresses (M…) are not supported: use the G… address and a memo');56 if (!/^G[A-Z2-7]{55}$/.test(addr)) throw new Error('invalid Stellar address: expected 56 characters starting with G');57 let raw: Uint8Array;58 try { raw = base32nopad.decode(addr); } catch { throw new Error('invalid Stellar address encoding'); }59 if (raw.length !== 35 || raw[0] !== VERSION_ACCOUNT) throw new Error('invalid Stellar address: not an account id');60 const crc = crc16xmodem(raw.subarray(0, 33));61 if (raw[33] !== (crc & 0xff) || raw[34] !== crc >> 8) throw new Error('invalid Stellar address checksum');62 const key = raw.slice(1, 33);63 if (stellarEncodeAccount(key) !== addr) throw new Error('non-canonical Stellar address');64 return key;65}6667/**68 * The single memo rule, used by the API and re-checked by the signer:69 * no memo -> "none"70 * canonical decimal integer in [0, 2^64-1] ("0" or no leading zero, digits only) -> "id" (MEMO_ID)71 * anything else, 1..28 UTF-8 bytes -> "text" (MEMO_TEXT)72 * Everything else throws. A numeric string with leading zeros or above 2^64-1 is text (an id could not carry it unchanged).73 */74export function stellarMemoType(memo: string | undefined): 'none' | 'text' | 'id' {75 if (memo === undefined) return 'none';76 if (memo.length === 0) throw new Error('empty memo');77 if (/^(0|[1-9]\d{0,19})$/.test(memo) && BigInt(memo) <= UINT64_MAX) return 'id';78 const bytes = enc.encode(memo);79 if (new TextDecoder('utf-8', { fatal: true }).decode(bytes) !== memo) throw new Error('memo is not valid Unicode text');80 if (bytes.length > 28) throw new Error('memo too long: a Stellar text memo holds at most 28 bytes');81 return 'text';82}8384// ---- XDR writer (big-endian, 4-byte aligned) ----85class Xdr {86 private parts: Uint8Array[] = [];87 u32(n: bigint | number): this {88 const v = BigInt(n);89 if (v < 0n || v > UINT32_MAX) throw new Error('uint32 out of range');90 const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, Number(v)); this.parts.push(b); return this;91 }92 u64(v: bigint): this {93 if (v < 0n || v > UINT64_MAX) throw new Error('uint64 out of range');94 const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, v); this.parts.push(b); return this;95 }96 i64(v: bigint): this {97 if (v < -(1n << 63n) || v > INT64_MAX) throw new Error('int64 out of range');98 const b = new Uint8Array(8); new DataView(b.buffer).setBigInt64(0, v); this.parts.push(b); return this;99 }100 fixed(b: Uint8Array, len: number): this {101 if (b.length !== len || len % 4 !== 0) throw new Error('bad fixed opaque length');102 this.parts.push(b); return this;103 }104 varOpaque(b: Uint8Array, max: number): this {105 if (b.length > max) throw new Error('variable opaque too long');106 this.u32(b.length); this.parts.push(b);107 const pad = (4 - (b.length % 4)) % 4;108 if (pad) this.parts.push(new Uint8Array(pad));109 return this;110 }111 bytes(): Uint8Array {112 const out = new Uint8Array(this.parts.reduce((n, p) => n + p.length, 0));113 let o = 0;114 for (const p of this.parts) { out.set(p, o); o += p.length; }115 return out;116 }117}118119const ENVELOPE_TYPE_TX = 2, KEY_TYPE_ED25519 = 0, PRECOND_TIME = 1, MEMO_NONE = 0, MEMO_TEXT = 1, MEMO_ID = 2;120const OP_CREATE_ACCOUNT = 0, OP_PAYMENT = 1, ASSET_TYPE_NATIVE = 0;121122const canonicalInt = (name: string, s: string | undefined, min: bigint, max: bigint): bigint => {123 if (s === undefined || !/^(0|[1-9]\d{0,19})$/.test(s)) throw new Error(`x.${name} must be a canonical decimal integer`);124 const v = BigInt(s);125 if (v < min || v > max) throw new Error(`x.${name} out of range`);126 return v;127};128129export interface StellarBuilt {130 /** Transaction XDR (what the hash commits to, without envelope type or signatures) */131 tx: Uint8Array;132 /** sha256(networkId || ENVELOPE_TYPE_TX || tx): the 32 bytes ed25519 signs, and the transaction id */133 hash: Uint8Array;134 describe: Record<string, string>;135}136137/** Rebuild the exact Transaction XDR from the intent alone. Throws on anything that is not a plain XLM payment / account creation. */138export function buildStellarTx(i: Intent, net: NetworkDef): StellarBuilt {139 if (i.v !== 1) throw new Error('unsupported intent version');140 if (net.family !== 'stellar' || i.network !== net.id) throw new Error('not a Stellar intent');141 if (net.chainRef !== STELLAR_PUBNET_PASSPHRASE) throw new Error('unknown Stellar network: only the public network is supported');142 if (i.chainRef !== net.chainRef) throw new Error('chain identity mismatch (network passphrase)');143 if (i.evm || i.btc || i.sol) throw new Error('unexpected chain section in a Stellar intent');144 if (i.asset !== 'native' || net.native.id !== 'native') throw new Error('only native XLM can be sent on Stellar');145 if (i.symbol !== net.native.symbol || i.decimals !== net.native.decimals || i.decimals !== 7) throw new Error('asset metadata mismatch');146 const x = i.x;147 if (!x) throw new Error('missing Stellar parameters');148 const keys = Object.keys(x).sort();149 if (keys.length !== X_KEYS.length || keys.some((k, n) => k !== X_KEYS[n])) throw new Error('unknown or missing Stellar parameter (expected fee, maxTime, memoType, op, sequence)');150151 const source = stellarDecodeAccount(i.from), dest = stellarDecodeAccount(i.to);152 if (i.from === i.to) throw new Error('destination equals source');153 const amount = baseUnits(i.amount);154 if (amount <= 0n) throw new Error('amount must be positive');155 if (amount > INT64_MAX) throw new Error('amount out of range');156 const op = x.op;157 if (op !== 'payment' && op !== 'createAccount') throw new Error('x.op must be "payment" or "createAccount"');158 if (op === 'createAccount' && amount < STELLAR_MIN_CREATE) throw new Error('creating an account needs a starting balance of at least 1 XLM');159160 const fee = canonicalInt('fee', x.fee, STELLAR_MIN_FEE, UINT32_MAX);161 if (fee > baseUnits(i.maxFee)) throw new Error('fee above approved maximum');162 const sequence = canonicalInt('sequence', x.sequence, 1n, INT64_MAX);163 const maxTime = canonicalInt('maxTime', x.maxTime, 1n, UINT64_MAX);164 if (maxTime <= BigInt(i.createdAt)) throw new Error('time bound is not after the intent creation time');165 if (maxTime < BigInt(i.expiresAt)) throw new Error('time bound ends before the approval window');166 if (maxTime > BigInt(i.createdAt) + BigInt(STELLAR_MAX_VALIDITY_SEC)) throw new Error('time bound too far in the future');167168 const memoType = stellarMemoType(i.memo);169 if (x.memoType !== memoType) throw new Error(`x.memoType must be "${memoType}" for this memo`);170171 const w = new Xdr();172 w.u32(KEY_TYPE_ED25519).fixed(source, 32); // sourceAccount: MuxedAccount173 w.u32(fee); // fee174 w.i64(sequence); // seqNum175 w.u32(PRECOND_TIME).u64(0n).u64(maxTime); // cond: TimeBounds { minTime: 0, maxTime }176 if (memoType === 'none') w.u32(MEMO_NONE);177 else if (memoType === 'text') w.u32(MEMO_TEXT).varOpaque(enc.encode(i.memo!), 28);178 else w.u32(MEMO_ID).u64(BigInt(i.memo!));179 w.u32(1); // operations<100>: exactly one180 w.u32(0); // operation.sourceAccount: absent (the transaction source)181 if (op === 'payment') w.u32(OP_PAYMENT).u32(KEY_TYPE_ED25519).fixed(dest, 32).u32(ASSET_TYPE_NATIVE).i64(amount);182 else w.u32(OP_CREATE_ACCOUNT).u32(KEY_TYPE_ED25519).fixed(dest, 32).i64(amount);183 w.u32(0); // ext184 const tx = w.bytes();185186 const hash = txHash(net, tx);187 return {188 tx, hash,189 describe: {190 network: net.name, operation: op === 'payment' ? 'Payment (native XLM)' : 'CreateAccount', from: i.from, to: i.to, amountStroops: amount.toString(),191 feeStroops: fee.toString(), sequence: sequence.toString(), maxTime: maxTime.toString(), memoType, memo: i.memo ?? '', txid: hexOf(hash),192 },193 };194}195196function concat(...a: Uint8Array[]): Uint8Array {197 const out = new Uint8Array(a.reduce((n, p) => n + p.length, 0));198 let o = 0;199 for (const p of a) { out.set(p, o); o += p.length; }200 return out;201}202/** TransactionSignaturePayload hash: sha256(networkId || ENVELOPE_TYPE_TX || tx), networkId = sha256(passphrase) */203const txHash = (net: NetworkDef, tx: Uint8Array) => sha256(concat(sha256(enc.encode(net.chainRef)), new Xdr().u32(ENVELOPE_TYPE_TX).bytes(), tx));204const hexOf = (b: Uint8Array) => Array.from(b, (v) => v.toString(16).padStart(2, '0')).join('');205const b64 = (b: Uint8Array) => btoa(String.fromCharCode(...b));206207/** TransactionEnvelope (ENVELOPE_TYPE_TX) with one decorated signature. */208export function stellarEnvelope(tx: Uint8Array, publicKey: Uint8Array, signature: Uint8Array): Uint8Array {209 if (signature.length !== 64 || publicKey.length !== 32) throw new Error('bad signature or key length');210 return concat(new Xdr().u32(ENVELOPE_TYPE_TX).bytes(), tx, new Xdr().u32(1).fixed(publicKey.slice(28), 4).varOpaque(signature, 64).bytes());211}212213/** Transaction id (hex) of a single-signature envelope produced by this module; used by the API to recognise an already-included transaction. */214export function stellarEnvelopeTxid(net: NetworkDef, rawBase64: string): string {215 const raw = Uint8Array.from(atob(rawBase64), (c) => c.charCodeAt(0));216 const tail = 4 + 4 + 4 + 64; // signature count, hint, length, signature217 if (raw.length < 4 + tail || new DataView(raw.buffer, raw.byteOffset).getUint32(0) !== ENVELOPE_TYPE_TX) throw new Error('not a Stellar transaction envelope');218 const tx = raw.subarray(4, raw.length - tail);219 return hexOf(txHash(net, tx));220}221222export const stellarPlugin: FamilyPlugin = {223 family: 'stellar',224 curve: 'ed25519',225 path(net: NetworkDef): string {226 if (net.coinType !== 148) throw new Error('Stellar derivation requires coin type 148 (SEP-0005)');227 return "m/44'/148'/0'";228 },229 address: (_net: NetworkDef, publicKey: Uint8Array) => stellarEncodeAccount(publicKey),230 normalizeAddress(_net: NetworkDef, addr: string): string {231 stellarDecodeAccount(addr);232 return addr;233 },234 build(i: Intent, net: NetworkDef) {235 const b = buildStellarTx(i, net);236 return { signDocs: [b.hash], describe: b.describe };237 },238 sign(i: Intent, net: NetworkDef, key: KeyHandle): Signed {239 const b = buildStellarTx(i, net);240 if (key.curve !== 'ed25519' || !key.signMessage || key.publicKey.length !== 32) throw new Error('Stellar needs an ed25519 key');241 if (stellarEncodeAccount(key.publicKey) !== i.from) throw new Error('signing key does not control the source account');242 const sig = key.signMessage(b.hash);243 if (sig.length !== 64 || !ed25519.verify(sig, b.hash, key.publicKey)) throw new Error('signature self-check failed');244 return { raw: b64(stellarEnvelope(b.tx, key.publicKey, sig)), txid: hexOf(b.hash) };245 },246};247