/* MoonBite time-lock vault (ADR-014). * * A vault is a P2WSH output whose witness script is * * 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 * 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 }; }