SPB Git

Repository Public

aptos.ts 13.4 KB 238 lines typescript

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

Theme