Skip to content

feat(referral): on-chain payout mode, batched into a single send-many tx#208

Merged
v0l merged 7 commits into
masterfrom
feat/184-onchain-referral-payout
Jul 24, 2026
Merged

feat(referral): on-chain payout mode, batched into a single send-many tx#208
v0l merged 7 commits into
masterfrom
feat/184-onchain-referral-payout

Conversation

@v0l

@v0l v0l commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #184.

Summary

Adds an on-chain referral payout mode. The automated payout worker pays every eligible on-chain referrer in a single send-many transaction (one network fee for the whole batch, absorbed by us — each referrer receives their exact owed amount), so we never spend one fee per referrer. All payout rows produced by a batch share the broadcast txid.

Built on the new payments-rs 0.6.0 OnChainProvider::send_coins send-many API (published from v0l/payments-rs#2).

Changes

  • DB: append-only ReferralPayoutMode::OnChain (3); new referral.onchain_address + referral_payout.txid columns (migration 20260723220303_referral_onchain_payout.sql).
  • Settings: [referral].min-onchain-payout-sats — a separate, higher threshold (mempool-fee aware). None disables on-chain payouts (commission still accrues for manual admin payout).
  • Worker / handler: threads the on-chain provider into ReferralPayoutHandler. Lightning/NWC payouts are unchanged and now skip on-chain referrers, which go through the new batched pass: reserve all → single send_coins → record the shared txid on every row; reservations are released if the send fails, so balances retry next run.
  • API: user POST/PATCH /api/v1/referral accept onchain_address + mode: "on_chain"; the address is validated as mainnet (regtest also accepted in debug builds for the e2e/regtest stack). Referral returns onchain_address; ReferralPayout returns txid. Admin referral info exposes onchain_address; payout info exposes txid and it is settable on the payout PATCH.
  • Docs: API_DOCUMENTATION.md, ADMIN_API_ENDPOINTS.md, API_CHANGELOG.md, config.yaml.

Fee handling

The network fee is absorbed by LNVPS (paid on top of the outputs), so each referrer receives their exact owed amount.

Tests

  • payout_batch_request — one output per referrer, totals sum correctly.
  • send_batchsingle transaction, shared txid on every row, all rows paid; reservations released on send failure.
  • validate_onchain_address — mainnet accepted, regtest only in debug builds, garbage/empty rejected.
  • ReferralPayoutMode round-trip incl. on_chain.

Notes

  • Touches payments — leaving for review rather than self-merging.
  • payments-rs bumped 0.5.0 → 0.6.0.
  • No automated e2e for the payout worker (needs a funded regtest on-chain wallet + interval trigger); batching logic is covered by unit tests against the mock provider.

v0l added 3 commits July 23, 2026 22:29
Adds ReferralPayoutMode::OnChain so referrers can be paid on-chain. The
automated payout worker batches EVERY eligible on-chain referrer into a
single send-many transaction (via payments-rs 0.6.0
OnChainProvider::send_coins), so one transaction and one network fee
cover the whole batch. The fee is absorbed by us — each referrer
receives their exact owed amount — and all payout rows produced by a
batch share the broadcast txid.

- DB: append-only ReferralPayoutMode::OnChain (3); new
  referral.onchain_address and referral_payout.txid columns (+migration).
- Settings: [referral].min-onchain-payout-sats — a separate, higher
  (mempool-fee aware) threshold; None disables on-chain payouts (commission
  still accrues for manual payout).
- Worker: threads the on-chain provider into ReferralPayoutHandler; the
  Lightning/NWC path is unchanged and skips on-chain referrers, which are
  handled by the new batched pass (reserve-all -> single send -> record
  shared txid; release reservations on failure).
- API: user POST/PATCH /api/v1/referral accept onchain_address + mode
  on_chain (address validated: mainnet only, regtest allowed in debug
  builds); Referral returns onchain_address; ReferralPayout returns txid.
  Admin referral info exposes onchain_address; payout info exposes txid
  and it's settable on the payout PATCH.
- Docs + config example updated.

Tests: payout_batch_request (one output per referrer, totals),
send_batch (single tx, shared txid, all rows paid; reservations released
on send failure), address validation (mainnet/regtest/garbage), and the
mode round-trip.

Refs #184. Bumps payments-rs 0.5.0 -> 0.6.0.
…point

- Merge referral.lightning_address + onchain_address into one `address`
  column; `mode` already describes the target type (lightning address vs
  bitcoin address vs none for nwc). Migration renames lightning_address ->
  address. API/admin expose a single `address`; validation dispatches on
  the effective mode.
- Record the precise on-chain payout outpoint (txid:vout) per payout,
  renaming referral_payout.txid -> outpoint. A batch is one send-many tx
  with many outputs, so each payout decodes the returned raw_tx (bitcoin
  crate) and matches the referrer's output script to its vout; rows from
  one batch share the txid but carry distinct vouts. Falls back to the
  bare txid if the raw tx is unavailable.
- Requires payments-rs 0.7.0 (send_coins returns raw_tx). Mock builds a
  real tx so the outpoint path is covered end to end.

Tests updated: batch records distinct vouts sharing one txid; valid
regtest addresses via a helper.
…e cap

The referrer now bears the payout network/routing fee instead of LNVPS
absorbing it, for both Lightning and on-chain payouts:

- New referral_payout.fee column (migration). The fee is debited from the
  referrer's balance alongside the amount when computing what remains
  owed, so a fee-induced deficit is naturally recovered from future
  referrals (saturating at 0). Lightning fee comes from the pay_invoice
  result; on-chain fee = chosen sat/vByte x the broadcast tx vsize, split
  across the batch in proportion to each payout.
- On-chain fee-rate cap: before each batch the next-block fee rate is
  fetched from mempool.space and the batch is deferred if it exceeds
  max-onchain-fee-per-vbyte (default 50), so payouts wait for cheap fees.
  The chosen rate is set on the send.
- Settings: [referral] min-onchain-payout-sats now defaults to 1000
  (a small buffer to absorb fees; null disables), plus new
  max-onchain-fee-per-vbyte (50) and mempool-url (https://mempool.space).
- Expose fee on user + admin ReferralPayout.

Tests: proportional fee split (incl. remainder to largest, zero cases);
batch records a non-zero fee per referrer with the larger payout bearing
the larger share. Docs + config + changelog updated.
@v0l

v0l commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a big round of revisions from review:

1. Single address field — merged lightning_address + onchain_address into one address (the mode already implies the type). Migration renames referral.lightning_addressaddress; API/admin expose one address; validation dispatches on the effective mode.

2. Precise outpoint — each payout now records its exact "{txid}:{vout}" (renamed txidoutpoint). A batch is one send-many tx; we decode the returned raw_tx (needs payments-rs 0.7.0, published — send_coins now returns the raw tx) and match each referrer's output script to its vout. Rows from one batch share the txid but carry distinct vouts.

3. Referrer bears the fee (LN + on-chain) — new referral_payout.fee, debited from their balance alongside amount, so a fee-induced deficit is recovered from future referrals (balance saturates at 0). LN fee from the pay_invoice result; on-chain fee = chosen sat/vByte × tx vsize, split proportionally across the batch.

4. mempool.space fee cap — before each on-chain batch we fetch the next-block rate from mempool.space and defer if it exceeds max-onchain-fee-per-vbyte (default 50), so we wait for cheap fees. min-onchain-payout-sats now defaults to 1000 (buffer for the fee); new mempool-url setting.

No further payments-rs changes needed (fee amount is computed locally from the decoded tx). Tests cover the proportional fee split and that each batched payout records a non-zero fee with the larger payout bearing more. Docs/config/changelog updated.

Abstract the on-chain fee-rate source behind a mockable FeeEstimator
trait (fee_estimate.rs) instead of a hard-coded mempool.space fetch:
- MempoolFeeEstimator (production) + FixedFeeEstimator (regtest/tests).
- Config: [referral].fee-estimator = mempool{url} (default) | fixed{sat-per-vbyte},
  replacing the raw mempool-url field.
- The fee-rate cap check moves into send_batch, so the defer/proceed
  decision is unit-testable: added tests that a >cap estimate reserves
  and broadcasts nothing, and MempoolFeeEstimator parsing via wiremock.

E2E: a real referral-payout flow in the lifecycle test —
- switches the referrer (with real BTC commission) to on-chain and seeds
  a 2nd on-chain + 1 Lightning referrer (reusing the referred VM's FK ids
  and a 100% per-referrer rate on a seeded paid BTC payment);
- triggers ProcessReferralPayouts and asserts both on-chain referrers are
  paid in ONE batched tx (shared txid, distinct vouts) each bearing a
  fee, the unresolvable Lightning referrer leaves no payout row (mixed
  isolation), and a re-run pays nothing new (idempotency).
- e2e api-config enables referral payouts with a fixed fee estimator
  (regtest has no mempool.space). New db/onchain helpers to seed
  commission, read payouts, and derive regtest payout addresses.

No payments-rs change.
@v0l

v0l commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the fee-estimator + e2e feedback:

FeeEstimator trait — the on-chain fee-rate source is now behind a mockable FeeEstimator trait (fee_estimate.rs) instead of a hard-coded mempool.space call:

  • MempoolFeeEstimator (prod) + FixedFeeEstimator (regtest/tests).
  • Config [referral].fee-estimator = mempool{url} (default) or fixed{sat-per-vbyte}, replacing the raw mempool-url.
  • The fee-rate cap check moved into send_batch, so the defer decision is now unit-tested (a >cap estimate reserves + broadcasts nothing), plus a wiremock test for the mempool parsing.

E2E payout coverage (in the lifecycle test) — a real referral payout run:

  • switches the referrer (which already earned BTC commission from the referred VM) to on-chain, and seeds a 2nd on-chain + 1 Lightning referrer;
  • triggers ProcessReferralPayouts and asserts both on-chain referrers are paid in one batched tx (shared txid, distinct vouts), each bearing a fee; the unresolvable Lightning referrer leaves no payout row (mixed isolation); and a re-run pays nothing new (idempotency).
  • e2e config enables payouts with a fixed fee estimator (regtest has no mempool.space); new db/onchain helpers seed commission, read payouts, and derive regtest payout addresses.

Covers the requested paths: real payout with fee, re-run no-op, batch, and batch mixed with LN. No further payments-rs changes.

v0l added 3 commits July 24, 2026 08:20
The lifecycle referrer earns 0 BTC commission in e2e (company
referral_rate is 0), and referral.user_id is UNIQUE (uk_referral_user)
so multiple seeded referrals cannot share one user. Seed three
independent referrer users (two on-chain, one Lightning) each with their
own 100%-override commission and a shared referred-VM owner; randomise
the seeded VM mac to avoid collisions.
…orded

Make the payout assertions authoritative instead of skipping when the
batch doesn't settle, so the on-chain payout path (batch, fee, outpoint,
idempotency) is genuinely verified in CI. Poll up to 60s.
… store mode

Mirror the address merge on the payout table: a payout's mode-specific
invoice (Lightning) and outpoint (on-chain) become a single `output`
column (a BOLT11 invoice for Lightning payouts, or the on-chain outpoint
"{txid}:{vout}" for on-chain payouts). A new `mode` column records how the
payout was made so `output` can be interpreted without joining to the
referral.

- Migration renames referral_payout.invoice -> output and adds mode.
- Model ReferralPayout: invoice+outpoint -> output, + mode; pre_image kept.
- Handler sets mode + output for both Lightning and on-chain payouts.
- User + admin ReferralPayout expose mode + output (admin create/update
  accept output + mode instead of invoice/outpoint).
- Docs + changelog + e2e reader updated.
@v0l
v0l merged commit 20069b2 into master Jul 24, 2026
8 checks passed
@v0l
v0l deleted the feat/184-onchain-referral-payout branch July 24, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Referral program: on-chain payout mode with minimum threshold

1 participant