Recurring Payments on Bitcoin Cash — Two Working Approaches (No Covenants, No OP_RETURN)

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.

  1. Payer sends total subscription amount to self in a funding TX
  2. Pre-signs a chain of N transactions, each spending the previous one’s change output
  3. Each TX has nLockTime set to the next payment date
  4. 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:

  1. Payer authorizes: “pay $X to pubkey Y every N days”
  2. Merchant sends Nostr event (kind 22240 ) with invoice
  3. Payer’s wallet matches against stored subscriptions
  4. 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

There’s a problem with liquidity here. If you want to support monthly recurring payments for 1 year, you have to lock up 1 year’s worth of BCH ahead of time. This is essentially a payment channel where recipient can settle parts of it as pieces mature and you can close the channel by spending the UTXO to yourself. But your liquidity is locked. If you want to take some BCH out, you will invalidate ALL pre-signed TXs and will have to interact with your recipient again.

Not anymore. Now you have CashTokens and TX introspection opcodes. It’s possible to put your BCH in a little smart contract pool, and the pool can emit “authorized to pull X amount every N blocks or seconds” NFTs to recipients. Here you can share the same liquidity pool with many services. As long as you maintain balance, all the services can keep pulling their subscriptions. If your balance drops, it can be seen as cancellation or pausing. If you encumber the authorization NFTs with a covenant, you could explicitly revoke subscriptions by burning their NFTs.

Anyway, I think the “pre-signed chain” is too fragile, and liquidity locking is a real problem that would hamper adoption of this.