Non-custodial collateralised lending on Sequentia. Borrow one issued asset against another -- or against native Bitcoin on the parent chain -- with the loan's terms compiled into a covenant and enforced by the script interpreter rather than by an operator. Restricted assets get weaker arrangements, labelled as such.
Design and security analysis: docs/pignus-design.md.
A borrower locks collateral in one taproot UTXO with a NUMS internal key -- so there is no key path -- whose only ways out are the four exits below.
| leaf | who | needs | does |
|---|---|---|---|
REPAY |
anyone | nothing at all | pay the lender the debt, return the whole collateral to the borrower |
LIQUIDATE |
anyone | an oracle attestation under the strike | pay the lender, keep the bonus, return the surplus |
DEFAULT |
anyone | an attestation, after maturity | the same seizure, at any price |
RECOVER |
anyone | a long timeout after maturity | sweep the whole collateral to the lender's pinned address: the oracle-liveness backstop |
No exit needs a signature. Every leaf reads what it enforces out of the transaction and pays a destination baked into the address, which is what lets a browser wallet drive all four -- a wallet can sign its own inputs, but not a covenant leaf.
Every term -- both asset ids, the total repayment, both payout scriptPubKeys, the oracle key, the price feed, the strike, the maturity, the bonus -- is a constant inside those leaves, and the leaves are committed inside the taproot output key. So the terms and the address are the same fact stated twice, and that is what makes the one check below sufficient.
REPAY goes further: no oracle either, and no witness data at all. A solvent
borrower can always leave, whatever anyone else does.
Pignus calls its collateral types tiers. Tier A is any unrestricted issued
asset in the covenant vault above, and is the design. Tiers B, C and D --
native Bitcoin, OpenAMP and OpenDAMP restricted assets -- are weaker on
purpose; each has its section below, and What is trusted, and what is not
says what each gives up. Section 8 of
docs/pignus-design.md says why each is what it is.
git clone https://github.com/ConcatenaLabs/pignus
cd pignus
bin/pignus-cli --version
On the testnet:
- the page is
https://sequentiatestnet.com/lending/. It signs nothing itself: the keys are in Ambra for Chromium (github.com/ConcatenaLabs/sequentia-extension), the browser extension it drives; - testnet assets come from
https://sequentiatestnet.com/faucet; - a source tarball of this repository is at
https://sequentiatestnet.com/download/; - problems go to
https://github.com/ConcatenaLabs/pignus/issues.
Nothing to build and nothing to install: the commands are the scripts in
bin/, and Python 3.9 or later is all they need. Put bin/ on your PATH if
you would rather write pignus-cli than bin/pignus-cli -- this document
writes the short form throughout.
Anything that derives an address also needs a Sequentia source checkout,
because that is where the proven covenant lives; pignus-cli selftest says so
plainly if it cannot find one. It looks beside this checkout at ../Sequentia,
then at ~/Sequentia, then at vendor/sequentia, and SEQUENTIA_SRC names
one anywhere. When SEQUENTIA_SRC is set it is the only place looked at: a
wrong path is reported, never silently replaced by one of the defaults.
git clone https://github.com/ConcatenaLabs/Sequentia ../Sequentia
bin/pignus-cli selftest
Commands that touch a chain need a node's RPC as well; the flags and the
PIGNUS_RPC_* variables are under From the command line. show, quote
and address need neither a node nor a wallet. verify needs a node to read
the funded output, unless --spk hands it the scriptPubKey, and never a
wallet.
pignus-cli verify --terms loan.json --txid <funding> --vout 0
It rebuilds the vault address from the terms you agreed and compares it to the output actually being funded, and it asserts the internal key is NUMS. A loan whose debt is one atom different, or whose lender, oracle, market or strike has been swapped, compiles to a different address and is refused.
--txid reads the output out of the utxo set with gettxout, so no transaction
index is needed. A vault that has already been spent is not in that set: check
one with --blockhash <block>, or with --spk <hex> if you have the
scriptPubKey directly. There are two vault layouts -- the four-leaf tree of a
directly originated loan and the single-leaf vault a funded offer creates -- and
the command accepts either and reports which one matched, or insists on one with
--four-leaf / --single-leaf.
Run this before signing anything, and again on the vault a take produced:
offer-take prints a record --terms reads, so the check is the same one
whether the loan came from a resting offer or was originated directly.
Everything Pignus claims reduces to it; a wallet or a book that skips it has
quietly reintroduced a trusted party.
As a lender: connect the wallet, open Lend, pick a market, set the amount per loan, the number of loans, the interest, both loan-to-value figures and the term, and publish. Your principal goes into an offer covenant; borrowers take it while you are offline, and anything untaken comes back with Withdraw once the offer expires.
As a borrower: open Borrow, pick an offer and press Borrow; the page shows the vault address and the exact terms before your wallet asks you to sign. Repay from Loans at any time until the vault is spent -- before maturity, or after it while nobody has called the loan -- and the collateral returns in the same transaction. For native Bitcoin, use BTC collateral: the collateral waits in a pre-vault only you can take back, from its abort height on, until you claim the principal.
A cross-chain loan is remembered in the browser and rebuilt from the book on any device with the same wallet, and what the chain says is read directly: that the collateral moved into the vault, that the lender claimed the repayment, that the funding reached the chain at all. A report the lender's responder never sent costs the borrower nothing. A take the lender never answered, or whose funding was signed but never broadcast, has a button to forget it; a funding this browser signed and did not send can be broadcast later, after the release and both chains' deadlines are checked again.
A borrower's own risk is a price moving while their attention is elsewhere, so
the page says what needs a person, above everything else, for whichever seat
the wallet is in: a loan close to or under its strike (and for how long nobody
has liquidated it), one that has matured or whose oracle-free sweep has opened,
a maturity days away, a cross-chain repayment deadline coming up or a reclaim
fee the parent chain has outgrown; and for a lender, an expired offer with lots
untaken, a matured loan to call, a loan under its strike that nobody has taken.
Each carries the button, and says when the wallet could not pay for it. The
count goes in the browser tab's title, the one surface a background tab still
owns. Each tab has its own
link -- #borrow, #lend, #loans, #repo, #btc after the page's address
-- so a runbook or a message can point at one, and a reload lands where you
were.
pignus/compat.py imports the PROVEN covenant and refuses a drifted one
pignus/terms.py LoanTerms: the agreement, the address, and verify_funding()
pignus/oracle.py attestation format, signing, verification, price quoting
pignus/vault.py every transaction: fund/take/withdraw an offer, the four
exits, explicit-coin preparation for node wallets
pignus/fees.py a fee in any asset, from the node's exchange rates
pignus/watcher.py reconcile loans AND offers to the chain; name each exit;
discover loans from take witnesses; catch ghosts; give
a poll up on a node that stops answering
pignus/book.py the loan book: discovery, nothing else
pignus/offers.py funded resting offers (the node repo's pignus_offer.py)
pignus/btc_collateral.py native BTC collateral (Tier B): the pre-vault, the
vault, and both chains' legs of a cross-chain loan
pignus/btc_relay.py what a relay may and may not be believed about: the
signatures on every offer and every lender's report
pignus/adaptor.py BIP340 signing and verification, and the Schnorr
adaptor signatures the DLC settlement uses
pignus/dlc.py DLC primitives for settling BTC collateral at maturity;
a library, used by nothing else here
pignus/btcscript.py the Bitcoin script and taproot primitives Tier B needs
pignus/openamp.py Tier C pledges at an OpenAMP policy server
pignus/repurchase.py Tier D: the OpenDAMP repurchase, labelled as one, never a loan
pignus/node.py a thin JSON-RPC client; many calls in one round trip
pignus/ratelimit.py the token bucket both daemons ration unauthenticated
requests with, in one place rather than a copy each
bin/pignus-oracle sign prices on a timer and publish them
bin/pignusd the loan book, the watcher, the cross-chain relay, and
the page at /lending/
bin/pignus-cli selftest, quote, propose, show, address, verify, status,
loans, offers, explain, check-attestation; with a node
wallet:
offer-fund, offer-publish, offer-take, offer-delist,
offer-withdraw,
loan-export, repay, liquidate, default, recover; btc-*
(Tier B, both chains); pledge-* (Tier C); repo-*
(repurchase)
bin/pignus-liquidator one liquidator among however many people run one
web/ the browser client pignusd serves: pignus.js, offer.js,
repurchase.js, pset.js, flows.js, wallet.js, alerts.js, app.js,
and for Tier B btc.js, adaptor.js, btcborrow.js
deploy/ systemd units (oracle, book, cross-chain responder),
example configs, DEPLOY.md
docs/pignus-design.md the design and security analysis
docs/api.md every HTTP endpoint the book and the oracle serve
There is one proven implementation of the covenant, in the node
repository's test/functional/pignus_covenant.py, proven against a node by
feature_pignus_vault.py. This package imports it rather than porting it: a
port that differs by a single byte derives a different address, and the failure
mode of a wrong vault address is collateral nobody can ever spend.
pignus/vectors.json exists for implementations that genuinely cannot import
Python. The browser is that second implementation -- web/pignus.js and
web/offer.js for the covenant, web/repurchase.js for the repurchase vault,
web/btc.js and web/adaptor.js for the parent chain -- each pinned byte for
byte to vectors the proven Python emits (pignus/vectors.json,
web/btc_vectors.json, web/adaptor_vectors.json), and the page refuses to run
if the loan pinning fails. compat.verify_builder() uses the vectors here as a
tripwire, refusing to derive addresses from a builder that has changed; it runs
the first time any process loads the covenant, so a drifted checkout is caught
before an address is derived from it rather than after.
The book follows the chain on its own. An offer's coin is watched; when it is
taken, the borrower's payout program is read out of the take witness, the new
vault is registered as a loan, and the offer moves to its remainder. A loan
taken by any wallet, through the page or not, turns up on the page, provided the
take is within the watcher's scan depth (rescan_depth blocks); after a longer
outage, start the daemon once with --rescan-from <height>.
Everything the page does, from a node wallet. Each command derives the address it acts on from the terms and checks it against the coin before building anything; the fee options below say how fees are priced and coins prepared.
pignus-cli loans --book <url> --mine and pignus-cli offers --book <url> --mine list the loans and offers this wallet is party to, deciding "mine" the
way the page does: by matching each record's payout programs against the
wallet's coins and payout address. Both exit 4 when something of yours needs a
person, so either one in a cron job that mails you is a monitor. pignus-cli status --terms loan.json --book <url> adds the book's price, health and
liquidatable_since to the reconciliation without anybody typing --price,
and --watch keeps going, printing a line whenever the state, liquidatable,
matured or recover_open changes.
pignus-cli offer-fund --market GOLD/USDX --principal 100 --lots 3 \
--interest 3 --open-ltv 50 --liq-ltv 75 --term-days 30 --rpc-wallet me
pignus-cli offer-take --offer <id> --rpc-wallet me
pignus-cli repay | liquidate | default | recover --loan <id> --rpc-wallet me
pignus-cli offer-delist --offer <id> --token <manage token>
pignus-cli offer-withdraw --offer <id> --rpc-wallet me
offer-delist and offer-withdraw are different acts. Delisting takes the
book's advertisement down so nobody new takes the terms, and touches no coin;
withdrawing brings the principal itself back, and only once the offer's expiry
has opened. A delisted offer's record stays in the book, hidden from the board,
because it is the only copy of the terms the refund is built from;
offer-withdraw finds it by id, and the page keeps it under "mine" with its
Withdraw button. Nothing returns on its own. The manage token printed when the
offer is listed (by offer-fund, or by offer-publish) is served once and
stored only as a hash, so a lender who loses it waits for the expiry.
pignus-cli <command> --help is the complete list of options for any command.
What shapes an offer is worth having here:
| flag | what it sets |
|---|---|
--principal / --lots |
lent per loan, and how many loans the one coin holds |
--interest |
percent over the whole term (default 3) |
--open-ltv / --liq-ltv |
the loan-to-value a loan opens at and the one it liquidates at (default 50 and 75) |
--term-days / --offer-days |
the term, and how long the offer stays open (default: the term) |
--bonus |
the liquidation bonus, percent (default 5) |
--borrower-ver |
the witness version borrowers are paid out at: 0 for a bech32 extension wallet (the default), 1 for taproot. It is part of the offer's address, so it cannot be changed afterwards |
--oracles / --oracle-threshold |
an m-of-n oracle set, below; the threshold defaults to all of them |
--memo |
a note kept in the terms |
--no-publish |
fund the offer without listing it on the book |
--book names the pignusd to read markets and offers from (default
http://127.0.0.1:8741, or PIGNUS_BOOK). The node wallet that signs is named
by --rpc (default http://127.0.0.1:18776, the RPC port of the binary's
default chain; PIGNUS_RPC_URL), --rpc-wallet, and either --rpc-cookie or
--rpc-user/--rpc-password. Every one of them also reads a PIGNUS_RPC_*
environment variable, which is where credentials belong: a command line is
readable by every process on the machine.
Every command that composes a covenant transaction takes the same three fee
options. --fee-asset and --fee-amount name the asset and the atoms; the
default is the asset already being spent, and failing that anything held with a
published rate. Nothing falls back to a privileged asset, because there is not
one. --prep-fee-asset names the asset for the preparing sends -- the ones that
give the wallet explicit coins, since a covenant cannot spend a blinded input
and a node wallet's change is blinded -- and defaults to --fee-asset. The
cross-chain btc-* commands take --fee-asset on its own, their Sequentia legs
being one plain payment each. --dry-run broadcasts nothing at all, preparing
sends included. Most commands then print the transaction hex. offer-fund and
offer-take print the terms instead, because the transaction they would build
needs explicit coins that only a real preparing send produces; repo-fund
prints the address and the amounts. In every case nothing left this machine.
liquidate and default take their price from the book, or from
--attestation, --attestations (for an m-of-n loan) or --oracle. It is
verified locally against the key the vault bakes in either way, and refused
if it was signed more than --max-attestation-age seconds ago -- 600 by
default, --allow-stale to build against an older one anyway. Tapscript can
tell that an attestation is newer than the loan but not that it is recent, so
recency is checked off chain by whoever builds the spend, or not at all.
pignus-cli --version prints the version and --debug re-raises with the
traceback instead of a one-line message. A command exits 0 when it is done, 1
for an error, 2 for REFUSED -- a check failed, and nothing was built or
broadcast -- 3 when the covenant builder could not be loaded or has drifted
from the golden vectors, and 4 when a check command ran and found a state that
is not safe to act on. Status 2 is the one worth scripting against: it is the
promise that nothing happened. Status 4 is what loans --mine, offers --mine, btc-responder-status, pledge-seize, repo-verify and
repo-settle --inspect report; each section says which states earn it.
A loan does not need the book at all. --loan <id> is a convenience that looks
the terms up; with the terms file in hand, pignus-cli repay --terms loan.json --txid <vault txid> --rpc-wallet me closes it against nothing but a node.
pignus-cli loan-export writes that file out of the book for keeping.
After a loan closes, pignus-cli explain reads the ending back off the chain:
which exit was taken, the attested price behind a seizure, checked against the
key the vault itself bakes in, and what the transaction actually paid each
party against what the terms say that price buys. With --terms and --txid
it does that against your own node and nothing else; with --loan <id> it
returns the book's verdict, and says so, since a book is the party that
computed it.
A price is debt-asset atoms per collateral-asset atom, scaled by
price_scale (default 1e5). Quoting per atom is what keeps the covenant
ignorant of either asset's decimals.
pignus-cli quote --market GOLD/USDX --collateral-ref 3000 --debt-ref 1
prints 300000000: 3,000 USDX atoms per GOLD atom once the 1e5 scale is
divided out, i.e. 3,000 USDX per GOLD. Beside it comes strike_at_liq_ltv, the
strike a loan opened at --open-ltv (50% by default) would need for each of the
usual liquidation ratios -- the same arithmetic offer-fund does, so a lender
can see the number before committing to it.
The seizure forms gross * price_scale on chain, and OP_ADD64 aborts on
64-bit overflow, so at 8 decimals and the default scale one loan caps at about
878,000 units of the debt asset. Lower price_scale to 1e4 or 1e3 for
~8.8M or ~88M, trading price precision for size. The builders assert the bound
at construction, so a loan that could not be liquidated cannot be created.
An m-of-n loan bakes in several independent oracles and needs threshold of
them to agree before it can be liquidated:
pignus-cli offer-fund --market GOLD/USDX --principal 100 \
--oracles book --oracle-threshold 2 --rpc-wallet me
--oracles book uses every oracle the book quotes against; the CLI, the
liquidator and the browser all assemble the threshold witness.
The threshold is a constant inside the vault's leaves, so it can never be lowered afterwards, and that cuts both ways. A 1-of-n is WEAKER than a single oracle, because any one of the keys can liquidate alone; the CLI refuses it and the page never offers it. An n-of-n is the other end: while any single one of those oracles is down, the loan cannot be liquidated at all until maturity -- the lender's backstop still works, but the price-driven exit does not. Both the CLI and the page say so at the moment the choice is made, which is the only moment it can be changed.
Liquidation is a permissionless race. Every racer must pay the lender in full and return the borrower's surplus, so the borrower and the lender are indifferent to who wins, and the bonus is what prices the race. Nothing about running one is privileged, and nothing on the testnet runs one for you.
pignus-liquidator --book http://127.0.0.1:8741 --rpc http://127.0.0.1:18776 \
--rpc-wallet liquidator --taker-address <addr> --dry-run --once
Always start with --dry-run --once: it reports what it would do and
touches nothing.
| flag | what it does |
|---|---|
--book |
a pignusd to read LIVE loans from, re-read every round, so a loan that appears while it runs is watched too |
--oracle URL |
an oracle to read prices from, repeatable; needed when there is no book, and worth adding for loans baked to a key the book does not quote |
--loans FILE |
a JSON list of {"terms": …, "txid": …, "vout": 0, "single_leaf": bool} instead of a book |
--taker-address / --taker-spk |
where seized collateral is paid |
--fee-asset / --fee-amount |
the fee asset and atoms; by default the debt asset being spent, else anything held with a published rate |
--min-profit ATOMS |
skip a seizure whose collateral, at the attested price, is worth less than this much more than the debt it pays AND the network fee it costs (default 0: never at a loss). The fee is priced with the same call the spend will use, so the two cannot drift |
--max-attestation-age S |
ignore a price signed longer ago (default 600); the covenant cannot check recency, so the spender must |
--allow-stale |
act on older prices anyway |
--call-due |
also call loans past maturity, through DEFAULT |
--interval / --once |
how often a round runs, or run one and stop |
--book or --loans is required, and --oracle as well whenever there is no
--book. The RPC flags take the same
PIGNUS_RPC_* environment defaults as pignus-cli, so credentials need not be
on the command line.
Every attestation is verified here against the key that loan names and at the scale that loan computes with, not against whichever oracle served it: a price signed by another key, or quoted at another scale, is a number about a different loan.
The wallet must hold the debt asset -- a liquidator pays the lender in full out of its own pocket and keeps collateral worth more -- plus enough of some asset with a published exchange rate to pay the fee.
Borrow a Sequentia asset against real Bitcoin. The collateral sits on Bitcoin,
the debt on Sequentia, and one hash appears in both chains' scripts: the
Bitcoin vault's RECLAIM leaf demands the secret behind it alongside both
parties' signatures, and the Sequentia repayment can only be taken by publishing
that same secret. So repaying and reclaiming are one act, and the borrower can
check that for themselves rather than being asked to believe it -- the hash they
repay against is the hash their release is built on.
Origination is atomic, because otherwise it is a gift: the collateral waits in a pre-vault the borrower can take back, the principal is paid into an output only the borrower can open, and opening it publishes the secret that moves the collateral into the vault. Neither side ever holds both, and the only party exposed to a loss rather than a delay is a lender who goes offline in the middle of it.
pignus-cli btc-check prints where a loan stands on both chains and whose move
it is next. When the collateral has left the vault it also names WHICH of the
three leaves spent it -- reclaim, seizure or the lender's timeout sweep -- read
out of the spend's own witness, because the three mean opposite things to a
borrower and only one of them is good news.
The page and the CLI are two routes, not one. A loan begun in the browser is
finished in the browser, because its secret lives in the extension; a loan
begun with btc-offer-take is finished with the btc-* commands and the
ticket file it wrote. Neither can pick up the other's loan.
A borrower does all of this in the browser at /lending/. From the command
line, it is a two-party handshake, one command per move, with a ticket JSON
passed between the parties (public state only, never a key or a secret):
pignus-cli btc-keygen --out lender.key # each party once
pignus-cli btc-propose --lender-key lender.key --oracle-x <x> \
--btc-amount 100000 --debt-asset <id> --debt 5250000000 \
--principal 5000000000 --lender-prog <hex> --market BTC/USDX \
--strike <debt atoms per collateral atom, scaled> \
--recover-after <btc-height> --abort-after <btc-height> \
--repay-deadline <seq-height> --d-refund <seq-height> --out loan.json
pignus-cli btc-prepare loan.json --borrower-key borrower.key \
--borrower-prog <hex> --btc-rpc ... # fund the pre-vault, unbroadcast
pignus-cli btc-release loan.json --lender-key lender.key # draw the secret,
# publish its hash,
# sign the release
pignus-cli btc-originate loan.json --borrower-key borrower.key --btc-rpc ...
pignus-cli btc-disburse loan.json --rpc ... # lender: pay the principal
pignus-cli btc-claim-principal loan.json --borrower-key borrower.key --rpc ...
pignus-cli btc-upgrade loan.json --lender-key lender.key --btc-rpc ...
pignus-cli btc-repay loan.json --rpc ... # borrower: pay the hashlock
pignus-cli btc-claim loan.json --lender-key lender.key --rpc ... # reveals t
pignus-cli btc-reclaim loan.json --borrower-key borrower.key --rpc ... --btc-rpc ...
The order matters and is not a matter of taste. The vault's address commits to
the lender's hash, so the borrower cannot sign the move into it until the lender
has drawn the secret; and the lender cannot start the loan until the borrower
has opened the principal, which is what publishes the secret btc-upgrade
needs. Every step before btc-originate commits nothing at all. btc-check
names the next move at each stage, so the sequence need not be memorised.
btc-claim-principal tells the book where the principal was claimed; give
btc-repay a --borrower-key and it does the same for the repayment. Nothing
depends on it -- the
lender's responder reads both off the chain -- but it saves that scan and keeps
the borrower's own page from showing a loan as running after they have paid it.
--borrower-prog and --borrower-ver are the borrower's Sequentia payout
program -- where the principal is paid and where a repayment refunds to -- and
--lender-prog and --lender-ver the lender's; both are 20 bytes at witness
version 0 and 32 at version 1, and both are baked into addresses, so neither can
be changed afterwards. pignus-cli payout-program --rpc-wallet <name> prints a
fresh one from a node wallet in exactly this form. --reclaim-address is the
Bitcoin address the collateral comes back to, a fresh one from the wallet by
default.
The rest of an offer's terms come from the book it is published to.
--oracle-x is the first entry of GET /v1/oracles there, --debt-asset is
the asset id GET /v1/markets shows for the market, and --strike is what
pignus-cli quote computes for the liquidation price. The four deadlines are
block heights, judged against both chains' tips (height and btc_height in
GET /v1/markets) by the publish command, by the relay, and by every
responder, all with the same rules: d_refund at least two hours ahead;
abort_after at least a day after d_refund; repay_deadline at least a day
after d_refund plus the two-hour claim margin; recover_after at least a
day after repay_deadline, and past abort_after. Because the heights are
absolute, the offer leaves the board two hours before d_refund, and a take
made later has a shorter term than an earlier one.
The Bitcoin fees are asked of the node, not assumed. --feerate prices the
funding in sat/vB and, left unset, comes from estimatesmartfee; so does
--upgrade-fee, which is what the pre-vault holds on top of the collateral so
the move into the vault can pay for itself even if the borrower has gone by
then. A constant in either place would be a transaction that confirms when the
parent chain is quiet and sits in the mempool when it is not. --btc-fee is
the flat satoshi fee the transactions spending the vault carry.
A funding that does not confirm is not a delay here; it is a loan that never
starts. The funding is
broadcast after the borrower has already signed the move into the vault, so a
funding stuck in the mempool leaves their collateral committed with no way out
until abort_after. And the upgrade is signed in advance by both parties,
spends a covenant leaf and sets a final sequence, so it can be neither replaced
nor paid for by a child: whatever an offer commits to is the only fee that
move will ever have. An offer published when fees were low is an offer whose
loans cannot be started when they are high, which is why btc-offer-take
refuses one whose fee has fallen far behind the chain and says by how much --
and why the page refuses it too, at the same threshold, before a borrower has
committed any Bitcoin.
Fees can rise after the take, though, and then a lender is right not to pay a
principal into a loan whose start cannot confirm. That leaves a borrower with
collateral committed and nothing moving, so both sides are told: the lender's
btc-responder-status reports a take that has waited on one reason for hours
as needing attention, and the borrower's page reads the same two numbers -- the
offer's fee, and what this book says Bitcoin is charging -- and says the loan
may never start and that the collateral comes back at abort_after. Neither
depends on the other side saying anything.
Several of these commands refuse before they act -- terms whose deadlines leave
no margin, a claim too shallow to spend against, a timelock that has not opened
-- and take --force to proceed anyway. Read what the refusal said before using
it: those checks are most of what stands between an over-collateralised loan and
a stall that pays the other side.
The other endings: btc-seize-sighash / btc-seize (lender + oracle),
btc-timeout (lender, after the term), btc-refund (borrower, if the lender
never claims the repayment), btc-abort (borrower, if the principal never
came), btc-refund-principal (lender, if the borrower never claimed it). The
trust model, the exposure at each step and why liquidation needs the oracle to
co-sign on the Bitcoin side are in the design doc, section 7.
A seizure is the one move that needs a third party while a loan is live: there
is no covenant on the Bitcoin side, so the oracle's signature is the decision.
btc-seize-sighash --out --book writes a request carrying the loan, the
lender's signed offer, and the borrower's own acceptance of it; the oracle
operator co-signs it with pignus-oracle --sign-seize --request, and both the signature and the attestation behind it
are published at the oracle's /v1/seizures -- so a seizure that was not
justified is visible to anyone afterwards, which is the whole of the
accountability this tier has. Two signatures pin the strike, because it is in
no Bitcoin script: the lender's signature over the offer, and the borrower's
acceptance of it (take_auth, fetched from the take), which ties the strike to
the offer id the borrower signed. The oracle refuses a request missing either.
deploy/DEPLOY.md has the procedure.
Because the decision is a signature rather than a script, it can happen at any
moment and nothing on Sequentia records it. The page therefore reads the
Bitcoin vault itself, as btc-check does, and a loan whose collateral has left
it stops offering Repay: repaying after a seizure pays the debt for collateral
that is already gone.
The borrower's only forward warning is the distance to the strike. The page shows every live cross-chain loan's strike and its health -- the current price over that strike, from the loan's own market -- and marks one whose health has gone under 1.00 as seizable now. A loan whose market has no current price shows no health at all rather than a zero, which would read as the opposite of what it means.
Passing a ticket by hand is fine for two people who already know each other. To
be found by strangers, a lender publishes an offer on a pignusd and keeps a
responder running against it:
pignus-cli btc-offer-publish --config responder.json --market BTC/USDX \
--oracle-x <x> --strike <price> --btc-amount 100000 \
--debt-asset <id> --debt 5250000000 --principal 5000000000 \
--lender-prog <hex> --lots 3 \
--recover-after <btc-height> --abort-after <btc-height> \
--repay-deadline <seq-height> --d-refund <seq-height>
pignus-cli btc-respond --config responder.json --watch
The command judges the four heights against both chains' tips and prints how
much headroom each margin has, warning under two days: the parent chain does
not keep a steady pace, and an offer whose headroom runs out is expired by the
book, with the reason on its record. deploy/DEPLOY.md gives heights that
keep one on the board for days.
The responder signs releases, pays principals once the collateral is confirmed,
starts loans as borrowers claim them, and takes back what nobody claimed. Its
configuration file carries the node credentials and the path to the lender's
key, so nothing secret is on the command line, where every user on the machine
can read it. deploy/responder.example.json is the starting point and
deploy/pignus-btc-responder.service runs it. It also keeps a state file, which
is what stops a principal being paid twice after a crash: back it up with the
key.
| flag | what it does |
|---|---|
--config |
the JSON holding the key, both nodes' credentials and the state file; deploy/responder.example.json names every key |
--watch / --interval |
keep running, and how many seconds between passes (default 5) |
--disburse-conf |
Bitcoin confirmations required on the collateral before a principal is paid (default 2, which is also the floor: the shortest depth that survives an ordinary one-block reorg, which is what every other cross-chain step here waits for) |
--claim-depth |
Sequentia confirmations required on a borrower's claim before their collateral is moved into the vault (default 6) -- and, whatever this says, the Bitcoin block that claim is anchored to must be two deep, so the effective wait is the longer of the two |
--scan-interval |
seconds between chain scans for a repayment whose borrower never said where it landed (default 300) |
--fee-asset |
what the Sequentia legs pay their fee in (default: the debt asset) |
--state |
where it records what it has already done (default: beside the key) |
The Sequentia wallet the config names must be loaded on the node -- put it in
the node's wallet= configuration so a restart reloads it -- and hold the debt
asset for every lot on offer plus an asset with a published fee rate. The
responder refuses to start when it cannot reach the wallet, and will not sign a
release the wallet could not pay.
With --sign-only the responder signs releases and does nothing else, and
needs no wallet. It still needs read-only RPC to both nodes, because it signs
nothing it cannot check the deadlines of, and the deadlines are measured from
both chains' tips.
pignus-cli btc-responder-status prints what a lender's responder has done
and what each take is waiting on and for how long, read-only and safe against a
running one; it exits 4 when something needs attention. Most reasons clear
within a block or two. A take that has waited on one reason for longer than
--waiting-hours (default 6) is reported as needing attention, because a
borrower's collateral is committed behind it. Every wait and every recorded
failure carries the time it began, and a step that succeeds clears the failure
recorded before it, so an old error is never shown as current beside a take
that has since recovered. btc-responder-clear is the one recovery a
responder cannot make for itself: telling it that a send it recorded as
in-flight never went out. btc-responder-clear --take <id>, given node
credentials, checks the chain first and refuses without them unless
--force; when the payment IS on chain, --found <txid:vout> records it
instead. It takes the responder's own lock, so it cannot run against a live
one. The same command
with --write-off "<why>" records that this key can do nothing more about a
take, so it is reported as written off rather than as needing a person; a
take with a paid principal and no claim of its repayment needs --force as
well, since no pass will claim it once it is written off.
Give btc-responder-status a --book and it also checks that the offers that
book serves under this key still verify under it. One that does not is one
whose takes the responder skips, live loans included, so a borrower's
collateral is never released and nothing else would show it. btc-offer-resign --offer <id> repairs that: the book takes a fresh signature over the terms it
already holds, and changes nothing else.
Every offer is signed by the key it names as the lender, and the relay verifies that before storing it -- otherwise anyone could publish in a lender's name and have that lender's own responder pay it out. The same goes the other way: every report a responder makes about a take is signed, and a borrower's page checks it.
A borrower takes an offer from the page's BTC tab, or from the command line:
pignus-cli btc-offer-take --offer <id> --borrower-key borrower.key \
--borrower-prog <hex> --out loan.json --btc-rpc ... --rpc ...
That is the whole borrower's half of the handshake in one command: it checks
the offer's own signature, refuses an offer whose oracle is the lender's key,
funds the pre-vault without broadcasting it, waits for the lender's hash, signs
the move into the vault that hash implies, waits for the release, verifies it
locally, and only then broadcasts. Every refusal along the way leaves the
Bitcoin untouched. --wait bounds how long it will sit waiting for a lender.
It writes the same ticket file the two-party commands use, so btc-check and
the rest work on the loan afterwards.
The relay itself is never trusted: it holds no key, moves nothing, and rebuilds
every address, outpoint and sighash from the offer's own terms rather than
believing what it is told. docs/api.md documents each endpoint.
Two asset models on Sequentia cannot use the covenant vault, and each gets a different answer rather than a pretence. The loan book carries neither. Tier C is command-line only; Tier D has a Repurchase tab on the page (its panel is headed Check a repurchase) that reads a terms document and its bond back, while everything that moves money is a command.
OpenAMP (Tier C) collateral never moves. The issuer's policy server records
a pledge against part of the borrower's balance and refuses transfers that
would spend it, so pledge-create, pledge-list, pledge-release and
pledge-seize speak to that server rather than to a node. pledge-sign is what
lets a party authorise a release or a seizure on their own machine, so a
signature travels instead of a key: pass the result as --lender-sig or, for
the borrower's countersignature on an early seizure, --holder-sig. Every one
of these prints the sentence that says the collateral is issuer-permissioned,
because presenting it quietly beside a Tier A loan would be a lie. --issuer
names the openampd (default http://127.0.0.1:8722, or PIGNUS_ISSUER).
--token is the issuer operator's bearer token, and PIGNUS_ISSUER_TOKEN is
where it belongs: those four commands are run by whoever operates the policy
server, never by the borrower or the lender. Those two run pledge-sign and
hand the issuer the signature; the policy server's public /v1/log is their
read path, and it records every pledge, release and seizure. pledge-sign --key is the key whose x-only public key the party registered with the
issuer for their account (an AID, in OpenAMP's terms); no other key verifies.
Both parties need an account holding the asset at that issuer, and the asset
must carry a clawback leaf; otherwise the issuer refuses.
The order of operations, since Pignus moves no money on this tier: the issuer
runs pledge-create; the lender sees it in /v1/log and pays the principal
by an ordinary transfer; the borrower repays by an ordinary transfer; the
lender runs pledge-sign --action release --extra <repaid txid> and the
issuer runs pledge-release --lender-sig … --repaid-txid <the same>. The
release is the lender's word alone; nothing checks the repayment. A seizure of
an asset whose issuer key is external is two phases, like a clawback: the
request, then the issuer signing the sighashes it is handed and completing
it; pledge-seize says which of the two it got, and exits 4 while the
collateral is still the issuer's to move.
OpenDAMP (Tier D) assets cannot be collateral at all, so what Pignus offers is a repurchase: the borrower sells the asset outright and holds a claim to buy it back, secured by a bond in a two-leaf covenant vault.
The LENDER funds that bond, with repo-fund, and it is the only thing
standing behind their promise to sell the asset back. repo-propose writes the
terms; repo-settle composes the buyback, which pays the bond to the lender in
the same transaction that returns the asset; and repo-forfeit pays the bond to
the BORROWER if the deadline passes with no settlement.
A holder's C_U is their address under the asset's policy, a P2TR, and the
32-byte program the commands take -- repo-propose --borrower-cu,
repo-verify --lender-cu, repo-settle --inspect --lender-cu -- is the last
32 bytes of the script opendamp derive --snapshot <policy snapshot> prints
beside C_U(<their key>).
Settlement takes the lender's OpenDAMP key. The buyback spends two
OpenDAMP Simplicity inputs -- the verifier and the lender's restricted output
-- and the lender signs both with opendamp transfer-cosign, the OpenDAMP
transfer tool's command for a transaction it did not build, against the
issuer's current policy snapshot (their openampd serves it at
GET /v1/snapshots?asset=<id>). A lender who cannot produce that signature
cannot return the asset, and if the settlement never comes, the bond is all
the holder is left with; sell only to a lender who can produce that
signature. repo-propose says so on every document it writes.
So a forfeit is not a remedy that makes the borrower whole: they keep the bond
and the lender keeps the asset, which is the arrangement they agreed and not a
restoration of it. The bond is collateral_value - debt, so it is worth what
the borrower would have gained by buying back, and nothing more. A borrower who
wants the ASSET back has one route, which is the lender settling.
Origination is not atomic. Leg one (the asset to the lender) and leg two
(the bond into the vault) are separate transactions, and between them one party
is exposed. Fund the BOND first: a bond funded against an asset that never
arrives is at least swept back by the borrower at forfeit_after, whereas an
asset transferred against a bond that never arrives has no remedy at all in the
covenant. docs/pignus-design.md §8.1 states the window in full.
repo-verify is the check that matters, and it reports a state rather than
an "ok": not-funded, leg-one-only (the lender has the asset and no bond
secures its return), bond-only (the bond is there and nobody has looked at the
half it secures), funded-unburied, live, forfeitable, or settled. A bond
alone is worth nothing, which is why the leg-one arguments are what move it past
bond-only, and why --min-confirmations decides when either half stops being
reorgable. Only live, forfeitable and settled exit 0; the rest exit 4, so
an unattended caller cannot read a half-checked repurchase as a good one.
pignus-cli repo-verify terms.json --txid <bond funding> \
--leg-txid <the transfer to the lender> --lender-cu <hex>
Settling is one atomic transaction of four inputs and at most six outputs -- exactly what OpenDAMP allows, with no spare slot in either direction -- so it is composed in two steps and signed in between:
pignus-cli repo-settle terms.json --txid <bond> --verifier <txid:vout> \
--verifier-spk <hex> --cu-lender <txid:vout> --debt-utxo <txid:vout> \
--skeleton settle.json
pignus-cli repo-settle terms.json --txid <bond> --inspect settle.json \
--lender-cu <hex> # the lender, before signing
opendamp transfer-cosign --snapshot <policy snapshot> --transaction settle.json \
--sender-privkey <the lender's OpenDAMP key> --out settle.signed.json
pignus-cli repo-settle terms.json --txid <bond> --attach settle.signed.json --broadcast
--skeleton writes a document: the transaction, with the borrower's own debt
coin already signed by the wallet that composed it, and the four outputs it
spends, which is what a signer that did not build it needs. Composed by a
wallet that does not hold that coin -- the lender's, say -- the document says
the coin is not signed, and the borrower signs its tx with
signrawtransactionwithwallet and puts the result back before anyone else
signs; the fee comes out of that coin either way, never out of the composing
wallet. --inspect is the
lender's look before signing, and it needs no node: it says what the document
pays whom, judged against the terms, and exits 4 if any output is not where
the terms say. That look is what the design rests on, because the borrower's
payment of the debt is the one output no covenant checks; the tool that signs
signs what it is handed. The lender then signs
the two OpenDAMP inputs with opendamp transfer-cosign from the
openamp repository (cargo build --release in its opendamp/ directory), which leaves every other witness as
it found it. --attach puts the covenant's RETURN witness on last, which is
the only order that works, and refuses a transaction on which any of the three
signatures is missing, naming the input and who signs it. repo-settle,
repo-forfeit and repo-verify all FIND the bond:
left without --vout, they take the unspent output of the funding
transaction that pays the address the terms compile to, so the lender's own
change at index 0 is never mistaken for a spent bond. Name --vout only to
tell a settled or forfeited bond from one that was never funded, which a scan
cannot. The borrower's debt-asset side must be a single coin and the fee
comes out of it, because there is no room for another input.
pignusd serves the loan book, the chain watcher, the cross-chain relay and the
browser client, and on the testnet it is what /lending/ is.
pignusd --config pignusd.json is the whole of running it.
deploy/pignusd.example.json is a starting configuration, and every key is
explained under pignusd configuration in deploy/DEPLOY.md, which also
covers running it and the oracle as systemd units behind Caddy, and how a
failure of any of them reaches a phone.
docs/api.md documents every endpoint it serves.
pignusd --config <file> --once refreshes once and prints the markets, the
stats and the health, without serving, which is the way to check a
configuration before it is a unit. It refuses to start at all if the covenant
builder cannot be loaded or has drifted from the golden vectors, because every
vault address it would show is derived from that builder.
pignus-oracle --config oracle.json
{
"keyfile": "/var/lib/pignus/oracle.key",
"logfile": "/var/lib/pignus/attestations.log",
"listen": "127.0.0.1:8740",
"interval": 60,
"price_scale": 100000,
"markets": ["GOLD/USDX", "SILVR/USDX", "OILX/USDX", "BTC/USDX"],
"symbols": {"BTC": "tBTC"},
"precisions": {"GOLD": 8, "SILVR": 8, "OILX": 8, "BTC": 8, "USDX": 8},
"log_max_bytes": 256000000,
"source": {"type": "http_bulk", "url": "http://127.0.0.1:8088/prices",
"field": "price", "timeout": 8, "max_age": 300}
}| key | what it is |
|---|---|
keyfile / logfile |
where the key is created, mode 0600, and where every attestation is appended |
markets |
the feeds this oracle signs, COLLATERAL/DEBT |
flat_rounds |
rounds every market may come back byte-identical before the feed is called frozen and signing stops (default 30; 0 turns it off; a static source is exempt) |
trusted_proxies |
the peers whose X-Forwarded-For this oracle believes when it keys its log rate limit, loopback by default; set it behind a reverse proxy, or the whole internet shares one bucket |
precisions |
each named asset's decimal count. Give every one an entry: a missing one is assumed to be 8, and where that is wrong the signed price is wrong by a power of ten, which no signature check downstream can catch. A config that names some and not others is refused at start |
symbols |
the ticker the feed knows an asset by, where it differs from the market's name |
price_scale |
what a price is multiplied by before signing (default 1e5; see Prices) |
interval |
seconds between signing rounds |
listen |
host:port |
log_max_bytes |
rotate the attestation log past this size (0, the default, never rotates) |
previous_keys |
x-only keys this oracle used to sign with, published at /v1/pubkey so a borrower can tell a rotation from a stranger |
compromised_keys |
keys this operator declares compromised, published at /v1/pubkey; a book refuses attestations under them and flags the loans that bake them. Each must also be in previous_keys (a book honours a declaration only for the declarer's own keys), and an instance whose own key is listed refuses to start |
max_jump, jump_rounds |
a price that moves further than max_jump (a fraction of the last signed price, default 0.5) in one step is held, unsigned, until it has stayed there for jump_rounds consecutive rounds (default 3). A feed that switched units is a perfectly good signature over a number a thousandfold off; 0 turns the guard off |
book |
the book a cross-chain seizure is checked against before it is co-signed: the take must still be open there. Optional; --allow-unlisted co-signs without asking |
seizures |
where Tier B co-signatures are logged (default: <logfile>.seizures) |
source.type |
static (fixed prices, for drills), http (one request per market) or http_bulk (one snapshot per round, which is what keeps a round's prices consistent) |
source.prices |
with static, the fixed reference price per asset named in markets ({"GOLD": 3000, "USDX": 1}) |
source.url, .field |
where the prices are, and which field of each row holds one |
source.timeout, .max_age |
seconds to wait, and how long a fetched snapshot may be reused. Every attestation is stamped with the time the price was OBSERVED -- the feed's own _meta.updated when it publishes one, else the fetch -- so a snapshot re-used after the feed stopped answering ages honestly, and nothing new observed signs nothing new |
source.insecure |
allow a plain-http feed on another machine. Refused otherwise: a path in between can rewrite it, and this oracle would sign the rewrite |
source.feed_max_age |
how old the feed's own _meta.updated may be before this oracle refuses to re-sign its numbers. Off unless you set it, and set it only against a feed that publishes that field: an oracle asked for a check it cannot perform refuses to sign at all rather than read "cannot tell" as "fresh", and a key that signs nothing is one no loan under it can ever be liquidated |
8730 is the oracle's built-in listen default and 8741 is pignusd's.
deploy/oracle.example.json listens on 8740 and deploy/pignusd.example.json
points its oracle there; whatever port the oracle listens on, the book's
oracle must name it.
--once signs one round, prints it and exits, without serving; --print-pubkey
prints the x-only key and exits. None of --once, --print-pubkey and
--sign-seize will create a key file: asking what a key is, checking a config,
or asking the key to co-sign must never answer with a new one. The serving
path creates one on its own only on a fresh install, where no attestation log
exists yet; a machine that holds this oracle's log but not its key is a lost
key, and it refuses to start until the key is restored or --create-key says
a new one is meant. Creation is announced loudly: back the file up then.
The key is created 0600 and its mode is re-checked on every start.
It is never logged and never served. Prices come from the price feed that
already drives the any-asset fee market (the node repository's
contrib/price-server) -- deliberately not a second price pipeline.
An http_bulk feed is a JSON object keyed by ticker, matched without regard
to case, each value a number or an object whose field (default price)
holds one; an optional _meta.updated in Unix seconds says when the feed
last refreshed its own numbers. Booleans, redirects and answers over a
megabyte are refused. The feed's Date header is compared with this machine's
clock: a gap over sixty seconds is reported in the journal and in /healthz
as clock_skew, because a book refuses an attestation dated in the future or
already stale. It is only reported, since the feed's clock may be the wrong
one; run NTP. --once prints X is not in URL (have: ...), which is how to
discover the feed's names for symbols. flat_rounds x interval and
source.max_age should both sit under the book's max_price_age, or the book
will call a price stale that this oracle still serves.
The signing loop prints every transition to its journal -- a market that
stops being signed and why, the feed going quiet or answering again, the
feed frozen, and how many markets were signed when that changes -- so
journalctl says what /healthz says. /healthz also carries signed and
clock_skew.
Endpoints: /v1/pubkey, /v1/markets, /v1/attestation/{market} (use _ for
the slash) and /v1/attestation/{market}/at/{ts}, /v1/log, /v1/log/raw,
/v1/digest, /v1/seizures, /v1/seizure/{sighash}, /healthz. All of them
are in docs/api.md.
/healthz answers 503, not 200, when the oracle has not completed a signing
round within two intervals, or thirty seconds where that is longer: the process
staying up while the signing thread is dead is exactly the outage that
otherwise goes unnoticed until it reaches the RECOVER backstop.
Co-signing a Tier B seizure is an operator's command rather than an endpoint,
because it moves someone's bitcoin. It refuses unless this oracle's own last
published price is under the strike, and it publishes the co-signature next to
the attestation that justified it at /v1/seizures:
pignus-oracle --config oracle.json --sign-seize --request seizure.json
seizure.json is what pignus-cli btc-seize-sighash --out writes: it carries
the loan, so the oracle rebuilds the sighash from the terms rather than signing
a number somebody else computed. --sighash, --market, --strike and
--price-scale are the hand-fed alternative. A bare sighash pins nothing, and
neither does a request without a lender-signed offer; the oracle refuses both
unless --allow-unpinned-strike says the operator has checked the terms by
hand, and nothing then holds the lender to any strike. --max-age
(600 seconds by default) is how recent the justifying price must be, and
--allow-stale co-signs against an older one.
The request also carries the lender's signed offer and the borrower's acceptance of it, which pin the strike (Native BTC collateral above says why a sighash alone cannot). The published record carries the loan and that signature too, so a borrower disputing a seizure can re-check the judgement and not only the price.
Anyone can check one afterwards, with nothing privileged:
- Read the closing transaction's witness for the price and the timestamp
(
pignus-cli explainprints both). - Fetch the exact signed bytes:
GET /v1/attestation/{market}/at/{timestamp}. pignus-cli check-attestation --attestation att.json --oracle-x <the key the VAULT bakes in> --price-scale <the loan's>, which prints the attestation's own scale and refuses when it is not the loan's.- Download the log file it is in from
/v1/log/raw, hash it, and compare with the.sha256beside it and the chain in/v1/digest. A log rewritten to add or remove an attestation stops matching a digest published before the rewrite.
The oracle can assert a price low enough to open LIQUIDATE. That is all of it.
It cannot move funds, choose a recipient, change how much is seized, trigger a
default before maturity, stop a repayment, or keep the borrower's surplus. Its
worst behaviour -- fabricating a dip -- costs a borrower the bonus and the price
difference on the seized portion, is bounded, and is permanently visible in the
published log. Section 6.1 of the design doc has the full accounting, and
section 5 has the one honest gap: nothing in tapscript can prove an attestation
is recent, only that it is newer than origination.
The platform is trusted for nothing: the book is discovery, the watcher is read-only, and the liquidator bot is just whoever noticed first.
Three collateral types are weaker on purpose and are labelled as such:
- Native BTC (design doc section 7) is cross-chain. Repayment and release are bound by one hash carried in both chains' scripts, so neither can happen without the other -- but a lender who simply declines to claim the repayment keeps the collateral, which on an over-collateralised loan is worth more. Liquidation needs the oracle to co-sign, because Bitcoin has no introspection. Section 7.2 explains why that is not a DLC, and 7.1's exposure table says exactly what each party can lose.
- OpenAMP restricted assets never enter a vault at all: the issuer's policy server records a pledge and refuses transfers that would spend it. A release needs the lender's signature; a seizure needs the lender's signature plus either maturity or the borrower's countersignature. The lender's security is the issuer's promise rather than a script, so this is not non-custodial in the sense above -- it is inherent to a transfer-restricted asset, and the CLI labels every pledge issuer-permissioned.
- OpenDAMP assets cannot be collateral at any price, for three structural
reasons the design doc sets out. What Pignus offers instead is a repurchase:
the borrower sells the asset to the lender, and the lender's obligation to
sell it back is secured by a bond in a two-leaf covenant vault (
repo-*, design doc 8.1). It is never shown as a loan, because it is not one -- the borrower has sold their asset and holds a claim.
tests/run-tests.sh runs everything below, fastest first, so a mistake surfaces
in seconds rather than after a two-chain rig has finished starting. Each file's
docstring says what it proves. A quick smoke test is tests/cli_drill.sh
followed by pignus-cli selftest: no node, a few seconds.
tests/test_btc_collateral.py and the other two-chain tests need a Bitcoin Core
bitcoind (PIGNUS_BITCOIND, default ~/bitcoin-28.0/bin/bitcoind); the
tests/*.mjs browser checks need Node; and tests/page_check.sh needs a
headless Chromium, which it SKIPS rather than fails without.
Offline, no node. This half also runs in CI on every push:
tests/cli_drill.sh every CLI command, refusals included
tests/service_drill.sh the oracle and pignusd together
tests/test_units.py the covenant vectors + an oracle round trip
tests/test_openamp.py the Tier C pledge message, pinned
tests/test_watcher.py reorgs, and reading an exit back
tests/test_watcher_reorgs.py a close undone below the restart tip, a dropped
or replaced mempool take, a mid-poll block
tests/test_oracle_service.py what the oracle will not sign
tests/test_liquidator.py what the liquidation bot refuses
tests/test_btc_relay.py the relay and the lender's responder
tests/test_btc_relay_auth.py what the relay may be believed about
tests/test_web.mjs web/pignus.js against the golden vectors
tests/test_offer_web.mjs web/offer.js against them
tests/test_repurchase_web.mjs web/repurchase.js against them
tests/test_btc_web.mjs web/btc.js against web/btc_vectors.json
tests/test_adaptor_web.mjs web/adaptor.js against adaptor_vectors.json
tests/test_btcborrow_web.mjs what the browser's BTC borrow flow refuses
tests/test_takeoffer_web.mjs what a take puts at output index 1
tests/test_format_web.mjs the amounts the page shows, exactly
tests/test_alerts_web.mjs what needs a person, per seat and per moment
tests/test_spend_depth.py a cached spend's depth, and a reorg under it
tests/test_offer_expiry.py a cross-chain offer's own end
tests/test_arith_parity.py the same arithmetic, addresses and refusals
in both languages, over a sweep
tests/page_check.sh the page, in a real browser; it skips
itself where there is no headless Chromium.
PIGNUS_SHOTS=<dir> keeps a desktop and a
phone screenshot there for a person to look at
Needing node binaries (a sequentiad, and for the BTC ones a bitcoind),
which the tests start themselves:
tests/test_btc_disburse.py paying the principal
tests/test_platform.py this library, end to end
tests/test_pset.py the browser's PSETs, accepted by a node
tests/test_flows.py the browser's flows through a whole loan
tests/test_book.py the book and the watcher against a chain
tests/test_watcher_reorg.py the watcher against a real reorg
tests/test_tiers.py Tiers C and D
tests/test_repo_opendamp.py Tier D settled against a real OpenDAMP asset; needs the
`opendamp` binary ($OPENDAMP_BIN, or a build beside this
checkout at ../openamp/opendamp/target/{release,debug}/opendamp)
tests/test_lifecycle.py the CLI through fund, take, repay,
liquidate, withdraw, default, with the
daemon discovering every step
tests/test_threshold.py a 2-of-3 oracle loan, end to end
tests/test_btc_collateral.py Tier B's covenant and crypto
tests/test_btc_cli.py the BTC-collateral library legs
tests/test_btc_cli_flow.py the BTC-collateral CLI handshake
tests/test_prevault.py origination on the Bitcoin side: the
pre-vault, the upgrade, the abort
tests/test_btc_origination.py a whole cross-chain loan, both chains,
nobody trusting anybody
tests/test_pset.mjs and tests/test_flows.mjs are the browser halves of
test_pset.py and test_flows.py and are driven by them, because both need a
node behind them. run-tests.sh fails if a file in tests/ is run by nothing
at all: a test nobody runs is a test nobody notices going red.
In the node repository, and in its test_runner.py:
test/functional/feature_pignus_vault.py the covenant's exits and refusals
test/functional/feature_pignus_oracle_set.py the on-chain oracle set
test/functional/feature_pignus_offer.py funded offers
test/functional/feature_pignus_hashlock.py the signature-free hashlock both
cross-chain legs are paid through
test/functional/feature_pignus_attack.py the attack suite
tests/test_platform.py needs the test/config.ini a built checkout's
configure writes, and is skipped without one; the rest of the chain tests
start their nodes through tests/rig.py and need only the binaries.
tests/gen_web_vectors.py regenerates the golden vectors the browser's Bitcoin
and adaptor code pins itself to. Run it only when the Python it mirrors changes,
in the same commit, and re-run test_btc_web.mjs and test_adaptor_web.mjs.