Repository Public
1// Aptos (family "aptos"): native APT only, one entry function: 0x1::aptos_account::transfer(address, u64).2// Pure module: no network access, no side effects. The BCS RawTransaction and SignedTransaction are written by hand from the3// approved intent and proven byte-for-byte against @aptos-labs/ts-sdk in test/aptos.test.ts.4//5// References: aptos-core types/src/transaction (RawTransaction, SignedTransaction, TransactionAuthenticator), the BCS6// specification, AIP-40 (address format), the Aptos derivation path m/44'/637'/account'/0'/index' (SLIP-0010 ed25519, as used7// by Petra and the official SDK). Intent parameters (x), all canonical decimal u64:8// sequence_number the sender's next sequence number9// max_gas_amount gas units the transaction may use10// gas_unit_price octas per gas unit; max_gas_amount × gas_unit_price <= intent.maxFee11// expiration_timestamp_secs the transaction can never be committed in a block at or after this time (unix seconds)12import { ed25519 } from '@noble/curves/ed25519.js';13import { sha3_256 } from '@noble/hashes/sha3.js';14import { baseUnits } from '../amounts.ts';15import type { FamilyPlugin, KeyHandle, Signed } from '../families.ts';16import type { Intent } from '../intent.ts';17import type { NetworkDef } from '../networks.ts';1819/** chain id of the Aptos main network: part of every signed transaction (testnet is 2) */20export const APTOS_MAINNET_CHAIN_ID = 1;21/** network minimum gas unit price in octas (gas schedule `min_price_per_gas_unit`) */22export const APTOS_MIN_GAS_UNIT_PRICE = 100n;23/** network maximum gas units per transaction (gas schedule `maximum_number_of_gas_units`) */24export const APTOS_MAX_GAS_AMOUNT = 2_000_000n;25/** longest validity window (expiration - createdAt) the signer accepts, in seconds */26export const APTOS_MAX_VALIDITY_SEC = 900;2728const UINT64_MAX = (1n << 64n) - 1n;29const X_KEYS = ['expiration_timestamp_secs', 'gas_unit_price', 'max_gas_amount', 'sequence_number'];30const enc = new TextEncoder();31const hexOf = (b: Uint8Array) => Array.from(b, (v) => v.toString(16).padStart(2, '0')).join('');32const bytesOf = (h: string) => Uint8Array.from(h.match(/../g) ?? [], (p) => parseInt(p, 16));33function concat(...a: Uint8Array[]): Uint8Array {34 const out = new Uint8Array(a.reduce((n, p) => n + p.length, 0));35 let o = 0;36 for (const p of a) { out.set(p, o); o += p.length; }37 return out;38}3940/** Legacy Ed25519 account address: authentication key = sha3-256(public key || 0x00 scheme byte); the address is that key. */41export function aptosAddressOf(publicKey: Uint8Array): string {42 if (publicKey.length !== 32) throw new Error('ed25519 public key must be 32 bytes');43 return '0x' + hexOf(sha3_256(concat(publicKey, new Uint8Array([0]))));44}4546/**47 * Decode a canonical account address (AIP-40 long form): "0x" + exactly 64 lowercase hex digits.48 * Short forms, missing leading zeros, upper case and the reserved special addresses 0x0–0xf (framework accounts) are refused.49 */50export function aptosDecodeAddress(addr: string): Uint8Array {51 if (typeof addr !== 'string' || !/^0x[0-9a-f]{64}$/.test(addr)) throw new Error('invalid Aptos address: expected 0x followed by 64 lowercase hexadecimal characters');52 const raw = bytesOf(addr.slice(2));53 if (raw.subarray(0, 31).every((b) => b === 0) && raw[31]! < 0x10) throw new Error('reserved Aptos address (0x0–0xf): not a wallet');54 return raw;55}5657/**58 * Address of the primary fungible store that holds `owner`'s APT (fungible asset metadata object 0xa):59 * sha3-256(owner || metadata address || 0xFC). Deposit events name this store, not the owner.60 */61export function aptosAptStore(owner: string): string {62 const meta = new Uint8Array(32); meta[31] = 0x0a;63 return '0x' + hexOf(sha3_256(concat(aptosDecodeAddress(owner), meta, new Uint8Array([0xfc]))));64}6566// ---- BCS writer (little-endian integers, ULEB128 lengths and enum variants) ----67class Bcs {68 private parts: Uint8Array[] = [];69 u8(n: number): this {70 if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error('u8 out of range');71 this.parts.push(new Uint8Array([n])); return this;72 }73 u64(v: bigint): this {74 if (v < 0n || v > UINT64_MAX) throw new Error('u64 out of range');75 const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, v, true); this.parts.push(b); return this;76 }77 uleb(n: number): this {78 if (!Number.isInteger(n) || n < 0 || n > 0xffff_ffff) throw new Error('uleb128 out of range');79 const out: number[] = [];80 let v = n;81 do { const byte = v % 128; v = Math.floor(v / 128); out.push(v > 0 ? byte | 0x80 : byte); } while (v > 0);82 this.parts.push(Uint8Array.from(out)); return this;83 }84 fixed(b: Uint8Array, len: number): this {85 if (b.length !== len) throw new Error('bad fixed-size field length');86 this.parts.push(b); return this;87 }88 /** vector<u8> / string: ULEB128 length then the bytes */89 bytes(b: Uint8Array): this { this.uleb(b.length); this.parts.push(b); return this; }90 str(s: string): this { return this.bytes(enc.encode(s)); }91 done(): Uint8Array { return concat(...this.parts); }92}9394const PAYLOAD_ENTRY_FUNCTION = 2, AUTHENTICATOR_ED25519 = 0, AUTHENTICATOR_SINGLE_SENDER = 4, ACCOUNT_AUTHENTICATOR_NONE = 4, TRANSACTION_USER = 0;95const FRAMEWORK = new Uint8Array(32); FRAMEWORK[31] = 1; // 0x196const RAW_TX_PREFIX = sha3_256(enc.encode('APTOS::RawTransaction'));97const TX_PREFIX = sha3_256(enc.encode('APTOS::Transaction'));9899const canonicalU64 = (name: string, s: string | undefined, min: bigint, max: bigint): bigint => {100 if (s === undefined || !/^(0|[1-9]\d{0,19})$/.test(s)) throw new Error(`x.${name} must be a canonical decimal integer`);101 const v = BigInt(s);102 if (v < min || v > max) throw new Error(`x.${name} out of range`);103 return v;104};105106export interface AptosTransferFields { sender: Uint8Array; recipient: Uint8Array; amount: bigint; sequence: bigint; maxGas: bigint; gasPrice: bigint; expiration: bigint }107/**108 * BCS RawTransaction of `0x1::aptos_account::transfer(recipient, amount)` on the main network. The only transaction encoder of109 * this module: the signer reaches it through buildAptosTx (which validates the intent), the API uses it to simulate.110 */111export function aptosEncodeTransfer(f: AptosTransferFields): Uint8Array {112 return new Bcs()113 .fixed(f.sender, 32) // sender: AccountAddress114 .u64(f.sequence) // sequence_number115 .uleb(PAYLOAD_ENTRY_FUNCTION) // payload: TransactionPayload::EntryFunction116 .fixed(FRAMEWORK, 32).str('aptos_account') // module: ModuleId { address: 0x1, name }117 .str('transfer') // function118 .uleb(0) // ty_args: none119 .uleb(2) // args: two BCS-encoded values120 .bytes(new Bcs().fixed(f.recipient, 32).done()) // to: address121 .bytes(new Bcs().u64(f.amount).done()) // amount: u64122 .u64(f.maxGas).u64(f.gasPrice).u64(f.expiration)123 .u8(APTOS_MAINNET_CHAIN_ID) // chain_id124 .done();125}126127export interface AptosBuilt {128 /** BCS RawTransaction */129 rawTx: Uint8Array;130 /** sha3-256("APTOS::RawTransaction") || rawTx: the message ed25519 signs */131 signingMessage: Uint8Array;132 /** largest fee the network can charge: max_gas_amount × gas_unit_price, in octas */133 maxFee: bigint;134 describe: Record<string, string>;135}136137/** Rebuild the exact RawTransaction from the intent alone. Throws on anything that is not a plain APT transfer. */138export function buildAptosTx(i: Intent, net: NetworkDef): AptosBuilt {139 if (i.v !== 1) throw new Error('unsupported intent version');140 if (net.family !== 'aptos' || i.network !== net.id) throw new Error('not an Aptos intent');141 if (net.chainRef !== String(APTOS_MAINNET_CHAIN_ID)) throw new Error('unknown Aptos network: only the main network (chain id 1) is supported');142 if (i.chainRef !== net.chainRef) throw new Error('chain identity mismatch (chain id)');143 if (i.evm || i.btc || i.sol) throw new Error('unexpected chain section in an Aptos intent');144 if (i.memo !== undefined) throw new Error('Aptos transfers carry no memo');145 if (i.asset !== 'native' || net.native.id !== 'native') throw new Error('only native APT can be sent on Aptos');146 if (i.symbol !== net.native.symbol || i.decimals !== net.native.decimals || i.decimals !== 8) throw new Error('asset metadata mismatch');147 const x = i.x;148 if (!x) throw new Error('missing Aptos parameters');149 const keys = Object.keys(x).sort();150 if (keys.length !== X_KEYS.length || keys.some((k, n) => k !== X_KEYS[n]))151 throw new Error('unknown or missing Aptos parameter (expected expiration_timestamp_secs, gas_unit_price, max_gas_amount, sequence_number)');152153 const sender = aptosDecodeAddress(i.from), recipient = aptosDecodeAddress(i.to);154 if (i.from === i.to) throw new Error('destination equals source');155 const amount = baseUnits(i.amount);156 if (amount <= 0n) throw new Error('amount must be positive');157 if (amount > UINT64_MAX) throw new Error('amount out of range');158159 const sequence = canonicalU64('sequence_number', x.sequence_number, 0n, UINT64_MAX);160 const maxGas = canonicalU64('max_gas_amount', x.max_gas_amount, 1n, APTOS_MAX_GAS_AMOUNT);161 const gasPrice = canonicalU64('gas_unit_price', x.gas_unit_price, APTOS_MIN_GAS_UNIT_PRICE, UINT64_MAX);162 const maxFee = maxGas * gasPrice;163 if (maxFee > baseUnits(i.maxFee)) throw new Error('fee above approved maximum');164 const expiration = canonicalU64('expiration_timestamp_secs', x.expiration_timestamp_secs, 1n, UINT64_MAX);165 if (expiration <= BigInt(i.createdAt)) throw new Error('expiration is not after the intent creation time');166 if (expiration < BigInt(i.expiresAt)) throw new Error('expiration ends before the approval window');167 if (expiration > BigInt(i.createdAt) + BigInt(APTOS_MAX_VALIDITY_SEC)) throw new Error('expiration too far in the future');168169 const rawTx = aptosEncodeTransfer({ sender, recipient, amount, sequence, maxGas, gasPrice, expiration });170171 return {172 rawTx, signingMessage: concat(RAW_TX_PREFIX, rawTx), maxFee,173 describe: {174 network: net.name, operation: '0x1::aptos_account::transfer (native APT)', from: i.from, to: i.to, amountOctas: amount.toString(),175 sequenceNumber: sequence.toString(), maxGasAmount: maxGas.toString(), gasUnitPrice: gasPrice.toString(), maxFeeOctas: maxFee.toString(),176 expirationTimestampSecs: expiration.toString(), chainId: String(APTOS_MAINNET_CHAIN_ID), rawTransaction: '0x' + hexOf(rawTx),177 },178 };179}180181/** BCS SignedTransaction: RawTransaction || TransactionAuthenticator::Ed25519 { public_key, signature }. */182export function aptosSignedTx(rawTx: Uint8Array, publicKey: Uint8Array, signature: Uint8Array): Uint8Array {183 if (publicKey.length !== 32 || signature.length !== 64) throw new Error('bad signature or key length');184 return concat(rawTx, new Bcs().uleb(AUTHENTICATOR_ED25519).bytes(publicKey).bytes(signature).done());185}186187/** Transaction hash: sha3-256(sha3-256("APTOS::Transaction") || BCS(Transaction::UserTransaction(signed))), "0x" + hex. */188export function aptosTxid(signedTx: Uint8Array): string {189 return '0x' + hexOf(sha3_256(concat(TX_PREFIX, new Bcs().uleb(TRANSACTION_USER).done(), signedTx)));190}191192/** Bytes of a broadcast payload produced by this module ("0x" + hex of the BCS SignedTransaction). */193export function aptosRawBytes(raw: string): Uint8Array {194 if (typeof raw !== 'string' || !/^0x([0-9a-f]{2}){150,400}$/.test(raw)) throw new Error('not an Aptos signed transaction');195 return bytesOf(raw.slice(2));196}197198/**199 * Body for POST /v1/transactions/simulate: the exact RawTransaction with an authenticator that cannot be submitted.200 * With the sender's public key: the Ed25519 authenticator with an all-zero signature (the API refuses a valid one there).201 * Without it (the API server only knows the address): TransactionAuthenticator::SingleSender(NoAccountAuthenticator),202 * which the node accepts for simulation only and which skips the authentication-key check.203 */204export function aptosSimulationTx(rawTx: Uint8Array, publicKey?: Uint8Array): Uint8Array {205 if (publicKey) return aptosSignedTx(rawTx, publicKey, new Uint8Array(64));206 return concat(rawTx, new Bcs().uleb(AUTHENTICATOR_SINGLE_SENDER).uleb(ACCOUNT_AUTHENTICATOR_NONE).done());207}208209export const aptosPlugin: FamilyPlugin = {210 family: 'aptos',211 curve: 'ed25519',212 path(net: NetworkDef): string {213 if (net.coinType !== 637) throw new Error('Aptos derivation requires coin type 637');214 return "m/44'/637'/0'/0'/0'";215 },216 address: (_net: NetworkDef, publicKey: Uint8Array) => aptosAddressOf(publicKey),217 normalizeAddress(_net: NetworkDef, addr: string): string {218 // hexadecimal is case-insensitive: accept upper case and return the canonical lower-case long form; never pad or guess219 if (typeof addr !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(addr)) throw new Error('invalid Aptos address: expected 0x followed by 64 hexadecimal characters');220 const a = addr.toLowerCase();221 aptosDecodeAddress(a);222 return a;223 },224 build(i: Intent, net: NetworkDef) {225 const b = buildAptosTx(i, net);226 return { signDocs: [b.signingMessage], describe: b.describe };227 },228 sign(i: Intent, net: NetworkDef, key: KeyHandle): Signed {229 const b = buildAptosTx(i, net);230 if (key.curve !== 'ed25519' || !key.signMessage || key.publicKey.length !== 32) throw new Error('Aptos needs an ed25519 key');231 if (aptosAddressOf(key.publicKey) !== i.from) throw new Error('signing key does not control the source account');232 const sig = key.signMessage(b.signingMessage);233 if (sig.length !== 64 || !ed25519.verify(sig, b.signingMessage, key.publicKey)) throw new Error('signature self-check failed');234 const signed = aptosSignedTx(b.rawTx, key.publicKey, sig);235 return { raw: '0x' + hexOf(signed), txid: aptosTxid(signed) };236 },237};238