/* MoonBite transaction building and signing — real Bitcoin-family segwit. * * Builds a standard P2WPKH (native segwit) transaction and signs each input * with BIP143, exactly as MoonBite Core validates. The private key never * leaves the browser; only the finished raw transaction is broadcast. * * Single-key wallet: every UTXO is on the wallet's own key (m/84'/2'/0'/0/0), * so one key signs all inputs. */ import { getPublicKey, signAsync } from './vendor/noble-secp256k1.js?v=20260904a'; import { hash160, bytesToHex, hexToBytes } from './moonbite-hd.js?v=20260904a'; import { pubkeyHashFromAddress, privkeyFromSeedPhrase } from './moonbite-address.js?v=20261016e'; const enc = new TextEncoder(); function concat(...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;} function u32le(n){return new Uint8Array([n&255,(n>>>8)&255,(n>>>16)&255,(n>>>24)&255]);} function u64le(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;} function varint(n){if(n<0xfd)return new Uint8Array([n]);if(n<=0xffff)return concat(new Uint8Array([0xfd]),new Uint8Array([n&255,(n>>8)&255]));return concat(new Uint8Array([0xfe]),u32le(n));} function revHex(h){return hexToBytes(h).reverse();} async function sha256(b){return new Uint8Array(await crypto.subtle.digest('SHA-256',b));} async function dsha256(b){return sha256(await sha256(b));} /* scriptPubKey for a P2WPKH address (moon1…): OP_0 <20-byte hash>. */ function spkFromPubkeyHash(pkhHex){return concat(new Uint8Array([0x00,0x14]),hexToBytes(pkhHex));} /* scriptCode for signing a P2WPKH input: the classic P2PKH script. */ function scriptCode(pkhHex){return concat(new Uint8Array([0x19,0x76,0xa9,0x14]),hexToBytes(pkhHex),new Uint8Array([0x88,0xac]));} function bigToMinimal(x){ let h=x.toString(16); if(h.length%2)h='0'+h; let b=hexToBytes(h); if(b.length && (b[0]&0x80)) b=concat(new Uint8Array([0]),b); return b; } function derSig(r,s){ const R=bigToMinimal(r), S=bigToMinimal(s); const body=concat(new Uint8Array([0x02,R.length]),R,new Uint8Array([0x02,S.length]),S); return concat(new Uint8Array([0x30,body.length]),body); } export function selectUTXOs(utxos,target){ const sorted=[...utxos].sort((a,b)=>b.value-a.value); const chosen=[]; let total=0; for(const u of sorted){chosen.push(u);total+=u.value;if(total>=target)break;} return {chosen,total}; } /* ---- fees: priced by size, like the network does ---- * Core relays at 1 base unit per vbyte; we pay 2 for headroom, never less * than 1,000 units (0.00001 MBITE). A typical send (1 in, 2 out, ~141 vB) * costs the 1,000 floor; a 50-coin miner sweep about 6,900 units. * DUST: this chain's dust relay fee (30,000/kvB) makes any P2WPKH output * under 2,940 units non-standard, so change below that goes to the fee. */ export const FEE_RATE=2, MIN_FEE=1000, DUST_UNITS=3000; export function estimateVsize(nIn,nOut){return Math.ceil(10.5+68*nIn+31*nOut);} export function feeFor(nIn,nOut,rate=FEE_RATE){return Math.max(MIN_FEE,Math.ceil(estimateVsize(nIn,nOut)*rate));} /* Pick coins (largest first) for amountUnits and price the result. * Returns {chosen,total,fee,change} or null when the coins can't cover it. */ export function planSpend(utxos,amountUnits,{dustUnits=DUST_UNITS,rate=FEE_RATE}={}){ const sorted=[...utxos].sort((a,b)=>b.value-a.value); const chosen=[]; let total=0; for(const u of sorted){ chosen.push(u); total+=u.value; const fee2=feeFor(chosen.length,2,rate); if(total-amountUnits-fee2>=dustUnits) return {chosen,total,fee:fee2,change:total-amountUnits-fee2}; const fee1=feeFor(chosen.length,1,rate); if(total-amountUnits>=fee1) return {chosen,total,fee:total-amountUnits,change:0}; // sub-dust change -> fee } return null; } /* Largest amount sendable to one address using every coin. */ export function maxSendable(utxos,rate=FEE_RATE){ if(!utxos.length) return 0; const total=utxos.reduce((s,u)=>s+u.value,0); return Math.max(0,total-feeFor(utxos.length,1,rate)); } /* Build + sign. Returns { rawHex, txid, fee, change }. Amounts in base units. * Pass feeUnits for a fixed fee, or omit it to price by size (planSpend). */ export async function buildSignedTransaction({toAddress,toPubkeyHash,amountUnits,feeUnits,privkeyHex,seedPhrase,utxos,dustUnits=DUST_UNITS}){ if(!privkeyHex && seedPhrase) privkeyHex = await privkeyFromSeedPhrase(seedPhrase); const priv=hexToBytes(privkeyHex); const pub=getPublicKey(priv,true); const myPkh=bytesToHex(await hash160(pub)); const toPkh=toPubkeyHash||pubkeyHashFromAddress(toAddress); let chosen,total,change; if(feeUnits==null){ const plan=planSpend(utxos,amountUnits,{dustUnits}); if(!plan) throw new Error('Not enough balance for that amount plus the network fee'); ({chosen,total,change}=plan); feeUnits=plan.fee; }else{ const target=amountUnits+feeUnits; ({chosen,total}=selectUTXOs(utxos,target)); if(total0?2:1,1)){ const plan=planSpend(utxos,amountUnits,{dustUnits}); if(!plan) throw new Error('Not enough balance for that amount plus the network fee'); ({chosen,total,change}=plan); feeUnits=plan.fee; } } const outputs=[{value:amountUnits,pkh:toPkh}]; if(change>0) outputs.push({value:change,pkh:myPkh}); // --- BIP143 shared hashes --- const prevouts=concat(...chosen.map(u=>concat(revHex(u.txid),u32le(u.vout)))); const sequences=concat(...chosen.map(()=>u32le(0xffffffff))); const outsSer=concat(...outputs.map(o=>concat(u64le(o.value),varint(spkFromPubkeyHash(o.pkh).length),spkFromPubkeyHash(o.pkh)))); const hashPrevouts=await dsha256(prevouts); const hashSequence=await dsha256(sequences); const hashOutputs=await dsha256(outsSer); const version=u32le(2), locktime=u32le(0), sighashType=u32le(1); const sc=scriptCode(myPkh); const witnesses=[]; for(const u of chosen){ const outpoint=concat(revHex(u.txid),u32le(u.vout)); const preimage=concat(version,hashPrevouts,hashSequence,outpoint, sc,u64le(u.value),u32le(0xffffffff),hashOutputs,locktime,sighashType); const sighash=await dsha256(preimage); let sig=await signAsync(sighash,priv); sig=sig.normalizeS(); const der=concat(derSig(sig.r,sig.s),new Uint8Array([0x01])); witnesses.push([der,pub]); } // --- serialize (segwit) --- const vin=concat(...chosen.map(u=>concat(revHex(u.txid),u32le(u.vout),new Uint8Array([0x00]),u32le(0xffffffff)))); const vout=concat(varint(outputs.length),outsSer); const witSer=concat(...witnesses.map(w=>concat(varint(w.length),...w.map(item=>concat(varint(item.length),item))))); const raw=concat(version,new Uint8Array([0x00,0x01]),varint(chosen.length),vin,vout,witSer,locktime); // txid = dsha256 of the non-witness serialization, reversed const nonwit=concat(version,varint(chosen.length),vin,vout,locktime); const txid=bytesToHex((await dsha256(nonwit)).reverse()); return {rawHex:bytesToHex(raw),txid,fee:feeUnits,change}; } /* Sweep: spend spendable coins to ONE address, no change output (used when a * holder moves to a new recovery phrase). Takes at most maxInputs of the * largest coins so the tx stays far below the standard-size limit; anything * left is swept by running it again. Same signer as a normal send. * Returns { rawHex, txid, fee, amountUnits, inputs, remaining }. */ export async function buildSweepTransaction({toAddress,seedPhrase,privkeyHex,utxos,feeUnits,maxInputs=500,dustUnits=DUST_UNITS}){ const coins=[...utxos].sort((a,b)=>b.value-a.value).slice(0,maxInputs); const total=coins.reduce((s,u)=>s+u.value,0); if(feeUnits==null) feeUnits=feeFor(coins.length,1); const amountUnits=total-feeUnits; if(amountUnits