mirror of
https://github.com/Permissionless-Software-Foundation/psffpp-payments.git
synced 2026-09-21 16:52:04 -07:00
336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Cosign a ShareContract claim and write a JSON file another node can use to broadcast.
|
|
*
|
|
* IMPORTANT: ShareContract.claim uses CashScript checkSig over the *claim transaction*.
|
|
* bch-js BitcoinCash.signMessageWithPrivKey() signs "Bitcoin Signed Message:\\n…" and
|
|
* CANNOT unlock the share. This script produces real tx ECDSA signatures via CashScript
|
|
* SignatureTemplate (SIGHASH_ALL | SIGHASH_UTXOS), loaded from a WIF/mnemonic through
|
|
* minimal-slp-wallet (bch-js inside).
|
|
*
|
|
* Fee is taken from the share UTXO (single payout output; no companion fee input).
|
|
*
|
|
* Usage:
|
|
* node scripts/claim-cosign.mjs --wif <WIF> --share-index 0 [--out claim-sig.json]
|
|
* node scripts/claim-cosign.mjs --mnemonic "..." --share-index 1 --utxo <txid>:<vout>
|
|
*
|
|
* Docs: https://www.npmjs.com/package/minimal-slp-wallet
|
|
* https://bch-js.fullstackcash.net
|
|
* https://cashscript.org/docs/sdk/signature-templates
|
|
*/
|
|
import BchWallet from 'minimal-slp-wallet'
|
|
import {
|
|
TransactionBuilder,
|
|
SignatureTemplate,
|
|
HashType,
|
|
SignatureAlgorithm
|
|
} from 'cashscript'
|
|
import { binToHex, encodeCashAddress, hexToBin } from '@bitauth/libauth'
|
|
import { writeFileSync } from 'node:fs'
|
|
import { join, resolve } from 'node:path'
|
|
import {
|
|
loadDeployConfig,
|
|
createProvider,
|
|
instantiateContracts,
|
|
root
|
|
} from './lib.mjs'
|
|
|
|
const DEFAULT_REST = 'https://free-bch.fullstack.cash'
|
|
const DEFAULT_FEE_RATE = 1.2
|
|
const SIGHASH = HashType.SIGHASH_ALL | HashType.SIGHASH_UTXOS
|
|
const EMPTY_SIG = Uint8Array.of()
|
|
|
|
function parseArgs (argv) {
|
|
const args = {
|
|
configPath: join(root, 'config', 'deploy.mainnet.json'),
|
|
shareIndex: null,
|
|
utxo: null,
|
|
out: null,
|
|
dryRun: false,
|
|
feeRate: DEFAULT_FEE_RATE,
|
|
restURL: DEFAULT_REST,
|
|
wif: null,
|
|
mnemonic: null,
|
|
payoutSatoshis: null
|
|
}
|
|
|
|
const positionals = []
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i]
|
|
if (a === '--dry-run') args.dryRun = true
|
|
else if (a === '--fee-rate') args.feeRate = Number(argv[++i])
|
|
else if (a === '--rest-url') args.restURL = argv[++i]
|
|
else if (a === '--wif') args.wif = argv[++i]
|
|
else if (a === '--mnemonic') args.mnemonic = argv[++i]
|
|
else if (a === '--share-index') args.shareIndex = Number(argv[++i])
|
|
else if (a === '--utxo') args.utxo = argv[++i]
|
|
else if (a === '--out') args.out = argv[++i]
|
|
else if (a === '--payout-satoshis') args.payoutSatoshis = BigInt(argv[++i])
|
|
else if (a.startsWith('-')) throw new Error(`Unknown flag: ${a}`)
|
|
else positionals.push(a)
|
|
}
|
|
|
|
if (positionals[0]) args.configPath = positionals[0]
|
|
if (!Number.isFinite(args.feeRate) || args.feeRate <= 0) {
|
|
throw new Error('--fee-rate must be a positive number')
|
|
}
|
|
if (!args.wif && !args.mnemonic) {
|
|
throw new Error('Provide cosigner key via --wif <WIF> or --mnemonic "..."')
|
|
}
|
|
if (args.wif && args.mnemonic) {
|
|
throw new Error('Pass only one of --wif or --mnemonic')
|
|
}
|
|
if (!Number.isInteger(args.shareIndex) || args.shareIndex < 0 || args.shareIndex > 2) {
|
|
throw new Error('--share-index must be 0, 1, or 2 (which node share is being claimed)')
|
|
}
|
|
return args
|
|
}
|
|
|
|
function pkhToCashAddr (pkhHex, network) {
|
|
const prefix = network === 'mainnet' || !network ? 'bitcoincash' : 'bchtest'
|
|
return encodeCashAddress({
|
|
prefix,
|
|
type: 'p2pkh',
|
|
payload: hexToBin(pkhHex),
|
|
throwErrors: true
|
|
}).address
|
|
}
|
|
|
|
function parseUtxoRef (ref) {
|
|
const [txid, voutStr] = ref.split(':')
|
|
const vout = Number(voutStr)
|
|
if (!txid || !Number.isInteger(vout) || vout < 0) {
|
|
throw new Error(`--utxo must be <txid>:<vout>, got ${ref}`)
|
|
}
|
|
return { txid, vout }
|
|
}
|
|
|
|
function feeFromHex (hex, feeRate) {
|
|
const bytes = hex.length / 2
|
|
const atRate = BigInt(Math.ceil(bytes * feeRate))
|
|
const minOne = BigInt(Math.ceil(bytes))
|
|
return atRate > minOne ? atRate : minOne
|
|
}
|
|
|
|
async function createWallet ({ wif, mnemonic, restURL }) {
|
|
const wallet = new BchWallet(wif || mnemonic, {
|
|
interface: 'consumer-api',
|
|
restURL
|
|
})
|
|
await wallet.walletInfoPromise
|
|
return wallet
|
|
}
|
|
|
|
/**
|
|
* Build claim args: SignatureTemplate in signerSlot, empty elsewhere.
|
|
* Capture the tx ECDSA signature bytes when CashScript generates them.
|
|
*/
|
|
function claimArgsCapturing (signerSlot, template) {
|
|
const captured = { signature: null }
|
|
const orig = template.generateSignature.bind(template)
|
|
template.generateSignature = (payload, bchForkId) => {
|
|
const sig = orig(payload, bchForkId)
|
|
captured.signature = sig
|
|
return sig
|
|
}
|
|
const args = [EMPTY_SIG, EMPTY_SIG, EMPTY_SIG]
|
|
args[signerSlot] = template
|
|
const restore = () => {
|
|
template.generateSignature = orig
|
|
}
|
|
return { args, captured, restore }
|
|
}
|
|
|
|
async function main () {
|
|
const args = parseArgs(process.argv.slice(2))
|
|
const cfg = loadDeployConfig(args.configPath)
|
|
const network = cfg.network || 'mainnet'
|
|
const provider = createProvider(network)
|
|
const { shares } = instantiateContracts(cfg, provider)
|
|
|
|
const share = shares[args.shareIndex]
|
|
const payoutPkh = cfg.nodes[args.shareIndex].pkh
|
|
const payoutAddress = pkhToCashAddr(payoutPkh, network)
|
|
|
|
let utxos = (await share.getUtxos()).filter((u) => !u.token)
|
|
if (args.utxo) {
|
|
const ref = parseUtxoRef(args.utxo)
|
|
utxos = utxos.filter((u) => u.txid === ref.txid && u.vout === ref.vout)
|
|
if (utxos.length === 0) {
|
|
throw new Error(`Share UTXO not found: ${args.utxo}`)
|
|
}
|
|
}
|
|
if (utxos.length === 0) {
|
|
console.error(JSON.stringify({
|
|
ok: false,
|
|
error: 'No share UTXOs found',
|
|
shareIndex: args.shareIndex,
|
|
shareAddress: share.address
|
|
}, null, 2))
|
|
process.exit(1)
|
|
}
|
|
if (utxos.length > 1 && !args.utxo) {
|
|
console.error(JSON.stringify({
|
|
ok: false,
|
|
error: 'Multiple share UTXOs — pass --utxo <txid>:<vout>',
|
|
shareAddress: share.address,
|
|
utxos: utxos.map((u) => ({
|
|
txid: u.txid,
|
|
vout: u.vout,
|
|
satoshis: u.satoshis.toString()
|
|
}))
|
|
}, null, 2))
|
|
process.exit(1)
|
|
}
|
|
|
|
const shareUtxo = utxos[0]
|
|
const wallet = await createWallet(args)
|
|
const wif = wallet.walletInfo.privateKey
|
|
const template = new SignatureTemplate(wif, SIGHASH, SignatureAlgorithm.ECDSA)
|
|
const signerPubHex = binToHex(template.getPublicKey())
|
|
|
|
const signerSlot = cfg.nodes.findIndex(
|
|
(n) => n.pubkey.toLowerCase() === signerPubHex.toLowerCase()
|
|
)
|
|
if (signerSlot < 0) {
|
|
throw new Error(
|
|
`Cosigner pubkey ${signerPubHex} is not one of config.nodes[].pubkey — wrong key?`
|
|
)
|
|
}
|
|
|
|
// Fee from share: settle payout so miner fee meets feeRate / ≥1 sat-byte.
|
|
let payout
|
|
let fee
|
|
let signatureHex
|
|
|
|
if (args.payoutSatoshis != null) {
|
|
payout = args.payoutSatoshis
|
|
if (payout <= 0n || payout >= shareUtxo.satoshis) {
|
|
throw new Error(`Invalid --payout-satoshis ${payout} for share ${shareUtxo.satoshis}`)
|
|
}
|
|
const { args: claimArgs, captured, restore } = claimArgsCapturing(signerSlot, template)
|
|
try {
|
|
const hex = new TransactionBuilder({ provider })
|
|
.addInput(shareUtxo, share.unlock.claim(...claimArgs))
|
|
.addOutput({ to: payoutAddress, amount: payout })
|
|
.build()
|
|
fee = shareUtxo.satoshis - payout
|
|
const needed = feeFromHex(hex, args.feeRate)
|
|
if (fee < needed) {
|
|
throw new Error(
|
|
`Payout leaves fee ${fee} but tx needs ~${needed} at feeRate ${args.feeRate}`
|
|
)
|
|
}
|
|
signatureHex = binToHex(captured.signature)
|
|
} finally {
|
|
restore()
|
|
}
|
|
} else {
|
|
fee = 800n
|
|
payout = shareUtxo.satoshis - fee
|
|
for (let i = 0; i < 5; i++) {
|
|
const { args: claimArgs, captured, restore } = claimArgsCapturing(signerSlot, template)
|
|
try {
|
|
const hex = new TransactionBuilder({ provider })
|
|
.addInput(shareUtxo, share.unlock.claim(...claimArgs))
|
|
.addOutput({ to: payoutAddress, amount: payout })
|
|
.build()
|
|
const needed = feeFromHex(hex, args.feeRate)
|
|
const nextPayout = shareUtxo.satoshis - needed
|
|
if (nextPayout <= 0n) {
|
|
throw new Error(`Share ${shareUtxo.satoshis} too small to cover fee ${needed}`)
|
|
}
|
|
signatureHex = binToHex(captured.signature)
|
|
fee = needed
|
|
if (nextPayout === payout) break
|
|
payout = nextPayout
|
|
} finally {
|
|
restore()
|
|
}
|
|
}
|
|
// Final capture at settled payout
|
|
{
|
|
const { args: claimArgs, captured, restore } = claimArgsCapturing(signerSlot, template)
|
|
try {
|
|
new TransactionBuilder({ provider })
|
|
.addInput(shareUtxo, share.unlock.claim(...claimArgs))
|
|
.addOutput({ to: payoutAddress, amount: payout })
|
|
.build()
|
|
signatureHex = binToHex(captured.signature)
|
|
fee = shareUtxo.satoshis - payout
|
|
} finally {
|
|
restore()
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!signatureHex) {
|
|
throw new Error('Failed to capture ECDSA transaction signature')
|
|
}
|
|
|
|
const doc = {
|
|
type: 'psffpp-share-claim-sig',
|
|
version: 1,
|
|
network,
|
|
claim: {
|
|
shareIndex: args.shareIndex,
|
|
shareName: cfg.nodes[args.shareIndex].name || `node${args.shareIndex}`,
|
|
shareAddress: share.address,
|
|
shareUtxo: {
|
|
txid: shareUtxo.txid,
|
|
vout: shareUtxo.vout,
|
|
satoshis: shareUtxo.satoshis.toString()
|
|
},
|
|
payoutAddress,
|
|
payoutSatoshis: payout.toString(),
|
|
feeSatoshis: fee.toString(),
|
|
feeMode: 'from_share',
|
|
sighash: 'ALL|UTXOS',
|
|
signatureAlgorithm: 'ECDSA',
|
|
constructorPubkeys: cfg.nodes.map((n) => n.pubkey.toLowerCase())
|
|
},
|
|
signer: {
|
|
nodeIndex: signerSlot,
|
|
name: cfg.nodes[signerSlot].name || `node${signerSlot}`,
|
|
pubkey: signerPubHex.toLowerCase(),
|
|
cashAddress: wallet.walletInfo.cashAddress
|
|
},
|
|
signature: signatureHex,
|
|
notes: [
|
|
'This signature unlocks ShareContract.claim for the exact claim tx described above.',
|
|
'Do not change payoutSatoshis, fee, or UTXO without re-collecting signatures.',
|
|
'Unused claim slots must be empty bytes (0x), never invalid non-empty sigs.',
|
|
'BitcoinCash.signMessageWithPrivKey message signatures are NOT valid here.'
|
|
]
|
|
}
|
|
|
|
const outPath = resolve(
|
|
args.out ||
|
|
join(root, 'deployments', `claim-sig-share${args.shareIndex}-by-node${signerSlot}.json`)
|
|
)
|
|
|
|
if (!args.dryRun) {
|
|
writeFileSync(outPath, JSON.stringify(doc, null, 2) + '\n')
|
|
}
|
|
|
|
console.log(JSON.stringify({
|
|
wrote: args.dryRun ? null : outPath,
|
|
dryRun: args.dryRun,
|
|
shareIndex: args.shareIndex,
|
|
signerNodeIndex: signerSlot,
|
|
payoutAddress,
|
|
payoutSatoshis: payout.toString(),
|
|
feeSatoshis: fee.toString(),
|
|
signature: signatureHex
|
|
}, null, 2))
|
|
|
|
if (args.dryRun) {
|
|
console.log(JSON.stringify(doc, null, 2))
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|