git / moonbite-network / static/moonbite-tx.js · raw
/* 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(total<target) throw new Error('Not enough balance (need '+(target)+', have '+total+')'); change=total-target; if(change<dustUnits){feeUnits+=change;change=0;} // A chosen fixed fee must still clear the network's 1 unit/vB relay // floor; a many-coin spend at a "slow" fee would otherwise be refused. if(feeUnits<feeFor(chosen.length,change>0?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<dustUnits) throw new Error('Not enough to move after the network fee'); const built=await buildSignedTransaction({toAddress,amountUnits,feeUnits,privkeyHex,seedPhrase,utxos:coins,dustUnits}); if(built.change!==0) throw new Error('Sweep produced change'); // all chosen coins must go out return {...built,amountUnits,inputs:coins.length,remaining:utxos.length-coins.length};}