feat(referral): on-chain payout mode, batched into a single send-many tx#208
Conversation
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.
|
Pushed a big round of revisions from review: 1. Single 2. Precise outpoint — each payout now records its exact 3. Referrer bears the fee (LN + on-chain) — new 4. mempool.space fee cap — before each on-chain batch we fetch the next-block rate from mempool.space and defer if it exceeds 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.
|
Addressed the fee-estimator + e2e feedback: FeeEstimator trait — the on-chain fee-rate source is now behind a mockable
E2E payout coverage (in the lifecycle test) — a real referral payout run:
Covers the requested paths: real payout with fee, re-run no-op, batch, and batch mixed with LN. No further payments-rs changes. |
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.
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-rs0.6.0OnChainProvider::send_coinssend-many API (published from v0l/payments-rs#2).Changes
ReferralPayoutMode::OnChain(3); newreferral.onchain_address+referral_payout.txidcolumns (migration20260723220303_referral_onchain_payout.sql).[referral].min-onchain-payout-sats— a separate, higher threshold (mempool-fee aware).Nonedisables on-chain payouts (commission still accrues for manual admin payout).ReferralPayoutHandler. Lightning/NWC payouts are unchanged and now skip on-chain referrers, which go through the new batched pass: reserve all → singlesend_coins→ record the shared txid on every row; reservations are released if the send fails, so balances retry next run.POST/PATCH /api/v1/referralacceptonchain_address+mode: "on_chain"; the address is validated as mainnet (regtest also accepted in debug builds for the e2e/regtest stack).Referralreturnsonchain_address;ReferralPayoutreturnstxid. Admin referral info exposesonchain_address; payout info exposestxidand it is settable on the payoutPATCH.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_batch— single 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.ReferralPayoutModeround-trip incl.on_chain.Notes
payments-rsbumped 0.5.0 → 0.6.0.