dotnet testThe default run covers the plugin's own logic — invoice-record state transitions, payment-hash mapping,
connection-string handling, listener safety, settlement fan-out, the sweep engine's economics, guards and
crash recovery, and the whole ILightningClient surface driven through a fake SDK. It needs no Docker, no
database and no network, and finishes in a couple of seconds. It does not cover the EF store or the SDK
itself; those need the opt-in suites below.
The fake SDK deliberately models the real one's hazards rather than an idealised SDK: a cooperative-exit
quote that does not check the balance, quotes that expire, the script-type dust floor, a send that returns
still-pending and never completes, an idempotency-key replay that returns the original payment without
spending again, and a Payment whose amount is already net of the fee under FeesIncluded (as the real one
is — a fake that echoed the request back instead is what let the sweep message double-subtract the fee on
mainnet). A cooperative fake would let through precisely the bugs these tests exist to catch.
The suite runs on Microsoft.Testing.Platform, not VSTest: xunit.v3 4.0.0 dropped the VSTest bridge on the
.NET 10 SDK. Two things turn that on and both are needed — UseMicrosoftTestingPlatformRunner in the test
.csproj, and the repository-root global.json, which is what makes plain dotnet test speak MTP. The
--filter "Category=…" syntax below is unaffected; MTP's xunit runner accepts VSTest filters verbatim. What
does change is the console-verbosity flag: --logger "console;verbosity=detailed" (and its -l short form)
is VSTest-only, and MTP does not reject it — it reads it as a filter, matches no tests, and exits 5 with
"Zero tests ran". Use --output Detailed instead.
It also covers what the compiler cannot: ViewComponentCompatibilityTests resolves every <vc:…> tag,
partial, layout and UI-extension target the plugin names as a string against the components and views that
actually exist in the pinned submodule. Those are resolved by name at render time, so a green build against
an older BTCPay says nothing about them: a <vc:…> tag compiles to a string looked up in a dictionary the
host builds at startup, and against a host that lacks the component Razor emits the tag as literal HTML rather
than failing. That is how the declared floor in Constants.MinBTCPayServerVersion was once lowered onto a
BTCPay that 500'd on every plugin page. Raising the floor is a support decision; lowering it is an
engineering claim, and needs a real host of that version with every view actually rendered — not a green
build.
Against a real Postgres — required before trusting any change to Data/, because the store's
compare-and-set behaviour and its interaction with BTCPay's EnableRetryOnFailure cannot be reproduced
in memory:
docker run -d --rm --name spark-test-pg -e POSTGRES_PASSWORD=sparktest \
-e POSTGRES_DB=sparktests -p 54329:5432 postgres:17-alpine
SPARK_POSTGRES_TESTS="Host=127.0.0.1;Port=54329;Database=sparktests;Username=postgres;Password=sparktest" \
dotnet test --filter "Category=Postgres"Against the Lightspark-hosted regtest — loads the SDK's native library and talks to the real service provider. No API key is needed; regtest accepts a null one:
SPARK_INTEGRATION_TESTS=1 dotnet test --filter "Category=Integration"It connects a throwaway wallet and mints a real lnbcrt… invoice. It cannot cover settlement, which
needs a funded wallet — which is what the next section is for.
The regtest smoke test above stops at the point where anything settles. Everything past that point — a
Lightning receive that actually completes, a cooperative exit that reaches Confirmed, a sweep interrupted
between the record insert and the send and then recovered — has never run against the real SDK. Neither have
the log lines a completed payment emits, which is where a preimage would appear and is the one stated gap
in the plugin's log audit and in SparkLogScrubber's remarks.
A funded wallet closes that. It is one wallet, holding regtest coins that are worth nothing, shared by CI:
SPARK_REGTEST_SEED="<twelve words>" dotnet test --filter "Category=FundedRegtest"Absent the variable the suite skips, exactly as the Postgres and integration suites do. The suite needs 100,000 sats to start and burns roughly 4,000 sats per run in cooperative-exit fees — the principal is not burned, because sweeps are directed at the wallet's own static deposit address and come back on-chain.
1. Generate a seed — on your own machine, never in CI.
SPARK_REGTEST_WALLET_GENERATE=1 dotnet test \
--filter "FullyQualifiedName~Generate_a_wallet_mnemonic" --output DetailedThis needs no network, so it works from a machine whose IP the SSP blocks. The generator asserts that
GITHUB_ACTIONS is unset and fails if it is: it prints a private key to stdout, and a job log is a
published document readable by everyone with repository access.
Why the seed is not generated in CI. The obvious design — CI mints a mnemonic and returns it in an encrypted artifact — has no safe return channel. An encrypted artifact needs a passphrase, and the only channel
workflow_dispatchoffers is its inputs, which GitHub records against the run unmasked; that puts the key beside the lock. Artifacts are downloadable by every user with read access and retained for months, so a passphrase that leaks once compromises the wallet permanently. Generating locally means the seed crosses a network exactly once — when you paste it into GitHub's secret form over TLS. (Having CI write the secret itself via the API was also rejected:GITHUB_TOKENcannot write secrets, so it would need a PAT withsecrets: write, which is a far larger grant than the thing it protects.)
2. Store it as the repository secret SPARK_REGTEST_SEED.
Settings → Secrets and variables → Actions → New repository secret. Paste the twelve words with no quotes
and no trailing newline; the suite validates the mnemonic up front and says so if you do.
3. Get the deposit address. Actions → Spark regtest wallet → Run workflow. The run summary prints the wallet's static Bitcoin deposit address, its identity pubkey and its balance. The mnemonic appears nowhere — the wallet is identified by a SHA-256 prefix, and the tool asserts the seed is absent from its own output.
4. Fund it at https://app.lightspark.com/regtest-faucet. The faucet is reCAPTCHA-gated, so this step is unavoidably manual. Send at least 150,000 sats; the faucet caps each grant, so this may take a few rounds. The address is static — keep it, and you never need step 3 again.
The funded-regtest-test job fails with a message that begins "The CI regtest wallet is out of money" and
names the balance and the shortfall. That is an operations event, not a code defect, and the message says so.
Send more coins to the same static address from step 4. Nothing else needs to change.
If the wallet empties mid-run, the tests that had already started fail on their own balance checks with the same message rather than on a confusing assertion about a sweep. No state needs cleaning up: the suite holds nothing but a temp SQLite file it deletes, and any cooperative exit already in flight completes on-chain into the same wallet.
To rotate the wallet — the seed leaked, or you want a fresh one — repeat steps 1 to 4. The old wallet's coins are regtest and not worth recovering.
Every run uploads a funded-regtest-log-audit artifact, on success and on failure. It holds:
| file | what it is |
|---|---|
forwarded.log |
what an operator's BTCPay log would have shown — everything through SparkLogScrubber. Withheld if secret material appears in it — the wallet's mnemonic, a recorded payment preimage, the service provider's session token. This is the post-scrub log, so secret material in it is a scrubber hole, and even then a leak must not also be a publication. A withheld file leaves a forwarded.log.WITHHELD.txt marker naming the reason |
sdk.log |
the raw file the Rust subscriber wrote, which no C# scrubbing reaches. Withheld if the wallet's mnemonic, a recorded payment preimage, or the session token appears in it — these artifacts are downloadable by anyone with read access, and a leak must not also be a publication. A withheld file leaves an sdk.log.WITHHELD.txt marker naming the reason |
preimage-audit.md |
the answer: every distinct 64-hex run the SDK emitted, classified against the preimage, payment hash and txid the run recorded, with the surrounding words. Preimage-kind values print as one-way SHA-256 fingerprints unconditionally — the fingerprint plus the occurrence counts proves the same thing without publishing the secret — and public identifiers (payment hashes, txids, idempotency keys) stay verbatim. For a withheld source, its hex rows' values print as fingerprints, their contexts are redacted, and its count columns print withheld; classifications and the raw-side occurrence counts stay |
Read preimage-audit.md first, and check its banner before anything else: a run that aborted on a drained
wallet still produces hundreds of lines of connect chatter and an empty, clean-looking table, and the banner
is what stops that being misread as "measured, nothing there". Only a run that says "This run completed a
payment" is evidence.
Given that, the preimage row must show 0 occurrences in the forwarded column — the suite asserts it, so a
non-zero value is already a red run. Then look at the sdk.log rows classified PREIMAGE. None means the SDK
does not write a preimage to disk at debug, and §8.6's gap closes as measured. Some means reading their
context: a preimage next to a name the scrubber knows is handled; one with no name beside it is the case the
scrubber's remarks left open, and since it lives in sdk.log the fix is a log-level or file-permissions
question rather than a regex. Either way, record the answer in Sdk/SparkLogScrubber.cs and delete the
stated gap. That is what the artifact is for; it only needs reading once.
Both regtest suites above run against Lightspark's hosted service, where everything on the far side of an invoice is somebody else's: no counterparty will pay an invoice the plugin mints, no one can mine a block on demand, and the funded suite needs a faucet-filled wallet behind a repository secret — which is why it goes red when the wallet drains, and why a fork cannot run it at all.
A local stack removes all three constraints. e2e/local-regtest/ stands up
callebtc/cashu-regtest's --spark profile: Bitcoin Core in
regtest, three Spark operators signing 2-of-3, an open-ssp service provider backed by an LDK node,
Electrs serving an Esplora API, and three LND plus three CLN nodes in a funded channel topology. Those
Lightning nodes are the point — lnd-1 pays a Flint invoice and Flint pays lnd-1's, so a settlement
is observable from both sides, with the payment hash checked against what the payer recorded. Deposits
confirm because a test mines them, a cooperative exit reaches Confirmed in seconds rather than whenever
a block arrives, and there is no secret and no balance to keep topped up.
e2e/local-regtest/up.sh # clone at the pinned SHA, then ./start.sh --spark
e2e/local-regtest/write-network.sh # discover the live stack, write network.json
SPARK_LOCAL_REGTEST_NETWORK=$PWD/e2e/local-regtest/network.json \
dotnet test --filter "Category=LocalRegtest"
e2e/local-regtest/down.shSPARK_LOCAL_REGTEST_NETWORK points at a network descriptor: a JSON file naming the three operators
with their addresses, identity keys and TLS certificates, the SSP's base URL and identity key, and the
Esplora URL. The plugin's SparkCustomNetwork loader reads it and rewrites the SDK's regtest config from
it; the test fixture additionally reads the container names it needs to drive bitcoin-cli and lncli.
It has to be generated rather than committed, because the SSP's identity key and the operators'
certificates are created fresh by every start.sh — so re-run write-network.sh after every
up.sh. Absent the variable the suite skips itself, as the Postgres and integration suites do.
The cost is time: the fixture publishes no images, so a cold run builds six services from Rust and Go
source and takes tens of minutes (a few minutes once the layers are cached). On Docker Desktop for macOS
the fixture's Core Lightning nodes cannot write their SQLite database on a bind mount, so up.sh gives them
named volumes through a compose override there; see the
fixture README. Do not mine or start the suite while up.sh is
still running — the fixture's init waits for every node to reach one exact height, and outside mining
makes that wait time out with the SSP unfunded.
Everything the stack holds — the cashu/cashu RPC credentials, the regtest-spark-admin-token, the
operator keyshares — is a public fixture value, and the suite's wallet is random per run. Details, and how
to bump the pin, are in e2e/local-regtest/README.md.
The suite above drives the plugin's own collaborators and connects an SDK instance of its own. That is
enough to prove the money paths, but it means the plugin has never been observed inside a host: no
BTCPay invoice ever reached Settled, the Lightning payment method the provisioner writes was never
resolved by BTCPay, the reconciliation and sweep tasks never ran on BTCPay's schedule against real store
settings in Postgres, and the plugin was never once loaded the way an install loads it — as a directory
under the data dir, out of the official image, with Breez's native library resolved by BTCPay's plugin load
context. e2e/btcpay/ closes that gap.
It brings up BTCPay Server, NBXplorer and Postgres joined to the Spark stack's own docker network, so
NBXplorer uses the fixture's bitcoind and the plugin inside BTCPay reaches the fixture's operators, SSP
and Esplora. One chain, one fee market, and a cooperative exit the tests can mine to confirmation.
e2e/local-regtest/up.sh # the Spark stack first; this needs it
e2e/btcpay/up.sh # BTCPay + NBXplorer + Postgres, then provision and fund
FLINT_BTCPAY_E2E=$PWD/e2e/btcpay/btcpay.json \
dotnet test --filter "Category=BtcpayE2E" --output Detailed
e2e/btcpay/down.sh # BTCPay side only; then e2e/local-regtest/down.shup.sh takes about 20 seconds on a warm machine — it starts three containers, waits for BTCPay's
/api/v1/health, and then does everything a merchant would do, over Greenfield: create the first
administrator, mint an API key, create a store, POST /api/v1/stores/{id}/spark with
seedSource: generate, read the deposit address back, pay it from the fixture's bitcoind, mine, wait for
the claim, and configure sweeping. It writes e2e/btcpay/btcpay.json — base URL, API key, store id and the
same fixture block network.json carries — which is the only thing the tests read. The suite itself takes
about two minutes, nearly all of it the cooperative exit.
Two things about that provisioning are worth knowing before scripting anything similar.
- The API key needs
btcpay.server.canmodifyserversettings, even when it belongs to an administrator.seedSource: generatecreates a hot wallet, and BTCPay's ownCanUseHotWalletcheck answers yes only when the server policy allow non-admins to create hot wallets is on, or when the caller holds the server-settings policy — and an API key is judged on its own permissions, not on its owner's role. Without it the provisioningPOSTanswers403 hot-wallet-not-allowed. Seegreenfield-api.md. - The image is pinned by digest to
btcpayserver/btcpayserver:2.4.5, matching thebtcpayserversubmodule tag and thereforeConstants.BuiltAgainstBTCPayServerVersion. The official image is a Release build, soDEBUG_PLUGINS— the side-loading routedevelopment.mduses — does not exist in it;up.shinstead stages the Release build output as<plugindir>/BTCPayServer.Plugins.Flint/, which is the layoutPluginManagerscans and byte-for-byte whatPluginPackerwould have zipped.BTCPAY_PLUGINDIRis set explicitly because BTCPay resolves the plugin directory independently ofBTCPAY_DATADIR.
The three tests each close one host-facing gap, and each is checked against the counterparty rather than against the plugin:
- an invoice created through Greenfield, its BOLT11 read off the payment-methods endpoint, paid by
lnd-1, reachingSettled— with the payment hash BTCPay recorded compared against the hash LND says it paid; POST /api/v1/stores/{id}/lightning/BTC/invoices/paypaying an invoicelnd-1minted, with the verdict read from LND's own copy of that invoice — which also proves BTCPay resolved a plugin-suppliedILightningClientfrom the connection string the provisioner wrote;POST /api/v1/stores/{id}/spark/sweepreaching outcomeSwept, thenConfirmedafter mining, with the record's txid looked up in the fixture'sbitcoindand its confirmations counted.
The fixture's SSP liquidity is the consumable, and the numbers are tight. The wallet is funded with
150,000 sats out of the SSP's own Spark leaves, and the deposit is only the first demand: every Spark payment
out of the wallet needs the SSP to split a leaf it can back. Against a leaf set that is too small or too
coarse, a wallet takes its deposit and then refuses both the first Lightning send and the exit quote with
Tree service error: insufficient funds — a message that names the wallet and means the SSP. This is
sequence-dependent: the fixture seeds one 500,000-sat leaf, the LocalRegtest suite that runs first in CI
borrows ~140,000 of it and returns most through its cooperative exit, and this suite's deposit then left
~360,000 — at which point a 3,000-sat send failed, while the same run against two leaves (857,000) passed.
So up.sh tops the SSP up itself until it holds at least five times the funding amount, adding one
500,000-sat leaf per round exactly as the fixture's cashu-spark-fund-ssp does (admin deposit address →
bitcoind → three confirmations → admin claim). Regtest coins are free; the top-up costs about ten seconds.
down.sh is what keeps that from being necessary often. It pays the store's remaining balance back to
lnd-1 over Lightning before removing the containers, which returns the leaves to the SSP: a full
up.sh → suite → down.sh cycle was measured to cost the SSP 500 sats net. The return leg is a
Lightning payment and not a sweep on purpose — an exit on this chain costs around 20,000 sats, so the
plugin's fee guard correctly refuses any remainder much smaller than that, which is exactly the amount left
after the sweep test. Without it the leftover is stranded in a wallet whose storage down.sh destroys, and
one measured pair of runs took the SSP from 500,000 to 382,000.
Category=LocalRegtestExit is the exception, and it is not revenue-neutral. That category holds the
end-to-end unilateral exit (SparkLocalRegtestExitTests), which runs the whole exit against real operators:
it quotes, derives and funds the plugin's CPFP key from bitcoind, builds and signs, then broadcasts the
transactions exactly as the exit page instructs — fan-out and sweep alone, tree nodes as packages via
submitpackage, mining between rounds so every CSV timelock matures — and asserts the destination address
receives recoverable + unspent funding − total fee. It passes in CI in about 1m 40s.
It costs the SSP a whole wallet, because an exit converts that wallet's balance to on-chain Bitcoin at a
destination address and the return leg cannot undo that. Measured: repeated runs take a stack from 500,000
to 0 in a handful of runs, after which fixture setup fails with amount cannot be represented by available leaves without creating a child below the configured split floor. Hence teardown does not restore it and
local-regtest.yml tops the SSP up explicitly before running the category. Locally, top up with
cashu-regtest/docker-scripts.sh's cashu-spark-fund-ssp (500,000 per call) before each run.
It is also deliberately separated in CI rather than folded into Category=LocalRegtest, for two measured
reasons: it cannot share the suite's wallet (it exits the balance, and whichever ran first would decide
whether the other tests had any money), and it cannot share the stack (a second wallet funded from the
same SSP while the suite ran produced a failure in the suite's Lightning send, reported as Error: with an
empty message). So it runs as its own step, after the suite and after the top-up.
Observed figures from a green run, for calibration: 150,000 sats deposited, 140,100 credited (a
9,900-sat claim fee at ~100 sat/vB, auto-claimed by the SDK's own worker), and a sweep of 125,100 delivering
105,400 for a 19,700-sat exit fee — 18.7% of what the destination received, inside the store's 40%
ceiling and well inside the engine's 50% hard backstop. Every one of those numbers is set by the fixture's
fee market, which drifts upward as the fixture sends more fee_rate=100 transactions, so the sweep test
quotes first and sizes itself off the quote rather than fixing an amount.