Adding your own asset¶
An asset here is one database row plus one registry entry. The row is data — decimals, limits, fees, invoice currency. The entry is behavior — how to validate a destination, price a fee, send the money, and ask what you actually hold.
This guide is honest about which parts you can plug into and which are welded shut. Read the next section before you plan anything, because roughly half the questions people ask are answered by "you can't, and here is why".
1. What you can and cannot plug in¶
Deliberately fixed¶
BTCPay is the only deposit rail, and the only webhook source. Every deposit
flows through a BTCPay invoice, and apply_invoice_state is the single proven
transition path for one. Your asset must be payable through some BTCPay
payment method — a chain plugin, the Lightning node, the USDt plugin all
qualify. A non-BTCPay deposit rail is not supported, and inventing a
DepositRail protocol with exactly one implementation would be decoration
rather than design.
post_entry is the only code that writes postings. If your asset needs a
money movement that does not exist, add a caller — never a second writer. A
semgrep rule enforces this and will fail your build.
Both status matrices are shared. Deposit and withdrawal states, and which transitions are legal, are not per-asset. If your rail needs a state that does not exist, that is a change to the matrix with tests for every illegal pair, not a special case for your asset.
BTC is the anchor. required=True on its profile means a store that cannot
serve BTC fails startup rather than degrading. Your asset should almost
certainly not be required.
decimals is capped at 8, by a CHECK constraint. Amounts are BIGINT in the
smallest unit, and an 18-decimal token overflows above ~9.2e9 tokens. Adding one
is a deliberate migration and a real conversation about the integer type — not a
config row.
Job C, the reconciliation invariants, and the ledger's zero-sum rules apply to your asset whether you like them or not. You may not weaken them.
Genuinely pluggable¶
Four facets, each a protocol:
| Facet | Protocol | Lives in |
|---|---|---|
| Destination validation | Callable[[Session, Settings, str], None] |
your validator |
| Fee policy | FeePolicy.quote(*, gross) -> FeeQuote |
services/asset_registry.py |
| Withdrawal backend | AutomatedWithdrawalBackend or OperatorWithdrawalBackend |
services/backends.py |
| Custody source | CustodySource.balance() -> int \| None |
services/asset_registry.py |
Plus two capability flags, a payment-method matcher, and two optional hooks
(submission_guard, submitted_timeout_seconds) on the profile itself.
One honest correction to "a row plus an entry"¶
That is the shape of the contract and it holds. It is not the whole diff. Adding
BTC_LN also needed two new methods on the BTCPay gateway, because
Greenfield's Lightning endpoints are not the payout endpoints and nothing had
called them before.
Expect the same. The contract governs how your asset plugs into the money path;
it does not promise that the rail you are adding has already been talked to. If
your asset needs a call BTCPay exposes and this codebase has never made, add it
to BTCPayGateway and to FakeBTCPay — that part is ordinary growth, not a
contract change.
2. The protocols¶
Withdrawal backend — pick one of two¶
There are honestly two kinds, and pretending otherwise cost this project a missing type check for a whole release.
class AutomatedWithdrawalBackend(Protocol):
name: str
def initiate(self, withdrawal, *, net: int, decimals: int) -> BackendPayout: ...
def poll_status(self, backend_ref: str) -> BackendPayout: ...
def cancel(self, backend_ref: str) -> bool: ...
def find_for_withdrawal(self, withdrawal) -> tuple[BackendPayout | None, list[BackendPayout]]: ...
find_for_withdrawal is part of the contract, not a helper. Any backend that
can create a payout can crash immediately after creating one, and a backend with
no answer to "did I already send this?" leaves exactly two options: resubmit
and risk paying twice, or freeze the row forever.
class OperatorWithdrawalBackend(Protocol):
name: str
def new_reference(self) -> str: ...
def verify_broadcast(self, withdrawal, txid: str) -> Verification: ...
def confirmations(self, block_number: int | None) -> int: ...
Use this when a human sends the money. ManualTronBackend is the worked
example: the USDt plugin has no payout handler of any kind, so BTCPay genuinely
cannot send USDT and the operator does it by hand.
Return canonical states, never your rail's vocabulary. BackendPayout.state
is a BackendPayoutState, and an unrecognised state must normalize to UNKNOWN
rather than raise — a rail inventing a state should leave the row alone and log,
not stop the poller for every other withdrawal in the batch.
Two optional capabilities¶
Some rails can answer questions most cannot. These are separate
runtime_checkable protocols rather than methods on the main one, so a backend
that cannot answer says nothing instead of lying:
class ReportsActualFee(Protocol):
def actual_wallet_fee(self, payout) -> int | None: ...
class ProvesDefinitiveFailure(Protocol):
def definitive_failure_proof(self, payout) -> str | None: ...
ReportsActualFee matters when your rail's fee is not knowable at
submission. If you quote a FlatFee and the rail also charges you something
per payment, that charge is real money leaving custody and the ledger has to
see it — implement this, or every withdrawal quietly widens the gap between the
books and the wallet. Implement it only if you can be exact; returning a guess
is worse than returning None.
ProvesDefinitiveFailure is the only way an automatic release is permitted
after a payout exists. Read services/withdrawals.py's "Release legality"
before implementing it. Anything short of certainty is None, and None is
always safe: the hold then waits for an admin exactly as it does now.
The bar is higher than it first looks, and getting it wrong is a double
payment. "The rail says this payment failed" answers has money left yet. The
question you actually have to answer is can money still leave, and those come
apart whenever the rail has not stopped trying. LightningPayoutBackend needs
two different arguments for the two cases:
- the payout was cancelled — a cancelled payout is never retried, so the node's verdict is the whole answer;
- the payout is still live — BTCPay refuses to cancel one it has already picked up, so the proof has to be that the destination is dead. An expired BOLT11 cannot be paid by anyone, whatever retries.
If your rail can keep trying and cannot be stopped, find the equivalent of that
second argument before returning a proof. If there is no equivalent, return
None and let a human decide; that is what the attestation is for.
Two optional profile hooks¶
submission_guard: Callable[[Settings, str, int], None] | None
submitted_timeout_seconds: Callable[[Settings], int | None] | None
submission_guard runs the destination checks that need the net amount,
which destination_validator cannot do because no fee has been quoted when a
destination is first seen. It runs twice: at request time, so the caller gets a
422 they can act on, and again immediately before submission. The second run is
not belt-and-braces — pending_approval has no horizon, and a destination that
was valid when the user asked can be worthless by the time an operator approves.
A bitcoin address needs none of this. An expiring, amount-carrying payment
request needs all of it.
submitted_timeout_seconds is how long a payout may sit submitted before
the service cancels it. Leave it None unless your rail can genuinely get
stuck forever, and be sure before you set it: on chain, a transaction in the
mempool is being worked on, and a deadline there would mean abandoning money
already in flight.
Fee policy¶
FeeQuote.committed must equal net + wallet_fee. This is not a convention: it
is the number the submit entry moves into payouts_in_flight, and if it is
wrong the settle entry cannot sum to zero and the database trigger rejects the
whole withdrawal.
Raise DustAmount from quote when nothing useful would arrive. Raise it at
quote time — a dust check that only runs at submission has already taken the
user's balance hostage.
Custody source¶
class CustodySource(Protocol):
@property
def source_name(self) -> str: ...
def balance(self) -> int | None: ...
Return None when your API is unreachable. Never 0. Zero means "we hold
nothing", which is an insolvency emergency and pages a human. None means "we
do not know", which Job C reports honestly and does not alarm on. Getting this
wrong does not merely produce a false alarm — it teaches the operator to ignore
the one signal that matters.
Destination validator¶
def validate_my_destination(session: Session, settings: Settings, address: str) -> None:
... # raise AddressError on anything you will not send to
Raise AddressError and nothing else; the caller converts it into the API's
InvalidDestination. Reject your own hot wallet here — paying it burns a fee to
move money between your own pockets.
3. Step by step¶
a. Add the seed spec¶
cli.py, asset_specs(). This is read once, at migrate; afterwards the row is
the only source of truth.
AssetSpec(
id="MYCOIN",
display_name="My Coin",
decimals=8,
unit_name="satoshi-equivalent",
btcpay_payment_method=settings.seed_mycoin_payment_method,
withdrawal_auto_limit=...,
withdrawal_daily_cap=..., # this is your loss ceiling; choose it as one
withdrawal_user_daily_cap=None,
withdrawal_min=1, # keep it above withdrawal_flat_fee
withdrawal_flat_fee=0,
invoice_currency="MYCOIN",
pooled_addresses=False, # True only if addresses come from a shared pool
deposit_expiry_minutes=None, # None = the global default from settings
)
Your asset gets its own caps, and they bound their own pot of money. This is
easy to skim past. SEED_BTC_WITHDRAWAL_DAILY_CAP says nothing about how much
MYCOIN can lose in a day. If your asset draws on a separate float — a second
wallet, a channel, a different chain — then its cap is a second loss ceiling and
deserves the same thought as the first.
If your asset is optional, make the spec conditional. BTC_LN is appended
only when LIGHTNING_ENABLED, so a deployment that never wanted it has no row
rather than a disabled one. The reverse move is deliberately not silent: turning
the flag off once the row exists fails at startup, because an asset holding user
balances must not disappear because an environment variable did.
b. A migration, only if you need new columns¶
Most assets need none. If yours does, read the migration rules in
CONTRIBUTING.md: the downgrade must undo the upgrade
exactly, alembic check must agree the models match, and the frozen dumps in
tests/fixtures/upgrade/ must still upgrade cleanly.
c. The registry entry¶
services/asset_registry.py. Add a _mycoin_profile() and register it in
build_registry:
AssetProfile(
asset_id="MYCOIN",
fee_policy=lambda ctx: FlatFee(ctx.asset.withdrawal_flat_fee),
destination_validator=validate_my_destination,
payment_method_matcher=matches_mycoin,
withdrawal_backend="mycoin_payout",
has_btcpay_wallet=True, # does BTCPay expose a wallet API for it?
automated_backend=lambda ctx: MyPayoutBackend(ctx.gateway),
custody_source=lambda ctx: MyCustody(ctx.gateway),
)
sweep is derived: set automated_backend and it is "automated", set
operator_backend instead and it is "operator". You do not declare it, so it
cannot contradict the factories.
On the matcher: err fuzzy, not strict. If it fails to match the store's real
payment method id, sync_payment_methods disables your asset at startup and
deposits begin 404ing in production. That failure is silent. Read the comment on
matches_usdt_tron before writing yours.
d. Startup will check you¶
assert_every_enabled_asset_has_a_profile runs in both the API and the worker,
after the payment-method sync. An enabled asset with no profile is fatal — the
alternative is an asset that looks configured, accepts deposits, and only
discovers at withdrawal time that nothing knows how to price it.
4. Run the conformance suite¶
This is the part that decides whether your asset actually works. The suite ships inside the package, so it is the same one CI runs.
from crypto_processing_api.testing.contracts import (
AutomatedBackendContract,
CustodySourceContract,
FeePolicyContract,
)
class TestMyBackend(AutomatedBackendContract):
@pytest.fixture
def backend(self, my_fake):
return MyPayoutBackend(my_fake)
@pytest.fixture
def withdrawal(self, session):
return make_withdrawal_row(session, asset="MYCOIN", ...)
@pytest.fixture
def simulate_completion(self, my_fake):
return lambda ref: my_fake.complete(ref)
class TestMyFee(FeePolicyContract):
@pytest.fixture
def policy(self):
return FlatFee(flat_fee=1_000)
@pytest.fixture
def dust_gross(self):
return 500
class TestMyCustody(CustodySourceContract):
@pytest.fixture
def source(self, my_fake):
return MyCustody(my_fake)
@pytest.fixture
def broken_source(self, my_fake):
my_fake.fail_next["balance"] = MyApiError("down")
return MyCustody(my_fake)
tests/integration/test_backend_contracts.py is the worked example — both
shipped backends subclass the same classes you will.
Passing this suite unmodified is the acceptance test. If you find yourself
wanting to change contracts.py to make your backend pass, that is the contract
telling you something, and it is worth listening to before you edit it.
5. Regtest and a drill¶
deploy/docker-compose.regtest.yml is hand-rolled on purpose — keep it that
way. Add whatever node your asset needs, then extend
scripts/dev/smoke_test.py with a drill that asserts about money: the exact
number of units credited, and the number of times.
The existing eleven are the standard to match. Each one names a failure it would catch — the outage drill exists because "the poller credits what webhooks never delivered" is the entire claim of the reconciliation design, and a drill that only tests the happy path would not notice it breaking.
Put your nodes in an overlay file, not in the base stack. Lightning is
deploy/docker-compose.regtest.lightning.yml, and somebody working on BTC
should not have to boot three LND containers to run one drill. Pin images by
digest: a nightly whose images move under it is a green build that proves
nothing about the version it claims.
Stage the failure, not just the success. Drill 10 asks for more than the node can route, and getting there needed a third LND node. With two, deposits and withdrawals share a channel, so every satoshi a user deposits becomes a satoshi we can pay straight back and a liquidity failure is impossible to set up. A separate payee behind a deliberately small channel makes its inbound capacity the binding constraint — which is what runs out first in production anyway. If the interesting failure for your asset cannot be staged, say so rather than testing around it.
If your asset cannot be exercised on regtest — as USDT cannot — say so explicitly in your PR and in the compose file. An untestable path that nobody flags reads exactly like a tested one.
6. What the reconciliation jobs will do to your asset¶
Automatically, whether or not you asked:
- Job B polls your automated backend for every withdrawal in
submittedorbroadcastand applies the result through the shared state machine. - Job C compares, hourly, what the ledger says you hold against what your
CustodySourcereports, and alerts on insolvency. It alerts and never repairs — a job that silently corrects the books destroys the evidence that something was wrong. - The wallet scan looks for unattributed receives, but only if
has_btcpay_wallet=True. Set it false and you lose that detector; that gap is real and should be documented for your asset the way it is for USDT and BTC_LN. How much it costs you depends on the rail: for USDT it is serious, because a pooled address can be paid again a week later. ForBTC_LNit is close to harmless, because a BOLT11 invoice belongs to one deposit and can be paid exactly once. Work out which you are, and write it down. - The payout deadline, if you set
submitted_timeout_seconds, cancels a payout that has satsubmittedtoo long and then asks your backend whether it can prove the money never left.
Invariants you may not weaken: entries sum to zero, no zero-amount postings,
materialized balances equal the sum of postings, no credit-normal account goes
positive, postings are never updated or deleted, one effect per
(kind, source_ref), one payout and one txid per withdrawal, a hold released
exactly once.
7. What this design does not prove¶
Being straight about the limits of the contract:
- Every asset so far rides a BTCPay invoice for deposits. The contract has never been tested against a rail that does not.
- All three shipped backends talk to an HTTP API with a request/response shape. A backend that had to hold a signing key and construct transactions locally would stress parts of this contract nothing has stressed yet.
- The USDT path cannot be exercised on regtest, so "USDT behavior is unchanged" rests on unit tests against a fake and on live Nile verification, not on a drill.
- The contract has now been used once by a rail it was not designed around
(
BTC_LN), and it needed two additive hooks to fit. One use is evidence, not proof. Expect the third asset to find something too — and prefer adding an optional hook to bending an existing protocol, which is what happened here.
8. Worked example: how BTC_LN was added¶
Lightning is the asset this contract was tested against, and it was chosen because it stresses the seams rather than because it was easy. Read the commits in order; each one is a stage of this guide.
| Commit | What it did | Which section |
|---|---|---|
55fdab6 |
BOLT11 decoding in core/addresses.py, and the spike's captured Greenfield payloads into the corpus |
§2, destination validator |
afc94b2 |
Fee-drift journalling, the two capability protocols, LightningPayoutBackend |
§2, backend + ReportsActualFee |
b02c475 |
The assets row, the registry entry, LightningNodeCustody, submission_guard |
§3 |
30f8501 |
The submitted deadline and the definitive-failure release |
§2, ProvesDefinitiveFailure |
a227e18 |
Three LND nodes on the regtest stack, and the bootstrap behind a flag | §5 |
What went to plan¶
The deposit rail needed nothing. A top-up invoice already offers BTC-LN,
attach_invoice writes the BOLT11 into deposits.address without caring that
it is not an address, and a paid Lightning invoice goes straight to Settled
with no Processing in between — so the ordinary settled path credits it.
The withdrawal backend needed nothing new either.
LightningPayoutBackend inherits initiate, poll_status, cancel and
find_for_withdrawal from BtcpayPayoutBackend unchanged, and passes
AutomatedBackendContract unmodified. That passing run is the acceptance test
of everything in this guide.
The ledger needed no change at all. BTC_LN is a separate asset with its
own accounts, which is also the honest custodial model: channel balance and
wallet balance are different money with different risk.
What did not¶
Three things fought back, and they are the useful part of this example.
A fee you cannot know in advance still has to be booked. FlatFee reserves
nothing for the rail, so committed == net and the whole routing fee was
drift the settle entry never recorded. Left alone, every Lightning withdrawal
would take slightly more out of the channel than the ledger said, cumulatively,
until Job C reported an insolvency with nothing to point at. Hence
ReportsActualFee and the drift posting.
A destination can go bad while it waits. pending_approval has no horizon.
A BOLT11 invoice that was alive when the user asked is often dead by the time an
operator approves, and a payout against an expired one can never be paid. Hence
submission_guard, run twice.
A rail can fail routinely, never say so, and refuse to be stopped. BTCPay's
Lightning processor takes an unroutable payout, parks it InProgress and
leaves it there — and DELETE on one of those answers 400 invalid-state.
Hence submitted_timeout_seconds, and — because route-not-found is an everyday
event rather than an incident — ProvesDefinitiveFailure, so the ordinary case
does not become an admin queue.
Both of those facts were found by drill 10 against a real stack, after a
two-day feasibility spike had recorded the opposite. The spike watched a payout
with no processor configured and saw it wait politely in AwaitingPayment; the
real thing has a processor. If you are adding an asset, assume the same: a
spike tells you what is possible, and only the drill tells you what happens.
The other two backends¶
ManualTronBackend (operator flow, no automation possible) and
BtcpayPayoutBackend (automated, crash recovery through metadata) are complete
implementations in services/backends.py, with their registry entries in
services/asset_registry.py and their conformance runs in
tests/integration/test_backend_contracts.py. Read those three files together —
between them they cover every facet this guide describes.