git / moonbite-network / tools/vault/moonbite-vault.mjs · raw

/* MoonBite time-lock vault (ADR-014). * * A vault is a P2WSH output whose witness script is * *     <unlock_time> OP_CHECKLOCKTIMEVERIFY OP_DROP *     OP_DUP OP_HASH160 <20-byte pubkey hash> OP_EQUALVERIFY OP_CHECKSIG * * Nobody can spend it before unlock_time (a Unix time, checked by every node * against the chain's median time past), and after that only the key behind * <pubkey hash> can. There is no other path: no recovery key, no multisig. * * This module builds the script and address, and the transaction that spends * a vault once it has unlocked. It reuses the wallet's own key derivation, so * the 12 words (and extra password, if any) that control the founder wallet * are exactly what unlocks the vault. Nothing here talks to the network. */import { getPublicKey, signAsync } from '../../static/vendor/noble-secp256k1.js';import { hash160, bytesToHex, hexToBytes } from '../../static/moonbite-hd.js';import { privkeyFromSeedPhrase } from '../../static/moonbite-address.js';const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];function polymod(v) { let c = 1; for (const x of v) { const b = c >> 25; c = ((c & 0x1ffffff) << 5) ^ x; for (let i = 0; i < 5; i++) if ((b >> i) & 1) c ^= GEN[i]; } return c; }function hrpExpand(h) { const o = []; for (const c of h) o.push(c.charCodeAt(0) >> 5); o.push(0); for (const c of h) o.push(c.charCodeAt(0) & 31); return o; }function convertBits(data, from, to, pad) {    let acc = 0, bits = 0; const out = [], maxv = (1 << to) - 1;    for (const v of data) { acc = (acc << from) | v; bits += from; while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); } }    if (pad) { if (bits) out.push((acc << (to - bits)) & maxv); } else if (bits >= from || ((acc << (to - bits)) & maxv)) return null;    return out;}export function segwitEncode(hrp, programHex) {    const data = [0].concat(convertBits(hexToBytes(programHex), 8, 5, true));    const pm = polymod(hrpExpand(hrp).concat(data, [0, 0, 0, 0, 0, 0])) ^ 1;    const chk = []; for (let i = 0; i < 6; i++) chk.push((pm >> (5 * (5 - i))) & 31);    return hrp + '1' + data.concat(chk).map(d => CHARSET[d]).join('');}/* bech32 v0 address -> { hrp, program (hex, 20 or 32 bytes) }. */export function segwitDecode(addr) {    const a = String(addr).toLowerCase(), p = a.lastIndexOf('1');    if (addr !== a && addr !== String(addr).toUpperCase()) throw new Error('mixed-case address');    if (p < 1 || a.length - p < 7) throw new Error('bad address');    const hrp = a.slice(0, p), data = [...a.slice(p + 1)].map(c => { const i = CHARSET.indexOf(c); if (i < 0) throw new Error('bad address character'); return i; });    if (polymod(hrpExpand(hrp).concat(data)) !== 1) throw new Error('bad address checksum');    if (data[0] !== 0) throw new Error('only witness v0 addresses');    const prog = convertBits(data.slice(1, -6), 5, 8, false);    if (!prog || (prog.length !== 20 && prog.length !== 32)) throw new Error('bad witness program');    return { hrp, program: bytesToHex(new Uint8Array(prog)) };}const enc = (a) => { let n = 0; for (const x of a) n += x.length; const o = new Uint8Array(n); let i = 0; for (const x of a) { o.set(x, i); i += x.length; } return o; };const u32 = (n) => new Uint8Array([n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255]);const u64 = (v) => { const o = new Uint8Array(8); let n = BigInt(v); for (let i = 0; i < 8; i++) { o[i] = Number(n & 255n); n >>= 8n; } return o; };const varint = (n) => n < 0xfd ? new Uint8Array([n]) : enc([new Uint8Array([0xfd]), new Uint8Array([n & 255, (n >> 8) & 255])]);const sha256 = async (b) => new Uint8Array(await crypto.subtle.digest('SHA-256', b));const dsha256 = async (b) => sha256(await sha256(b));function minimal(x) { let h = x.toString(16); if (h.length % 2) h = '0' + h; let b = hexToBytes(h); if (b.length && (b[0] & 0x80)) b = enc([new Uint8Array([0]), b]); return b; }function der(r, s) { const R = minimal(r), S = minimal(s); const body = enc([new Uint8Array([2, R.length]), R, new Uint8Array([2, S.length]), S]); return enc([new Uint8Array([0x30, body.length]), body]); }/* CScriptNum, minimal little-endian with a clear sign bit. */function scriptNum(n) { const out = []; let v = n; while (v > 0) { out.push(v & 255); v = Math.floor(v / 256); } if (out[out.length - 1] & 0x80) out.push(0); return new Uint8Array(out); }/* The vault: witness script, its P2WSH address and scriptPubKey. */export async function vault({ pubkeyHash, unlockTime, hrp = 'moon' }) {    if (!/^[0-9a-f]{40}$/.test(pubkeyHash)) throw new Error('pubkeyHash must be 20 bytes hex');    if (!(unlockTime >= 500000000 && unlockTime < 2 ** 31)) throw new Error('unlockTime must be a Unix time');    const t = scriptNum(unlockTime);    const script = enc([new Uint8Array([t.length]), t, new Uint8Array([0xb1, 0x75, 0x76, 0xa9, 0x14]), hexToBytes(pubkeyHash), new Uint8Array([0x88, 0xac])]);    const program = bytesToHex(await sha256(script));    return { witnessScript: bytesToHex(script), address: segwitEncode(hrp, program), scriptPubKey: '0020' + program, unlockTime, pubkeyHash };}/* Spend vault coins once unlocked. The node accepts this only when the chain's * median time past is beyond unlockTime; before that it is "non-final". * Refuses to sign if the secret does not own the vault. Returns { rawHex, txid, fee, amount, vsize }. */export async function buildUnlock({ secret, passphrase = '', vaultInfo, utxos, toAddress, feeRate = 2, minFee = 1000 }) {    if (!utxos.length) throw new Error('no vault coins to spend');    const priv = hexToBytes(await privkeyFromSeedPhrase(secret, passphrase));    const pub = getPublicKey(priv, true);    if (bytesToHex(await hash160(pub)) !== vaultInfo.pubkeyHash) throw new Error('these words do not own this vault');    const script = hexToBytes(vaultInfo.witnessScript);    const dest = segwitDecode(toAddress);    const outSpk = enc([new Uint8Array([0x00, dest.program.length / 2]), hexToBytes(dest.program)]);    // vsize: 10.5 overhead + ~41 base + (1+73+1+33+1+script)/4 witness per input + output    const perIn = 41 + (1 + 1 + 73 + 1 + 33 + 1 + script.length) / 4;    const vsize = Math.ceil(10.5 + perIn * utxos.length + 9 + outSpk.length);    const fee = Math.max(minFee, Math.ceil(vsize * feeRate));    const total = utxos.reduce((s, u) => s + u.value, 0);    const amount = total - fee;    if (amount < 3000) throw new Error('not enough in the vault to cover the fee');    const version = u32(2), locktime = u32(vaultInfo.unlockTime), seq = u32(0xfffffffe), sighashType = u32(1);    const outs = enc([u64(amount), varint(outSpk.length), outSpk]);    const hashPrevouts = await dsha256(enc(utxos.map(u => enc([hexToBytes(u.txid).reverse(), u32(u.vout)]))));    const hashSequence = await dsha256(enc(utxos.map(() => seq)));    const hashOutputs = await dsha256(outs);    const scriptCode = enc([varint(script.length), script]);    const witnesses = [];    for (const u of utxos) {        const pre = enc([version, hashPrevouts, hashSequence, hexToBytes(u.txid).reverse(), u32(u.vout), scriptCode, u64(u.value), seq, hashOutputs, locktime, sighashType]);        let sig = await signAsync(await dsha256(pre), priv); sig = sig.normalizeS();        witnesses.push([enc([der(sig.r, sig.s), new Uint8Array([1])]), pub, script]);    }    const vin = enc(utxos.map(u => enc([hexToBytes(u.txid).reverse(), u32(u.vout), new Uint8Array([0]), seq])));    const vout = enc([varint(1), outs]);    const wit = enc(witnesses.map(w => enc([varint(w.length), ...w.map(x => enc([varint(x.length), x]))])));    const raw = enc([version, new Uint8Array([0, 1]), varint(utxos.length), vin, vout, wit, locktime]);    const txid = bytesToHex((await dsha256(enc([version, varint(utxos.length), vin, vout, locktime]))).reverse());    return { rawHex: bytesToHex(raw), txid, fee, amount, vsize };}