We’ve been seeing a lot of attempts at recurring payments on BCH, most relying on OP_RETURN metadata or waiting for covenants. We took a different approach and implemented two working solutions — both live and open source. Neither requires new opcodes.
Repo: https://github.com/00-Protocol/00-Wallet
Live app: 00 Sub — 0penw0rld
The Problem
Crypto is push-only — nobody can pull from your wallet. Every recurring payment attempt needs to solve: how do you authorize N future payments without giving away your private key, and without knowing future UTXOs?
Solution 1: Pre-signed nLockTime Chain (Fixed BCH — trustless, wallet offline)
The key insight: you CAN know future UTXOs if you create them yourself.
- Payer sends total subscription amount to self in a funding TX
- Pre-signs a chain of N transactions, each spending the previous one’s change output
- Each TX has
nLockTimeset to the next payment date - Receiver holds the pre-signed TXs and broadcasts each when its locktime expires
Funding TX → 12,000 sats (self)
│
├─ TX₁ (nLockTime: Apr 1) → 1000 to merchant + 11000 change
│ │
│ ├─ TX₂ (nLockTime: May 1) → 1000 to merchant + 10000 change
│ │ │
│ │ ├─ TX₃ (nLockTime: Jun 1) → 1000 to merchant + 9000 change
│ │ ...
The critical detail — input sequence must be 0xFFFFFFFE , not 0xFFFFFFFF :
const input = {
txidLE: h2b(prevTxid).reverse(),
vout: prevVout,
value: inputValue,
sequence: 0xFFFFFFFE, // enables nLockTime enforcement
scriptSig: new Uint8Array(0)
};
const hash = bchSighash(2, locktime, [input], outputs, 0, myScript, inputValue);
const sig = secp256k1.sign(hash, _privKey, { lowS: true });
Each TX’s txid is computed locally before broadcast via dsha256(serialized).reverse() — this is how we know the next TX’s input in advance.
Cancel: payer spends the change output of the last broadcast TX. Since this spend has no locktime, it confirms immediately and invalidates all remaining pre-signed TXs.
Properties:
- Standard P2PKH — no new opcodes, no OP_RETURN, works today
- Wallet can be closed after creation — fully trustless
- Receiver needs zero infrastructure — just holds raw TX hex and broadcasts on schedule
- Cancellable at any time by the payer
- Fees estimated upfront for the entire chain
Solution 2: Nostr-coordinated Invoices (Fixed USD — wallet online)
For USD-denominated subscriptions where the BCH amount changes with price:
- Payer authorizes: “pay $X to pubkey Y every N days”
- Merchant sends Nostr event (kind
22240) with invoice - Payer’s wallet matches against stored subscriptions
- Fetches BCH/USD price, calculates sats, auto-signs, broadcasts
// Merchant sends invoice via NIP-04 encrypted DM
const invoice = {
type: 'sub_invoice',
sub_id: subId,
amount_usd_cents: 500, // $5.00
period: 3,
addr: merchantAddr
};
const encrypted = await nip04Encrypt(sessionPriv, payerPub, JSON.stringify(invoice));
const ev = await makeEvent(sessionPriv, 22240, encrypted, [['p', payerPub], ['t', '0penw0rld-sub']]);
Wallet must be open for this mode — but the payment logic is fully self-custody (keys never leave the client).
Settlement Checker (Receiver Side)
Runs every 30 seconds — same pattern as HTLC settlement:
async function checkSubSettlements() {
for (const sub of subscriptions) {
if (sub.status !== 'active' || sub.role !== 'receiver') continue;
const now = Math.floor(Date.now() / 1000);
for (const tx of sub.chain_txs) {
if (tx.status !== 'pending' || now < tx.locktime) continue;
try {
await broadcast(tx.raw_hex);
tx.status = 'broadcast';
sub.periods_paid++;
} catch (e) {
// Input spent = payer cancelled
if (e.message.includes('Missing inputs')) {
sub.status = 'cancelled';
}
}
break;
}
}
}
Comparison
| Pre-signed Chain (BCH) | Nostr Invoice (USD) | |
|---|---|---|
| Currency | Fixed sats | Fixed USD (variable sats) |
| Wallet needed | No (after creation) | Yes (must be open) |
| Trustless | Yes | Semi (wallet auto-signs) |
| Cancel | Spend change output | Delete subscription |
| Infrastructure | None | Nostr relays |
| Opcodes | Standard P2PKH | Standard P2PKH |
Open Questions
- Any edge cases with nLockTime chain invalidation we might be missing?
- Better fee strategies for long chains (12+ months)?
- Is there interest in standardizing the Nostr event kinds (22240/22241) for cross-wallet compatibility?
Full implementation: sub.html — buildSubscriptionChain() at line ~768