🚀 Socket Launch Week Day 4:Socket MCP Adds Org Alerts, Threat Feed Review, and Package Inspection.Learn more
Sign In

@powforge/captcha-mcp

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@powforge/captcha-mcp - npm Package Compare versions

Comparing version
0.2.0
to
0.2.1
+21
LICENSE
MIT License
Copyright (c) 2026 PowForge
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
/**
* @powforge/captcha-mcp — Rune Phase 3a (PSBT builder)
*
* Tick 548, build/142 phase 3a. Phase 1 (scaffold) shipped MR !396.
* Phase 2 (real Runestone bytes) shipped MR !397. This module adds:
*
* • fetchUtxosFromMempoolSpace(address) — read UTXOs for a P2TR address
* via the public mempool.space REST API (no Bitcoin Core RPC needed).
* • buildRuneTransferPsbt({ utxo, runestoneScriptpubkey, recipientAddress,
* changeAddress, feeRate, internalKey }) — assemble an unsigned PSBT
* using @scure/btc-signer that transfers one parcel of POWFORGE•PROOF
* to recipientAddress with the change UTXO holding the relay's remaining
* supply.
* • assertPsbtRoundTrip(psbtBase64) — sanity check that the produced PSBT
* parses back through Transaction.fromPSBT() with all inputs+outputs
* preserved.
*
* Phase 3b (next tick) wires:
* • signing with the minting key
* • sendrawtransaction broadcast via lightning.lan:8332 (RPC permission
* confirmed tick 547) with mempool.space fallback
*
* Design rationale (research/tick-546-rune-phase3-prerequisites.md):
* • PSBT lib = @scure/btc-signer 2.2.0 (same author family as @noble/*
* already in the tree, two-package dep footprint).
* • allowUnknownOutputs: true because btc-signer rejects OP_RETURN by
* default; Runestone is OP_RETURN-by-design.
* • Output layout per the phase-2 transfer template:
* 0 = OP_RETURN Runestone (value=0)
* 1 = recipient P2TR (value=546 sats dust)
* 2 = relay-change P2TR (value=funding - 546 - fee)
* This is the canonical "edict points at output 1" layout that ord and
* runestone-lib both parse cleanly. Output 0 first means low-fee filters
* do not strip the data carrier.
*/
'use strict';
const btc = require('@scure/btc-signer');
// =============================================================================
// MEMPOOL.SPACE UTXO FETCH
// =============================================================================
const MEMPOOL_API_BASE = 'https://mempool.space/api';
/**
* Fetch UTXOs for a Bitcoin mainnet address from mempool.space. Returns the
* raw UTXO list shape mempool.space publishes:
* [{ txid: hex, vout: number, value: satsNumber, status: {...} }, ...]
*
* The status field carries confirmed/block_height/block_hash/block_time. We
* filter to confirmed UTXOs only by default (an unconfirmed UTXO is not safe
* to spend in a tx the relay will broadcast).
*
* Throws on network error or non-200 status. Returns [] when the address has
* no UTXOs (mempool.space returns an empty array for a fresh address).
*
* @param {string} address — Bitcoin mainnet address (P2TR, P2WPKH, P2PKH, P2SH).
* @param {object} [opts]
* @param {boolean} [opts.confirmedOnly=true] — filter to confirmed UTXOs.
* @param {Function} [opts.fetchImpl=globalThis.fetch] — fetch override for tests.
* @returns {Promise<Array>}
*/
async function fetchUtxosFromMempoolSpace(address, opts = {}) {
if (typeof address !== 'string' || address.length < 10) {
throw new Error('fetchUtxosFromMempoolSpace: address required');
}
const confirmedOnly = opts.confirmedOnly !== false;
const fetchImpl = opts.fetchImpl || globalThis.fetch;
if (typeof fetchImpl !== 'function') {
throw new Error('fetchUtxosFromMempoolSpace: fetch implementation required (Node 18+ or pass opts.fetchImpl)');
}
const url = `${MEMPOOL_API_BASE}/address/${encodeURIComponent(address)}/utxo`;
const res = await fetchImpl(url, { headers: { 'Accept': 'application/json' } });
if (!res.ok) {
throw new Error(`fetchUtxosFromMempoolSpace: HTTP ${res.status} for ${address}`);
}
const utxos = await res.json();
if (!Array.isArray(utxos)) {
throw new Error('fetchUtxosFromMempoolSpace: expected array response');
}
if (!confirmedOnly) return utxos;
return utxos.filter((u) => u && u.status && u.status.confirmed === true);
}
/**
* Pick the largest confirmed UTXO from a list — the most-balance-per-input
* strategy that suits the chained-relay model (we want the head UTXO that
* carries the running balance of the launch).
*
* @param {Array} utxos — output of fetchUtxosFromMempoolSpace.
* @returns {object|null} — largest UTXO or null when list is empty.
*/
function pickLargestUtxo(utxos) {
if (!Array.isArray(utxos) || utxos.length === 0) return null;
return utxos.reduce((best, cur) => {
if (!best) return cur;
return cur.value > best.value ? cur : best;
}, null);
}
// =============================================================================
// PSBT CONSTRUCTION
// =============================================================================
/**
* Build an unsigned PSBT that transfers one Rune parcel.
*
* Output layout (canonical for ord + runestone-lib):
* • Output 0: OP_RETURN Runestone (0 sats)
* • Output 1: recipient P2TR address (546 sats dust — receives parcel)
* • Output 2: relay change P2TR (remainder = utxo.value - 546 - fee)
*
* The Runestone edict in the OP_RETURN bytes MUST point at output 1 (the
* recipient). The runestone-lib encoder produced that exact assignment via
* encodeRunestoneEdict({ output: 1, ... }) — DO NOT override that here.
*
* @param {object} args
* @param {object} args.utxo — { txid, vout, value, scriptPubKey?: hex string }
* @param {Buffer} args.runestoneScriptpubkey — output of encodeRunestoneEdict()
* @param {string} args.recipientAddress — mainnet bech32/legacy address
* @param {string} args.changeAddress — relay's own change address (typically
* the same address as the input UTXO when chaining the relay cursor)
* @param {Uint8Array} args.internalKey — 32-byte x-only pubkey for the
* input's P2TR witness (Taproot key-path spend). Used to derive the
* witnessUtxo.script for signing.
* @param {number} [args.feeRate=2] — sats per vbyte. 2 is the post-2024
* "low priority" rate that confirms within ~24h. Bump for urgency.
* @param {number} [args.dustLimit=546] — output 1 value in sats.
* @returns {{ psbtBase64: string, psbtHex: string, txBytes: Uint8Array,
* estimatedVbytes: number, feeSats: number, changeSats: number }}
*/
function buildRuneTransferPsbt({
utxo,
runestoneScriptpubkey,
recipientAddress,
changeAddress,
internalKey,
feeRate = 2,
dustLimit = 546,
}) {
// -- input validation
if (!utxo || typeof utxo.txid !== 'string' || typeof utxo.vout !== 'number' || typeof utxo.value !== 'number') {
throw new Error('buildRuneTransferPsbt: utxo must be { txid, vout, value }');
}
if (!Buffer.isBuffer(runestoneScriptpubkey) && !(runestoneScriptpubkey instanceof Uint8Array)) {
throw new Error('buildRuneTransferPsbt: runestoneScriptpubkey must be Buffer/Uint8Array');
}
if (typeof recipientAddress !== 'string' || recipientAddress.length < 10) {
throw new Error('buildRuneTransferPsbt: recipientAddress required');
}
if (typeof changeAddress !== 'string' || changeAddress.length < 10) {
throw new Error('buildRuneTransferPsbt: changeAddress required');
}
if (!(internalKey instanceof Uint8Array) || internalKey.length !== 32) {
throw new Error('buildRuneTransferPsbt: internalKey must be 32-byte Uint8Array (x-only pubkey)');
}
// -- vbyte estimate for fee budgeting
// Transfer relay tx shape per research/tick-546-rune-phase3-prerequisites.md §3:
// 1 P2TR input (key-path Schnorr) ≈ 57.5 vbytes
// OP_RETURN Runestone output ≈ 10 + payload bytes
// 2 × P2TR output ≈ 43 vbytes each
// Tx overhead ≈ 10.5 vbytes
// Total ≈ 164 vbytes for a typical edict.
const runestoneBytes = runestoneScriptpubkey.length;
const estimatedVbytes = 10 + 58 + (10 + runestoneBytes) + 43 + 43;
const feeSats = Math.ceil(estimatedVbytes * feeRate);
const changeSats = utxo.value - dustLimit - feeSats;
if (changeSats < dustLimit) {
throw new Error(
`buildRuneTransferPsbt: change ${changeSats} sats below dust limit ${dustLimit} ` +
`(input ${utxo.value}, dust ${dustLimit}, fee ${feeSats}). Top up the relay UTXO or lower feeRate.`
);
}
// -- build the P2TR script for the input's witnessUtxo
// btc-signer's p2tr(internalKey) returns { script, address, ... }
const inputP2tr = btc.p2tr(internalKey, undefined, btc.NETWORK);
// -- assemble PSBT
// allowUnknownOutputs: true is required because btc-signer rejects
// OP_RETURN scripts by default as a footgun-prevention measure. Runestone
// IS an OP_RETURN, so we explicitly opt in.
const tx = new btc.Transaction({ allowUnknownOutputs: true });
tx.addInput({
txid: utxo.txid,
index: utxo.vout,
witnessUtxo: {
script: inputP2tr.script,
amount: BigInt(utxo.value),
},
tapInternalKey: internalKey,
});
// Output 0: OP_RETURN Runestone (zero value)
// We pass the raw scriptpubkey bytes directly (it already includes the
// OP_RETURN + OP_13 prefix and the encoded edict body).
tx.addOutput({
script: runestoneScriptpubkey,
amount: 0n,
});
// Output 1: recipient P2TR — receives the parcel
tx.addOutputAddress(recipientAddress, BigInt(dustLimit), btc.NETWORK);
// Output 2: relay change
tx.addOutputAddress(changeAddress, BigInt(changeSats), btc.NETWORK);
// -- serialize the PSBT (unsigned)
const psbtBytes = tx.toPSBT();
const psbtHex = bytesToHex(psbtBytes);
const psbtBase64 = Buffer.from(psbtBytes).toString('base64');
return {
psbtBase64,
psbtHex,
psbtBytes,
estimatedVbytes,
feeSats,
changeSats,
feeRate,
inputAmount: utxo.value,
inputScript: bytesToHex(inputP2tr.script),
};
}
// =============================================================================
// PSBT VALIDATION
// =============================================================================
/**
* Round-trip sanity check: re-parse the produced PSBT and assert the input
* count, output count, and amounts survive. Throws on any mismatch.
*
* @param {Uint8Array} psbtBytes — output of buildRuneTransferPsbt().psbtBytes
* @param {object} expectations — { inputCount, outputCount, outputAmounts? }
* @returns {{ inputCount: number, outputCount: number, outputAmounts: bigint[], runestoneFound: boolean }}
*/
function assertPsbtRoundTrip(psbtBytes, expectations = {}) {
if (!(psbtBytes instanceof Uint8Array) && !Buffer.isBuffer(psbtBytes)) {
throw new Error('assertPsbtRoundTrip: psbtBytes must be Uint8Array/Buffer');
}
const tx = btc.Transaction.fromPSBT(psbtBytes, { allowUnknownOutputs: true });
const inputCount = tx.inputsLength;
const outputCount = tx.outputsLength;
const outputAmounts = [];
let runestoneFound = false;
for (let i = 0; i < outputCount; i++) {
const out = tx.getOutput(i);
outputAmounts.push(out.amount);
// Detect Runestone by OP_RETURN (0x6a) + OP_13 (0x5d) prefix
if (out.script && out.script.length >= 2 && out.script[0] === 0x6a && out.script[1] === 0x5d) {
runestoneFound = true;
}
}
if (expectations.inputCount != null && inputCount !== expectations.inputCount) {
throw new Error(`assertPsbtRoundTrip: inputCount ${inputCount} != expected ${expectations.inputCount}`);
}
if (expectations.outputCount != null && outputCount !== expectations.outputCount) {
throw new Error(`assertPsbtRoundTrip: outputCount ${outputCount} != expected ${expectations.outputCount}`);
}
if (expectations.expectRunestone && !runestoneFound) {
throw new Error('assertPsbtRoundTrip: expected OP_RETURN+OP_13 Runestone output but none found');
}
return { inputCount, outputCount, outputAmounts, runestoneFound };
}
// =============================================================================
// HELPERS
// =============================================================================
function bytesToHex(u8) {
if (u8 instanceof Uint8Array || Buffer.isBuffer(u8)) {
return Buffer.from(u8).toString('hex');
}
throw new Error('bytesToHex: expected Uint8Array/Buffer');
}
// =============================================================================
// EXPORTS
// =============================================================================
module.exports = {
// UTXO fetching (Phase 3a)
fetchUtxosFromMempoolSpace,
pickLargestUtxo,
// PSBT building (Phase 3a)
buildRuneTransferPsbt,
assertPsbtRoundTrip,
// Constants
MEMPOOL_API_BASE,
// Private helpers exposed for tests
_bytesToHex: bytesToHex,
};
#!/usr/bin/env node
/**
* @powforge/captcha-mcp — Rune PoW Fair-Launch Phase 3b: Etch Broadcaster
*
* Tick 550, build/141 phase 3b. Reads the minting key and commitment UTXO
* state, builds the etching transaction, signs it via Taproot script-path
* spend (revealing the rune name commitment per the ord commit-reveal spec),
* and broadcasts to Bitcoin mainnet.
*
* Prerequisites before running:
* 1. scripts/generate-rune-minting-key.js must have been run (key exists).
* 2. The commitment P2TR address must be funded (>=25,000 sats via Boltz).
* 3. The funding UTXO must have >= 6 block confirmations.
* 4. data/rune-mint-state.json must exist with { commitment_address, utxo }.
*
* Etch tx layout (per design doc rune-pow-fairlaunch-design.md):
* Input 0: commitment UTXO (script-path spend — reveals commitment bytes)
* Output 0: relay P2TR (receives premine + funding minus fee)
* Output 1: OP_RETURN Runestone (etching spec)
*
* The etching Runestone carries:
* rune: POWFORGEPROOF (displayed as POWFORGE•PROOF)
* symbol: ⚒
* divisibility: 0
* premine: 21,000,000 raw units (= 21,000 × 1,000-unit parcels)
* terms: null (disables open mint — distribution via relay only)
* spacers: [8] (positions • between POWFORGE and PROOF)
*
* After successful broadcast, the etch txid is written to data/rune-mint-state.json
* and POWFORGE_RUNE_ID can be computed from the txid + confirmation block height.
*
* Usage:
* node packages/captcha-mcp/src/rune-etch.js
* node packages/captcha-mcp/src/rune-etch.js --dry-run (skip broadcast)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
const crypto = require('crypto');
const btc = require('@scure/btc-signer');
const { schnorr, Point } = require('@noble/secp256k1');
const { encodeRunestone } = require('@magiceden-oss/runestone-lib');
const { bech32m } = require('@scure/base');
// =============================================================================
// CONFIG
// =============================================================================
const KEY_PATH = path.join(process.env.HOME, '.config/powforge/rune-minting-key.hex');
const STATE_PATH = path.join(__dirname, '../../../data/rune-mint-state.json');
// POWFORGEPROOF rune name commitment bytes (matches generate-rune-minting-key.js)
const RUNE_COMMITMENT = Buffer.from('d777618111c4ff15', 'hex');
const RUNE_CONFIG = {
runeName: 'POWFORGEPROOF',
divisibility: 0,
premine: 21_000_000n,
symbol: '⚒',
spacers: [8],
};
const FEE_RATE = 2; // sat/vbyte — low priority, confirms within ~24h
const DUST_LIMIT = 546; // minimum output value for P2TR
const BITCOIN_RPC = {
host: 'lightning.lan',
port: 8332,
user: 'zeke',
pass: 'lOb_sXqOc4oT5UScfDajB2fepQQhvxyf8s9LOQ8mydA',
};
const MEMPOOL_API = 'https://mempool.space/api';
const MIN_CONFIRMATIONS = 6;
const DRY_RUN = process.argv.includes('--dry-run');
// =============================================================================
// BIP-341 UTILITIES (mirror of generate-rune-minting-key.js — kept local)
// =============================================================================
function taggedHash(tag, ...data) {
const tagHash = crypto.createHash('sha256').update(tag).digest();
const h = crypto.createHash('sha256');
h.update(tagHash);
h.update(tagHash);
for (const d of data) h.update(d);
return h.digest();
}
function buildCommitmentScript(xonlyPubkeyBuf) {
return Buffer.concat([
Buffer.from([RUNE_COMMITMENT.length]),
RUNE_COMMITMENT,
Buffer.from([0x75]), // OP_DROP
Buffer.from([xonlyPubkeyBuf.length]),
Buffer.from(xonlyPubkeyBuf),
Buffer.from([0xac]), // OP_CHECKSIG
]);
}
// =============================================================================
// BITCOIN RPC
// =============================================================================
function rpcCall(method, params = []) {
const body = JSON.stringify({ jsonrpc: '1.0', id: 1, method, params });
const auth = Buffer.from(`${BITCOIN_RPC.user}:${BITCOIN_RPC.pass}`).toString('base64');
return new Promise((resolve, reject) => {
const req = http.request({
host: BITCOIN_RPC.host,
port: BITCOIN_RPC.port,
path: '/',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'Authorization': `Basic ${auth}`,
},
}, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (parsed.error) return reject(new Error(`RPC ${method}: ${parsed.error.message}`));
resolve(parsed.result);
} catch (e) {
reject(new Error(`RPC parse error: ${e.message}`));
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// =============================================================================
// MEMPOOL.SPACE UTXO FETCH (fallback UTXO lookup)
// =============================================================================
async function fetchUtxo(address) {
const url = `${MEMPOOL_API}/address/${address}/utxo`;
const res = await fetch(url);
if (!res.ok) throw new Error(`mempool.space UTXO fetch failed: ${res.status}`);
const utxos = await res.json();
const confirmed = utxos.filter((u) => u && u.status && u.status.confirmed === true);
if (confirmed.length === 0) return null;
return confirmed.reduce((best, u) => (!best || u.value > best.value ? u : best), null);
}
async function fetchBlockHeight() {
const res = await fetch(`${MEMPOOL_API}/blocks/tip/height`);
if (!res.ok) throw new Error(`mempool.space height fetch failed: ${res.status}`);
return parseInt(await res.text(), 10);
}
// =============================================================================
// MAIN
// =============================================================================
async function main() {
// 1. Load minting key
if (!fs.existsSync(KEY_PATH)) {
throw new Error(`Minting key not found at ${KEY_PATH}. Run: node scripts/generate-rune-minting-key.js`);
}
const privHex = fs.readFileSync(KEY_PATH, 'utf8').trim();
const secretKey = Buffer.from(privHex, 'hex');
const xonlyPubkey = Buffer.from(schnorr.getPublicKey(secretKey));
// 2. Rebuild commitment P2TR (must match generate-rune-minting-key.js)
const commitScript = buildCommitmentScript(xonlyPubkey);
const p2trCommit = btc.p2tr(xonlyPubkey, { script: commitScript }, btc.NETWORK, true);
console.log(`Commitment address: ${p2trCommit.address}`);
// 3. Load state (contains funding UTXO info if already funded)
let state = {};
if (fs.existsSync(STATE_PATH)) {
state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
}
// 4. Fetch the funding UTXO (largest confirmed UTXO at commitment address)
console.log('Fetching UTXOs from mempool.space...');
const utxo = await fetchUtxo(p2trCommit.address);
if (!utxo) {
throw new Error(
`No confirmed UTXOs found at ${p2trCommit.address}.\n` +
`Fund this address with >= 25,000 sats via Boltz reverse swap:\n` +
` POST https://api.boltz.exchange/v2/swap/reverse\n` +
` { "to": "BTC", "from": "BTC", "invoiceAmount": 25000,\n` +
` "claimPublicKey": "${xonlyPubkey.toString('hex')}", "preimageHash": "<random 32 bytes hex>" }\n` +
`LNBits balance required: >= 25,000 sats (current deficit: check LNBits).`
);
}
console.log(`UTXO: ${utxo.txid}:${utxo.vout} = ${utxo.value} sats (block ${utxo.status.block_height})`);
// 5. Check confirmations
const tipHeight = await fetchBlockHeight();
const utxoHeight = utxo.status.block_height;
const confirmations = tipHeight - utxoHeight + 1;
console.log(`Confirmations: ${confirmations}/${MIN_CONFIRMATIONS} (tip=${tipHeight}, utxo=${utxoHeight})`);
if (confirmations < MIN_CONFIRMATIONS) {
throw new Error(
`UTXO has only ${confirmations} confirmations. Need >= ${MIN_CONFIRMATIONS} for ord commit-reveal.\n` +
`Blocks remaining: ${MIN_CONFIRMATIONS - confirmations}. Try again in ~${(MIN_CONFIRMATIONS - confirmations) * 10} minutes.`
);
}
// 6. Encode the etching Runestone
const { encodedRunestone } = encodeRunestone({ etching: RUNE_CONFIG });
console.log(`Runestone: ${encodedRunestone.length} bytes (hex: ${encodedRunestone.toString('hex')})`);
// 7. Estimate fee: P2TR script-path input ≈ 107 vbytes (witness heavy),
// OP_RETURN output ≈ 10 + payload, P2TR output ≈ 43, overhead ≈ 10.5
const estimatedVbytes = Math.ceil(10 + 107 + (10 + encodedRunestone.length) + 43);
const feeSats = Math.ceil(estimatedVbytes * FEE_RATE);
const relaySats = utxo.value - feeSats;
if (relaySats < DUST_LIMIT) {
throw new Error(`Insufficient funds: ${utxo.value} sats - ${feeSats} fee = ${relaySats} < ${DUST_LIMIT} dust`);
}
console.log(`Fee: ${feeSats} sats (${estimatedVbytes} vbytes × ${FEE_RATE} sat/vB), relay gets: ${relaySats} sats`);
// 8. Build etch tx
const tx = new btc.Transaction({ allowUnknownOutputs: true });
tx.addInput({
txid: utxo.txid,
index: utxo.vout,
witnessUtxo: {
script: p2trCommit.script,
amount: BigInt(utxo.value),
},
tapInternalKey: xonlyPubkey,
tapLeafScript: p2trCommit.tapLeafScript,
});
// Output 0: relay P2TR — receives premine and BTC (key-path only, no script)
const relayP2tr = btc.p2tr(xonlyPubkey, undefined, btc.NETWORK);
tx.addOutputAddress(relayP2tr.address, BigInt(relaySats), btc.NETWORK);
// Output 1: OP_RETURN Runestone
tx.addOutput({ script: encodedRunestone, amount: 0n });
// 9. Sign + finalize
tx.sign(secretKey);
tx.finalize();
const txHex = Buffer.from(tx.toBytes()).toString('hex');
const actualVsize = tx.vsize;
const actualFee = utxo.value - relaySats;
console.log(`Actual vsize: ${actualVsize} vbytes, fee: ${actualFee} sats, relay: ${relaySats} sats`);
console.log(`Tx hex (first 80): ${txHex.slice(0, 80)}...`);
if (DRY_RUN) {
console.log('DRY RUN — skipping broadcast');
console.log(`Full tx hex:\n${txHex}`);
return;
}
// 10. Broadcast via lightning.lan:8332 sendrawtransaction (primary)
let txid;
try {
txid = await rpcCall('sendrawtransaction', [txHex]);
console.log(`Broadcast SUCCESS via lightning.lan: txid = ${txid}`);
} catch (rpcErr) {
console.warn(`lightning.lan broadcast failed: ${rpcErr.message}`);
console.warn('Falling back to mempool.space broadcast...');
const res = await fetch(`${MEMPOOL_API}/tx`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: txHex,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`mempool.space broadcast failed (${res.status}): ${body}`);
}
txid = (await res.text()).trim();
console.log(`Broadcast SUCCESS via mempool.space: txid = ${txid}`);
}
// 11. Persist etch state
const etchState = {
...state,
status: 'etched',
etch_txid: txid,
relay_address: relayP2tr.address,
relay_sats: relaySats,
etch_timestamp: new Date().toISOString(),
note: 'After confirmation, get RUNE_ID from ord: block:tx of the etch tx. Set POWFORGE_RUNE_ID=<block>:<tx> in systemd unit.',
};
fs.writeFileSync(STATE_PATH, JSON.stringify(etchState, null, 2));
console.log(`State written to ${STATE_PATH}`);
console.log(`\n✅ POWFORGE•PROOF etched! txid: ${txid}`);
console.log(`\nNext steps:`);
console.log(` 1. Wait for tx to confirm and check ordinals.com/rune/POWFORGE%E2%80%A2PROOF`);
console.log(` 2. Get the confirmed block height of this tx`);
console.log(` 3. Set POWFORGE_RUNE_ID=<block>:<tx_index> in ops/systemd/captcha-mcp.service`);
console.log(` 4. Redeploy captcha-mcp to enable live claim broadcasts`);
}
main().catch((err) => {
console.error(`\n❌ Error: ${err.message}`);
process.exit(1);
});
/**
* @powforge/captcha-mcp — Rune PoW Fair-Launch (Phase 2: Runestone encoding)
*
* Tick 545, build/141 phase 2 (research/rune-pow-fairlaunch-design.md).
*
* Off-chain enforcement model: PowForge etches a Rune with premine = total
* supply, then gates transfers behind the existing pow-captcha PoW challenge.
* Solve the PoW, supply a Bitcoin recipient address, get back a Runestone
* OP_RETURN payload + transfer template the caller can broadcast (PSBT flow
* lands phase 3 once relay UTXO cursor is wired up).
*
* What Phase 2 ships:
* • encodeRunestoneEdict({ runeId, amount, output }) -> Buffer of the
* OP_RETURN scriptpubkey (6a 5d <varint-encoded edict>).
* • encodeRunestoneEtching(spec) -> { scriptpubkey, commitment } for the
* POWFORGE•PROOF etching tx.
* • claim() now returns a real `runestone_scriptpubkey` hex (from
* @magiceden-oss/runestone-lib) plus the transfer-tx template fields the
* caller (or phase-3 broadcaster) needs to finalize the tx.
*
* What Phase 3 will add (NOT in this file):
* • Relay UTXO cursor (chain forward each claim's leftover balance).
* • bitcoinjs-lib PSBT assembly + relay signature.
* • Broadcast endpoint hitting lightning.lan:8332 sendrawtransaction.
*
* The route shape, return contract, error codes, and rate-limit semantics from
* the Phase-1 scaffold are STABLE. Phase 2 enriches the response with real
* Runestone bytes; it does not change error codes or status semantics.
*/
'use strict';
const crypto = require('crypto');
const { verify: captchaVerify } = require('./index.js');
const { encodeRunestone } = require('@magiceden-oss/runestone-lib');
// =============================================================================
// CONSTANTS — match design doc §"Rune configuration"
// =============================================================================
const RUNE_NAME = 'POWFORGE•PROOF';
const RUNE_RAW_NAME = 'POWFORGEPROOF'; // dots stripped — etcher spec
const RUNE_SPACERS = [8]; // bitfield: spacer between POWFORGE and PROOF
const RUNE_SYMBOL = '⚒';
const RUNE_DIVISIBILITY = 0;
const RUNE_TOTAL_SUPPLY = 21_000_000;
const RUNE_PARCEL_SIZE = 1_000;
const RUNE_PARCEL_COUNT = RUNE_TOTAL_SUPPLY / RUNE_PARCEL_SIZE; // 21,000
// Per-recipient rate limit: 1 claim per address per 24h
const CLAIM_RATE_LIMIT_MS = 24 * 60 * 60 * 1000;
// Default rune location to use for transfer edicts until the live etch is
// performed on signet/mainnet. Tests assert this is overrideable via env so
// CI never needs a real on-chain etch. Format: <block>:<tx>.
function getRuneId() {
const raw = process.env.POWFORGE_RUNE_ID;
if (raw && /^\d+:\d+$/.test(raw)) {
const [block, tx] = raw.split(':');
return { block: BigInt(block), tx: Number(tx) };
}
// Sentinel rune id used for scaffold tests + Phase-2 encoding round-trips.
// Replaced with real (block, tx) of the etching tx on signet/mainnet.
return { block: 840000n, tx: 1 };
}
// In-memory claim ledger (PRODUCTION: persist to data/rune-claims.jsonl).
// Maps recipient_address -> { claimed_at_ms, parcel_id, txid|null }
const claimsByAddress = new Map();
// Sequential parcel counter — wraps a real on-chain UTXO cursor in production
let parcelsClaimed = 0;
// =============================================================================
// ADDRESS VALIDATION — Bitcoin mainnet only for phase 1.
// Accepts: P2WPKH (bc1q...), P2TR (bc1p...), legacy P2PKH (1...), P2SH (3...).
// Rejects: testnet, signet, regtest prefixes (handled separately when those
// networks are wired in).
// =============================================================================
function looksLikeBitcoinMainnetAddress(addr) {
if (typeof addr !== 'string') return false;
if (addr.length < 26 || addr.length > 90) return false;
// P2WPKH or P2TR (bech32 / bech32m): bc1q (P2WPKH, 42 chars) or bc1p (P2TR, 62 chars)
if (/^bc1[qp][a-z0-9]{38,90}$/.test(addr)) return true;
// P2PKH legacy: starts with 1, base58, ~26-35 chars
if (/^1[a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(addr)) return true;
// P2SH: starts with 3
if (/^3[a-km-zA-HJ-NP-Z1-9]{25,34}$/.test(addr)) return true;
return false;
}
// =============================================================================
// RUNESTONE ENCODING (Phase 2)
// =============================================================================
/**
* Encode a single-edict Rune transfer payload — the OP_RETURN scriptpubkey
* that pays `amount` units of `runeId` to transaction output `output`.
*
* Returns a Buffer of the full scriptpubkey: `OP_RETURN(0x6a) + OP_13(0x5d)
* + push(length) + varint-encoded edict tuple`. This is exactly what the
* indexer (ord, magic eden's runestone-lib decoder, runelib) parses to assign
* Rune balances post-confirmation.
*
* The bytes round-trip through `tryDecodeRunestone` from the same library —
* tests assert the decoded edicts match what we encoded.
*
* @param {object} args
* @param {{block: bigint, tx: number}} args.runeId Rune location (block:tx of etch).
* @param {bigint|number} args.amount Units to transfer.
* @param {number} args.output Output index that receives the rune.
* @returns {Buffer} Full scriptpubkey bytes.
*/
function encodeRunestoneEdict({ runeId, amount, output }) {
if (!runeId || typeof runeId.block !== 'bigint' || typeof runeId.tx !== 'number') {
throw new Error('encodeRunestoneEdict: runeId must be { block: bigint, tx: number }');
}
if (amount == null) throw new Error('encodeRunestoneEdict: amount required');
if (output == null || output < 0) throw new Error('encodeRunestoneEdict: output index required');
const amt = typeof amount === 'bigint' ? amount : BigInt(amount);
const { encodedRunestone } = encodeRunestone({
edicts: [{ id: runeId, amount: amt, output: Number(output) }],
});
return encodedRunestone;
}
/**
* Encode the POWFORGE•PROOF etching scriptpubkey. One-shot — used once at
* launch to mint the entire premine to the relay's address.
*
* Returns the scriptpubkey Buffer AND the etching commitment (Buffer of the
* rune-name commitment that must appear in a witness of the etching tx per
* the Runes spec — `tryDecodeRunestone` verifies this commitment on indexers).
*
* @param {object} [overrides] Optional fields for testing variants.
* @returns {{ scriptpubkey: Buffer, commitment: Buffer|undefined }}
*/
function encodeRunestoneEtching(overrides = {}) {
const spec = {
etching: {
runeName: overrides.runeName || RUNE_RAW_NAME,
divisibility: overrides.divisibility ?? RUNE_DIVISIBILITY,
premine: overrides.premine != null ? BigInt(overrides.premine) : BigInt(RUNE_TOTAL_SUPPLY),
symbol: overrides.symbol || RUNE_SYMBOL,
spacers: overrides.spacers || RUNE_SPACERS,
},
};
const { encodedRunestone, etchingCommitment } = encodeRunestone(spec);
return { scriptpubkey: encodedRunestone, commitment: etchingCommitment };
}
// =============================================================================
// SCAFFOLD HANDLERS
// =============================================================================
/**
* Return the public claim metadata: rune config, current state, PoW
* requirements, how to claim. Idempotent, cacheable.
*/
function info() {
return {
rune: {
name: RUNE_NAME,
symbol: RUNE_SYMBOL,
divisibility: RUNE_DIVISIBILITY,
total_supply: RUNE_TOTAL_SUPPLY,
parcel_size: RUNE_PARCEL_SIZE,
parcel_count: RUNE_PARCEL_COUNT,
},
pow: {
algo: 'sha256',
difficulty_bits: 14,
challenge_endpoint: '/rune/challenge',
claim_endpoint: '/rune/claim',
hint: 'GET /rune/challenge issues a fresh PoW challenge. Solve it (SHA256(salt+nonce) leading 14 zero bits). POST /rune/claim with {id, salt, nonce, signature, recipient_address}.',
},
distribution: {
model: 'off-chain-enforcement',
premine_held_by: 'powforge-relay',
claims_total: parcelsClaimed,
claims_remaining: Math.max(0, RUNE_PARCEL_COUNT - parcelsClaimed),
rate_limit: '1 claim per recipient address per 24h',
},
status: 'phase-2-encoding',
next_milestone: 'phase-3: relay UTXO cursor + PSBT signing + signet broadcast — see research/rune-pow-fairlaunch-design.md',
fairness_note:
'This is off-chain enforcement. PowForge holds the keys and gates transfers behind PoW. ' +
'Anyone who controls the relay key could bypass the PoW barrier. ' +
'Mainnet etch will be gated behind 2-of-3 multisig before public claim window opens.',
};
}
/**
* Issue a PoW challenge. Thin pass-through to the existing captcha challenge
* endpoint — we share the same SHA-256 14-bit primitive so we don't fork two
* challenge formats. The challenge object is identical to the one returned by
* GET /api/challenge on the captcha server.
*
* In a wired route handler this is implemented at the HTTP level: GET
* /rune/challenge proxies to the existing generateChallenge() function in
* scripts/pow-captcha-server.js. This function exists so callers can preflight
* the challenge shape and difficulty programmatically.
*/
function challenge() {
return {
instructions:
'GET /rune/challenge issues an HMAC-signed PoW challenge ' +
'(same format as GET /api/challenge on captcha.powforge.dev). ' +
'Brute-force a nonce string such that SHA-256(salt + nonce) ' +
'has at least `difficulty` leading zero bits (default 14). ' +
'Then POST /rune/claim with {id, salt, nonce, signature, recipient_address}.',
algo: 'sha256',
difficulty: 14,
captcha_challenge_url:
(process.env.CAPTCHA_URL || 'http://localhost:3077').replace(/\/+$/, '') +
'/api/challenge',
claim_url:
(process.env.CAPTCHA_URL || 'http://localhost:3077').replace(/\/+$/, '') +
'/rune/claim',
};
}
/**
* Verify a PoW solution AND a Bitcoin recipient address, then (eventually)
* sign a Rune transfer transaction.
*
* Phase 1 (THIS SCAFFOLD): validates input contract, checks rate limit,
* verifies PoW via captchaVerify(), reserves a parcel id, returns a
* structured "coming-soon" stub with the eventual response shape documented.
*
* Phase 2 (next tick): swap the stub body for runestone-lib tx construction.
* The function signature and return shape will not change.
*
* @param {object} input — { id, salt, nonce, signature, algo?, difficulty?, recipient_address }
* @param {object} opts — { captchaVerifyImpl?, now?, fetchImpl? } for testability
*/
async function claim(input, opts = {}) {
if (!input || typeof input !== 'object') {
return { error: 'input_required', hint: 'pass {id, salt, nonce, signature, recipient_address}' };
}
const { recipient_address } = input;
if (!recipient_address || typeof recipient_address !== 'string') {
return { error: 'recipient_address_required', hint: 'Bitcoin mainnet P2WPKH/P2TR/P2PKH/P2SH address' };
}
if (!looksLikeBitcoinMainnetAddress(recipient_address)) {
return {
error: 'recipient_address_invalid',
hint: 'Must be a Bitcoin mainnet address. bc1q... (P2WPKH), bc1p... (P2TR), 1... (P2PKH), or 3... (P2SH).',
};
}
const now = (opts.now && typeof opts.now === 'function' ? opts.now() : Date.now());
// Rate limit: 1 claim per recipient address per 24h
const prior = claimsByAddress.get(recipient_address);
if (prior && now - prior.claimed_at_ms < CLAIM_RATE_LIMIT_MS) {
const wait_ms = CLAIM_RATE_LIMIT_MS - (now - prior.claimed_at_ms);
return {
error: 'rate_limit_per_address',
retry_after_ms: wait_ms,
retry_after_iso: new Date(now + wait_ms).toISOString(),
prior_claim: { parcel_id: prior.parcel_id, claimed_at_iso: new Date(prior.claimed_at_ms).toISOString() },
hint: 'Each Bitcoin address may claim one parcel per 24h. Try a different recipient or wait.',
};
}
// Supply check
if (parcelsClaimed >= RUNE_PARCEL_COUNT) {
return {
error: 'launch_exhausted',
parcels_total: RUNE_PARCEL_COUNT,
parcels_remaining: 0,
hint: 'All 21,000 parcels of POWFORGE•PROOF have been claimed.',
};
}
// Verify PoW via the captcha-mcp verify handler (shares the same primitive)
const verifyImpl = opts.captchaVerifyImpl || captchaVerify;
const pow = await verifyImpl(input, opts);
if (!pow || !pow.valid) {
return {
error: 'pow_invalid',
reason: (pow && (pow.reason || pow.error)) || 'verification_failed',
hint: 'Re-solve the PoW challenge. SHA-256(salt+nonce) must have 14+ leading zero bits.',
};
}
// Reserve a parcel — in production this consumes one relay UTXO from the
// chained transfer ledger. Here we just bump an in-memory counter.
const parcel_id = parcelsClaimed + 1;
// Phase 2: build the real Runestone OP_RETURN scriptpubkey via
// @magiceden-oss/runestone-lib. The edict sends RUNE_PARCEL_SIZE units of
// POWFORGE•PROOF to output 0 (the claimer's address). The remainder of the
// relay balance lands on output 1 (the relay change address) once Phase 3
// wires the UTXO cursor.
const runeId = (opts.runeId && typeof opts.runeId === 'object') ? opts.runeId : getRuneId();
let runestoneScriptpubkey;
try {
runestoneScriptpubkey = encodeRunestoneEdict({
runeId,
amount: RUNE_PARCEL_SIZE,
output: 0,
});
} catch (e) {
return {
error: 'runestone_encoding_failed',
reason: e.message,
hint: 'Internal: failed to encode the Runestone OP_RETURN. Report this — should never happen on valid input.',
};
}
parcelsClaimed += 1;
claimsByAddress.set(recipient_address, {
claimed_at_ms: now,
parcel_id,
txid: null, // populated once Phase 3 wires broadcast
});
return {
status: 'runestone-ready',
parcel_id,
parcel_size: RUNE_PARCEL_SIZE,
rune: RUNE_NAME,
rune_id: { block: runeId.block.toString(), tx: runeId.tx },
recipient_address,
pow_verified: true,
pow_method: pow.method || 'pow',
pow_token: pow.token, // pass through the captcha token so caller can verify independently
// Phase 2 deliverable: real Runestone bytes the caller (or phase-3 relay)
// can drop straight into a Bitcoin transaction.
runestone: {
scriptpubkey_hex: runestoneScriptpubkey.toString('hex'),
scriptpubkey_len: runestoneScriptpubkey.length,
edicts: [
{ rune_id: `${runeId.block}:${runeId.tx}`, amount: RUNE_PARCEL_SIZE, output: 0 },
],
},
transfer_tx_template: {
// Caller assembles a tx with these outputs in order. Outputs 0 and 1 are
// dust (546 sats minimum standardness). Output 2 carries the Runestone.
outputs: [
{ description: 'dust to claimer (receives 1,000 RUNE via edict)', address: recipient_address, value_sats: 546 },
{ description: 'dust to relay change (receives the remainder)', address: '<relay_change_address>', value_sats: 546 },
{ description: 'OP_RETURN Runestone', scriptpubkey_hex: runestoneScriptpubkey.toString('hex'), value_sats: 0 },
],
relay_input_needed: true,
next_step: 'Phase 3 wires the relay UTXO cursor + PSBT signing. For now, callers can broadcast this Runestone in their own tx structure if they have a runestone-aware wallet.',
},
design_doc: 'research/rune-pow-fairlaunch-design.md',
claims_total: parcelsClaimed,
claims_remaining: RUNE_PARCEL_COUNT - parcelsClaimed,
};
}
// =============================================================================
// TEST HOOKS — used by tests to reset state between cases
// =============================================================================
function _resetForTests() {
claimsByAddress.clear();
parcelsClaimed = 0;
}
// =============================================================================
// EXPORTS
// =============================================================================
module.exports = {
info,
challenge,
claim,
// Phase-2 encoding primitives
encodeRunestoneEdict,
encodeRunestoneEtching,
// private but exposed for tests
_resetForTests,
_looksLikeBitcoinMainnetAddress: looksLikeBitcoinMainnetAddress,
_getRuneId: getRuneId,
// constants
RUNE_NAME,
RUNE_RAW_NAME,
RUNE_SPACERS,
RUNE_SYMBOL,
RUNE_DIVISIBILITY,
RUNE_TOTAL_SUPPLY,
RUNE_PARCEL_SIZE,
RUNE_PARCEL_COUNT,
CLAIM_RATE_LIMIT_MS,
};
+10
-3
{
"name": "@powforge/captcha-mcp",
"version": "0.2.0",
"version": "0.2.1",
"mcpName": "io.github.zekebuilds-lab/captcha-mcp",
"description": "MCP server that turns PowForge pow-captcha into agent auth. Charge AI agents per-call without accounts: PoW solve = free tier, Lightning payment = paid tier. Three tools: challenge, verify, status. Stdio + HTTP Streamable transports, stdlib only — no MCP SDK dependency.",
"description": "Stop your MCP server returning 429 to agents. PoW puzzle (free, ~5s CPU) or Lightning invoice — machine-readable backoff an agent can satisfy without an account. Three tools: challenge, verify, status. Stdio + HTTP transports, stdlib only.",
"keywords": [

@@ -20,2 +20,5 @@ "mcp",

"rate-limiting",
"rate-limit-replacement",
"429",
"backoff",
"anti-bot",

@@ -29,3 +32,3 @@ "powforge"

"scripts": {
"test": "node --test tests/server.test.js tests/http-transport.test.js",
"test": "node --test tests/server.test.js tests/http-transport.test.js tests/rune-fairlaunch.test.js tests/rune-broadcast.test.js",
"start": "node src/server.js",

@@ -50,3 +53,7 @@ "start:http": "node src/server.js --http"

"node": ">=18"
},
"dependencies": {
"@magiceden-oss/runestone-lib": "^1.0.2",
"@scure/btc-signer": "^1.8.1"
}
}
# @powforge/captcha-mcp
**Charge AI agents per-call without accounts.** PoW solve = free tier. Lightning payment = paid tier.
**Stop your MCP server returning 429 to agents.** Hand them a proof-of-work puzzle (free, ~5s of CPU) or a 3-sat Lightning invoice instead — both are machine-readable backoff signals an autonomous agent can satisfy without an account, email, or API key.
OpenAI's Sora API does not let you charge per call. Anthropic's billing does not pass through to your tools. If you ship an MCP server today and an autonomous agent finds it, you eat the bill.
OpenAI's Sora API does not let you charge per call. Anthropic's billing does not pass through to your tools. If you ship an MCP server today and an autonomous agent finds it, you eat the bill. When it hits your rate limit, it crashes with a 429 and no way to back off gracefully.

@@ -135,4 +135,8 @@ This is the gate. Three tools over stdio or HTTP. Stdlib only.

## How this compares to other MCP agent-auth primitives
A side-by-side breakdown against `x402-mcp`, `@agentauth/mcp`, and Cloudflare ARC/ACT is published at [powforge.dev/mcp/compare/x402-mcp](https://powforge.dev/mcp/compare/x402-mcp/). Short version: `captcha-mcp` is the only entrant that ships a free PoW tier alongside a Lightning paid skip on an MCP transport. The other three price every call (USDC) or require platform-issued credentials.
## License
MIT