The bridge: stablecoins in, uARG out, and back
The bridge lets dollars enter Argonmine from EVM chains and leave again. A deposit of USDC or USDT into a vault contract on any of the chains the bridge serves (Ethereum, Base, BNB Chain, Arbitrum, Polygon; their testnets first) is paid out as uARG on Argonmine, one uARG per dollar less the vault's fee (1 percent). A burn of uARG on Argonmine is paid out as USDC or USDT from the vault of the chain the user names, less the same fee ("Fees" below). ARG itself never crosses: it is mined on Argonmine and stays there.
uARG is an asset on the Argonmine chain in the sense of
Specification §3.5: a 32-byte asset id derived from the
guardians' joint key and the symbol UARG. Only that key can issue it, the
chain conserves it in every transaction, and fees are always paid in ARG.
uARG has 8 decimals and is always worth one dollar; which stablecoin on
which chain backs a given unit is not tracked, only that every unit is
backed by a dollar in some vault. The design decisions behind uARG and the
later wARG are in Assets.
Trust model, stated plainly
This is a federated bridge. A fixed set of guardians, each running this daemon on its own host with its own keys, watches Argonmine and every EVM chain.
- Issuing uARG needs every guardian. The issuer key is a MuSig2 (BIP-327) aggregate of all guardian keys, so an issuance is a single Schnorr signature that no subset of guardians can produce. The chain rejects any uARG that was not issued by that key.
- Paying out a stablecoin needs a threshold of guardians (2 of 3 in the reference setup). Each vault checks the signatures, which are bound to its own chain id, address, token, recipient, amount and burn id, and refuses to pay the same burn twice.
- Nothing is automatic for an invalid deposit or burn. A deposit whose destination is not a valid Argonmine address, that is below the token's minimum, or in a token the vault does not accept, is recorded as "unclaimable" and needs a human. A burn whose memo names a chain or token the bridge does not serve is "unreleasable" and needs a human.
- One guardian coordinates, and only it. The leader asks the others for signatures; every such request is signed with the leader's own guardian key, and a follower takes requests from that key and nothing else. The shared secret is a second layer, not the authority. A coordinator that lies gets nothing signed that a follower cannot check on its own, and a compromised leader still cannot issue a deposit twice, issue one before its confirmations, or issue more than the guardians' limits ("Security model" below).
What a user trusts: that the guardians are honest and online, that the vaults hold the dollars that back every uARG, that USDC and USDT are each worth a dollar, that no EVM chain reverts a block it has called finalized (or, on a chain without that notion, reorganises deeper than its confirmation depth), that the RPC providers of a chain are not all lying together, and that Argonmine does not reorganise deeper than the payout depth of a burn. Anyone can verify the backing: every vault's balance per token is public on its chain, the bridge status sums them in uARG units, and the explorer shows the uARG issued on Argonmine.
What a user does not trust: a web page, a database or a payout queue. The bridge page is a convenience that builds the two EVM transactions; the deposit is between the user and the vault contract, and the issuance is between the guardians and the chain.
Protocol
Deposit (EVM to Argonmine)
- The user approves the vault to take
amountof a token and callsdeposit(token, amount, "arg1…")on the vault of that chain. The vault checks that the token is allowlisted and the amount at least the minimum, takes the tokens, keeps its deposit fee and emitsDeposited(id, token, from, amount, fee, to), whereamountis the net credited to the user andfeewhat the vault kept. Deposit ids start at 1 in every vault, so a deposit is identified by (chain id, deposit id). - Every guardian scans every vault's events. A deposit becomes pending with the uARG units its net amount pays, converted from the token's decimals (see "Amounts" below); the fee is recorded with it, and so are its block, the block's hash and the log's index. Deposits above the chain's final block are recorded as soon as they are seen, so a wallet can follow them, but nothing is ever done with them before their block is final ("Finality and providers" below).
- Once a deposit's block is final the leader builds one
issuance transaction paying final pending deposits from every
chain, oldest first, within the issuance limits and the per-deposit
caps: custody ARG coins for
the fee (at most 20), one uARG output per deposit, at most one ARG
change output back to custody, a NULLDATA output with the deposit
references (
BRI1, below), and a single issuance of exactly the total. Each guardian checks it on its own before it takes part in the MuSig2 signing ("What a guardian checks before it signs" below) and records what it signed before its partial signature leaves it. - The leader broadcasts the transaction. When it is mined, every guardian marks the deposits it names confirmed, from the deposit references in the transaction itself (never by address and amount), so no deposit can be paid twice even if the leader changes or a guardian's state is rebuilt.
Deposit addresses (no wallet needed)
A deposit through deposit needs a browser wallet that approves the vault
and calls it. A deposit address removes that step: every Argonmine
address has its own EVM address on each chain that has a
DepositRouter (contracts/DepositRouter.sol), and a plain USDC or USDT
transfer to it, from any wallet or exchange, becomes a vault deposit
credited to that Argonmine address.
- The page asks the leader for the address (
GET /api/deposit-address). The leader computes it off-chain as the CREATE2 address of the router'sForwarder:keccak256(0xff ‖ router ‖ keccak256(bytes(to)) ‖ forwarderCodeHash)[12:], wheretois the Argonmine address in its canonical lowercase form (the salt is its exact bytes) andforwarderCodeHash()is read once per chain from the router. Before it hands out any address on a chain it checks, once, that the router'svault()is that chain's vault and that the router's owndepositAddress(to)view gives the same address; if either check fails, deposit addresses stay off on that chain (the reason is in/api/statusasrouter_error). It then remembers the address as a watch. - The user sends USDC or USDT to the address, on that chain only. The
leader looks for
Transferevents of the chain's tokens into its watched addresses (eth_getLogsper token, 100 addresses per query, 2000 blocks per range, from the router's deployment block), every scan round. - Once a transfer has the chain's
confirmations, the leader reads the address's balance. At or above the token'smin_depositit callsrouter.sweep(token, to): the router deploys the forwarder on its first sweep, pulls the whole balance and callsvault.deposit(token, amount, to). Below the minimum nothing happens: the funds wait at the address, and the next transfer that lifts the sum over the minimum is swept with it. A sweep the vault refuses because of its own minimum (AmountTooSmall) is treated the same way, not as an error. - The vault takes its fee and emits
Depositedwith the router asfrom; from here it is an ordinary deposit, issued as uARG by every guardian's checks and the MuSig2 issuance like any other.
There is never more than one sweep in flight per address and token: a sweep
that is sent is followed to its receipt before another is considered. A
sweep that fails to go out (the node is down, no gas) is retried with a
backoff of 30 seconds doubling to 10 minutes; one that reverts on chain, or
is not mined within an hour, is marked failed and the address is read again
after the backoff. Sweeping is open to everyone and harmless: it can only
move what sits at the address derived from to into the vault credited to
that same to, so a third party sweeping first changes nothing.
Who pays. The leader's gas key pays the gas of every sweep, as it
does for releases ("Release transactions") (about 270,000 gas for an address's first sweep, which
deploys its forwarder, and about 100,000 after that). The user pays only
the vault's deposit fee. On a chain where gas is expensive, set the
tokens' min_deposit so that a minimum deposit's fee is worth the gas. A
gas key without gas shows the problem in the chain's error, and the sweep
waits with the reason in its error.
What is lost if sent wrong. Only the chain's allowlisted tokens can ever leave a deposit address. Another token, the chain's native coin, or the right token sent on another chain (where that address belongs to no one) cannot be recovered. The page says so next to the address.
Followers do none of this: they neither watch nor sweep and answer the
deposit-address endpoints with 503. Watches live in the leader's state
(w/ records) with what
arrived and the sweeps made, so a restart loses nothing and never sends a
second sweep for one that was in flight. A watch that never received
anything is dropped after 30 days without a request for it; one that did
stays, since its owner may send to the same address again without visiting
the page. The leader watches at most 100,000 addresses; past that,
/api/deposit-address answers 503 with that reason. Registrations are
limited per client to a burst of 20 and one every 3 seconds after that.
Redemption (Argonmine to EVM)
-
The user sends a version-2 transaction that spends uARG coins and pays less uARG forward than it spends (the difference is burned), with a data output carrying a memo that names the payout:
Memo Bytes Meaning BRG1‖ recipient (20)24 pay the bridge's default token: the first chain's first token in its configuration BRG2‖ chain id (u64, big-endian) ‖ token (20) ‖ recipient (20)52 pay that token on that chain The desktop app builds it from the Wallet page ("Redeem uARG for USDC": chain, token, amount and payout address), and the command-line wallet with
argonmine-wallet redeem, with the same chain and token choice. Both show the burn's transaction id, which is the redemption id on the bridge page. The page's Redeem tab shows the memo for a chosen chain, token and address (GET /api/memo) for burns built by hand. -
Every guardian recognises the burn and records a redemption: the chain, the token, the recipient, and the burned amount converted to that token's units. A memo naming a chain or token that is not configured makes the redemption unreleasable with the reason.
-
After the burn's payout depth, which grows with its amount ("Payout depth" below), the leader checks that the vault can pay it now (not paused, within its per-release and 24-hour limits, enough of the token beyond the releases already in flight) and asks the other guardians for release signatures. Each guardian fetches the burn from its own node, decodes the memo against its own configuration, recomputes the amount and recipient, checks the depth its own tiers ask for that amount, that releases are not paused on it, and that the burn is at or above the height the chain's current vault pays from ("Vault epochs" below), and signs
releaseDigest(token, to, amount, burnId)as that chain's vault defines it: the EIP-191 digest ofkeccak256(abi.encode(chainid, vault, token, to, amount, burnId)), whereburnIdis the burn's transaction id. A guardian signs a burn for one release only: it records the digest before the signature leaves it, returns the same signature if asked again for the same release, and refuses any other release of that burn. -
With a threshold of signatures the leader calls
release(token, to, amount, burnId, sigs)on that chain's vault with the gross amount of the burn, from its gas key ("Release transactions" below): the vault lets anyone callreleasewith valid guardian signatures, so the sender is a dedicated low-balance key, never a guardian key or the vault's owner. The vault keeps its release fee, paysamount − fee, records the burn id and emitsReleased(burnId, token, to, amount, fee). The redemption is confirmed when the vault'sreleased(burnId)says it was paid; the transaction that did it is found among those sent for it by its receipt, and the fee and the net paid are taken from its event.
Swap addresses (no memo needed)
A swap address is an Argonmine address that names a payout: uARG sent to it from any wallet, in an ordinary transfer with no memo, is paid out as the named token on the named chain to the named EVM address. It is what the apps' Swap screen and the bridge page's Withdraw tab use, and what an exchange or another wallet can use without knowing anything about the bridge.
Its 32-byte P2PK payload is not anyone's key; it spells the destination:
| Bytes | Content |
|---|---|
| 0–2 | 83 ba 1x: every swap address reads <hrp>1qswap…; the low 4 bits of byte 2 are the layout version, 0 |
| 3 | the smallest value that makes the payload a valid x coordinate, so wallets that check keys accept it, and each payout has exactly one address |
| 4–7 | EVM chain id, big-endian |
| 8–11 | token symbol, ASCII upper case, zero-padded (USDC, USDT) |
| 12–31 | the EVM recipient |
Nobody has the private key of such a point: finding one is the discrete logarithm problem, and a key made at random matches the 248 fixed bits with no real chance. What is sent to a swap address can therefore never move again: uARG sent there is burned as surely as by a burn transaction, and ARG or any other asset sent there is lost. The desktop app, the command-line wallet and the browser app refuse to send anything but the bridge's own uARG (by asset id) to one, and refuse a swap address naming a chain or token the bridge does not serve.
Every guardian's index treats each output of the bridge's uARG to a swap
address as a redemption of that output's amount, with the id txid:vout
(one transaction can pay several). The payout is read from the address
alone, so there is nothing to register and no server can redirect it; the
rest is a redemption like any other (steps 2 to 4 below, the payout depth,
the vault epochs and the signing memory), except that its burnId in the
vault is H("Argonmine/bridge/swap-payment", txid ‖ vout as 4 bytes big-endian) (BIP-340 tagged hash), which no transaction id can equal. A
swap address whose chain or token the bridge does not serve makes the
redemption unreleasable with the reason, like a bad memo. The token is
named by symbol, not by contract, so an address stays valid when a testnet
token is redeployed; a chain that lists two tokens with one symbol cannot
be named by a swap address.
GET /api/swap-address returns the address for a chain, token and
recipient; anyone can compute the same one (bridge.SwapAddress in Go, or
argonmine-wallet swap-address <chain-id> <token> <0x-address>). The
explorer labels swap addresses with the payout they name.
Amounts
uARG units are dollars with 8 decimals. For a token with D decimals:
- Deposit:
units = amount × 10^8 / 10^D. ForD > 8(an 18-decimal USDT on BNB Chain) the division rounds down and the remainder, less than one uARG unit, stays in the vault. ForD < 8it is an exact multiply. - Payout:
token amount = units × 10^D / 10^8. ForD < 8(USDC and most USDT, 6 decimals) the division rounds down: a 6-decimal token cannot pay fractions of a cent, so the dust below10^(8−D)units is burned and not paid. A burn that rounds to zero is unreleasable ("burn too small to pay out"). ForD ≥ 8the payout is exact.
All of this is integer arithmetic on big integers; nothing is ever a float. Token amounts are carried as decimal strings in the state and the API, because 18-decimal amounts exceed 64 bits.
Fees
The bridge charges 1 percent on deposits and 1 percent on redemptions. Both fees are taken by the vault contract, in the token, and stay in the vault as its surplus; the daemon never moves a fee and could not.
- Where it is taken. On a deposit the vault takes the whole
amountthe user approved, keepsfee = amount × depositFeeBps / 10000(rounded down to a token unit) and creditsamount − fee; the guardians issue exactly that net, converted to uARG. On a redemption the guardians sign and callreleasewith the gross value of the burn (units × 10^D / 10^8, rounded down); the vault keepsfee = gross × releaseFeeBps / 10000(rounded down) and transfersgross − fee. A fee on a tiny amount rounds to zero, so the user never pays more than the stated share, and a burn that was releasable at zero fee is releasable at any fee. The deposit minimum (min_deposit, and the vault's own) is measured on what the user deposited, before the fee. - Backing is never touched. Every vault keeps
obligations(token), the token units that back issued uARG: a deposit adds its net, a release subtracts the gross of the burn. The fee of every deposit and every release lands in the differencesurplus(token) = balance − obligations(token). Only the surplus can ever be withdrawn, only by the vault's owner, and only up to that amount (withdrawSurplus(token, to, amount)reverts above it); the obligations leave the vault throughreleaseand nothing else. So uARG in circulation stays fully backed whatever is withdrawn, andreserve ≥ obligationsholds on every chain. - The rates live in the contract.
depositFeeBps()andreleaseFeeBps()are set by the owner (setFees, at most 10 percent each) and are read by the daemon from every vault on every scan, with the reserves; the chains file carries no fee, and a vault that charges nothing simply reports 0. The guardians only read the fees: they never set, collect or forward them. - What the status shows. Per chain,
deposit_fee_bpsandrelease_fee_bps; per token,obligationsandsurplusin token units besidereserve; at the top,backing_unitsandsurplus_units, the dollar sums of the obligations and of the surplus over every chain and token (reserve_unitsis their sum, up to rounding of 18-decimal tokens). A deposit carriestoken_fee, the units the vault kept, besidetoken_amount, the net it credited. A redemption carriestoken_amount(the gross, whatreleaseis called with and what the guardians sign),token_feeandtoken_paid(token_amount − token_fee), computed from the chain's release fee when the burn is classified and recomputed from the latest read of it when the release is sent, then replaced by theReleasedevent's figures once the receipt is in.
Reorganisations
Every scanner keeps a cursor. On Argonmine every block the daemon indexes
is recorded with its hash, and every transaction that changes the state (a
custody coin received or spent, a deposit settled, a burn recorded) is
applied in one atomic write together with an undo record: the previous
value of every key it wrote. Each round the daemon asks the node for the
hash at its cursor. Only a hash that differs from the recorded one counts
as a reorganisation; an error from the node stops the round and nothing is
undone. On a real reorganisation it walks back to the last block both
agree on and undoes exactly what the blocks above it did, newest first: a
deposit settled there is pending again (issued, on the leader), a custody
coin received there is gone, one spent there is back, a burn recorded
there is forgotten unless its release was already sent. Then it indexes
the new branch, where the issuance is usually mined again and settles the
same deposits again. Undo records are kept for 1000 blocks; a deeper
reorganisation stops the daemon with an error. A block indexed only in
part (the daemon stopped in its middle) is undone and indexed again.
Before it spends a custody coin, the leader asks its node (gettxout)
whether the coin is still there and drops it if not. A reorganisation
that undoes more than -l1-reorg-pause blocks (default 3) pauses every
issuance and release on that guardian until its operator resumes them
("Pauses" below): a burn's payout depth is chosen on the assumption that
Argonmine does not reorganise that deep, so the guardians stop and a human
looks.
On the EVM chains the protection is finality, below.
Finality and providers
A deposit is acted on only once its block is final. Each chain has a
finality mode:
finalized(the default on Ethereum, Sepolia, Holesky, Hoodi, Base, Base Sepolia, OP, Arbitrum One and Sepolia, BNB Chain and its testnet, Polygon and Amoy): the final block is the lower of the block the node calls finalized (eth_getBlockByNumber("finalized")) andlatest − confirmations. On Ethereum that is about 13 minutes; on Arbitrum and Base it follows the L1 and takes 15 to 30 minutes; on BNB Chain fast finality gives a few blocks, so the confirmations decide.depth(every other chain, a local anvil included):latest − confirmations.
The scan's cursor never passes the final block. Every block it stops at
(each scanned range's last block) is recorded with its hash, and so is
every deposit's block hash and log index. Each round the hash at the
cursor is read again. When it changed, the chain reorganised below a block
the guardian took for final (a provider fault, a reorganisation deeper than
confirmations on a depth chain, or a broken finality): the scan goes
back to the newest recorded block that is still in the chain (or to
start_block if none is) and logs an ALERT, and every deposit above that
point is looked up again by its transaction hash:
- the transaction is in another block with the same
Depositedevent (same id, token, depositor, amount, fee and destination): the record is re-located there and the deposit goes on as before, no human needed; - the transaction is gone, failed, or no longer holds that event, and the
deposit was never issued (nor signed by this guardian): it is marked
reorgedand is never issued. If its id is later taken by another deposit on the new branch, that deposit replaces the record and the old one is kept aside (z/in the state); - the deposit was already issued (or this guardian signed an issuance for
it): an alert. Issuance on that chain is paused on the guardian
("Pauses") until its operator has looked and resumes it; the deposit
keeps its status with the alert in its
error.
The same look-up runs for any recorded deposit whose block the final scan reaches without finding its event there, which also covers the deposits recorded above the final block (they are previewed from the first provider each round so wallets can follow them) when they do not survive.
Right before it signs an issuance, every guardian, the leader for its own
share included, reads each deposit's receipt again: status 1, mined in
exactly the block on record (same hash, same log index), at or below the
final block read from every provider just now, and holding a Deposited
log of the vault's address with exactly the recorded id, token, depositor,
amount, fee and destination. A deposit is also issued only once the
guardian's own final scan has passed its block.
Several RPC providers. A chain can list "rpcs" beside "rpc". The
heads, the block hashes, the deposit logs of every final range and the
receipts are read from every provider, and a deposit is acted on only
when they all agree on its block hash and its log. When they disagree, the
chain is paused on that guardian (nothing on it is issued or released, and
chains[].paused in the status says why, with an ALERT in the log) until
they agree again. The first provider is the one transactions are sent
through. Use providers run by different companies.
Security model
Who may coordinate
Only the leader ever asks the other guardians for anything:
POST /guardian/nonces and POST /guardian/sign (the two MuSig2 rounds
of an issuance) and POST /guardian/sign-release. Every request carries
four headers:
| Header | Content |
|---|---|
X-Guardian-Time |
Unix seconds when the request was made |
X-Guardian-Nonce |
16 random bytes, hex |
X-Guardian-Auth |
BIP-340 Schnorr signature by the leader's L1 guardian key over TaggedHash("ArgonmineBridge/guardian-request/v1", P) |
X-Guardian-Signature |
HMAC-SHA256 with the shared secret over "ArgonmineBridge/guardian-request/v1" ‖ P |
where P = varbytes(network) ‖ varbytes(custody key) ‖ varbytes(path) ‖ time (i64) ‖ varbytes(nonce) ‖ SHA-256(body) in the consensus encoding.
A follower is configured with the leader's public key (-leader-pubkey,
one of -guardians; it does not start without it) and refuses, with 401,
a request signed by any other key, without the secret, more than 60
seconds away from its own clock, or whose nonce it has accepted before
(the nonces of accepted requests are remembered until their time is too
old anyway; only a request already proven to come from the leader is
remembered, so nobody else can fill that memory). The leader takes no
guardian requests at all (403), so its public address never serves
/guardian/*. The secret must have at least 32 characters; the daemon
reads it from ARGONMINE_BRIDGE_SECRET first, then -secret-file, and
only then -secret, which shows in the process list. A follower keeps at
most 4 signing sessions in flight, each for at most 2 minutes, an issuance
spends at most 20 coins and pays at most 50 deposits, and a request body
is at most 1 MB.
What a guardian checks before it signs an issuance
Every guardian, the leader for its own share included, checks the transaction against its own node, its own EVM clients and its own state:
- every input is a custody ARG coin its node has (
gettxout): unspent, mature, of the amount the leader claims; at most 20 of them; - every uARG output pays, in order, a deposit it saw itself, still pending, to that deposit's address and for its amount;
- each deposit's block is final on every one of the guardian's providers read just now, and passed by its own final scan; its receipt says the transaction succeeded in exactly that block with exactly the recorded event of the vault ("Finality and providers");
- issuance is not paused on the guardian, for everything or for that chain, and its providers agree ("Pauses");
- each deposit's token answered
decimals()with the configured value ("Token decimals"), and its gross is within the token'smax_deposit; - the only other outputs are at most one ARG change output to custody and exactly one NULLDATA output, equal to the deposit references of the deposits paid;
- the single issuance is the bridge's asset, under the custody key, of exactly the sum of the outputs, and the fee is bounded;
- the issuance is within the limits: at most
-max-issueuARG, and with what this guardian signed in the last 24 hours at most-max-issue-day; - the signing memory allows it (next section).
It checks again when the second round comes, and records the issuance (the coins it spends, the deposits it pays, its amount and time) on disk, flushed, before it computes and returns its partial signature.
The signing memory
A guardian remembers, per deposit, every issuance transaction it partially
signed, with the custody coins it spends. It signs another issuance for
that deposit only if, for each of those earlier ones, the new transaction
is the same transaction, or spends at least one of the same coins (so the
two conflict and at most one can ever be mined), or the earlier one can
never be mined any more because one of its coins was spent on Argonmine by
another transaction at least l1_confirmations deep. Whatever the
coordinator does, every issuance that could still confirm for a deposit
conflicts with every other, so a deposit is issued at most once. The
leader builds its transactions to satisfy this: for a deposit it signed
before, it spends one of that issuance's coins that is still unspent, and
the deposit waits while there is none; and it does not spend such coins
for other deposits. An issuance broadcast by the leader that is neither
mined nor in its node's mempool after 30 minutes returns its deposits to
pending, to be issued again under the same rule. The memory survives
every format change; deleting the state directory deletes it.
Deposit references
Every issuance carries, in one NULLDATA output,
"BRI1" ‖ (chain id varint ‖ deposit id varint) per deposit, in output order
with the minimal LEB128 varints of the consensus encoding, at most 80
bytes (consensus.MaxNullDataLen); the leader ends a batch when the next
reference would not fit (15 Sepolia deposits, about 19 on chains with
smaller ids). A guardian settles deposits from these references, in a
transaction that creates uARG (which only the custody key can do), and
from nothing else: a reference to a deposit it has not seen yet is kept
and applied when it sees the deposit. An issuance mined before references
existed is settled only by the leader, by its own record of the
transaction id.
Limits
-max-issue (default 10000 uARG) caps one issuance and -max-issue-day
(default 100000 uARG) what one guardian signs over any 24 hours; 0
disables a limit. Every guardian enforces its own values, so set the same
on all of them. A deposit above -max-issue waits with the reason in its
error and is counted in held in the status; one that would pass the
daily limit waits until the window frees up. Raising a limit needs every
guardian.
Per-deposit cap
A token may have a max_deposit (token units, measured on the gross the
user deposited, like min_deposit). A deposit above it is recorded as
pending with the reason in its error and counted in held; it is
never issued while it is above the cap of the guardian asked, and every
follower refuses it. Its funds are in the vault (they count in
obligations, since the vault took the deposit). Two ways out:
- Issue it: raise
max_depositon every guardian (a new chains file and a restart); it is issued in the next batch. - Refund it: the guardians sign by hand a release of the deposit's net
to the depositor's address (
fromin/api/deposit/<chain>/<id>) withburnId = keccak256("refund" ‖ chain id ‖ deposit id)and send it from any key (the vault pays once per burn id, and the release lowers the obligations by the amount, so the backing stays exact). Then, on every guardian,argonmine-bridge mark-unclaimable <chain id>#<id> refundedmarks it unclaimable so it can never be issued afterwards, whatever the cap becomes ("Operator commands").
Token decimals
At start every guardian reads decimals() of every configured token (on
the first scan of each chain, and again on every start: the chains file is
read only at start, so a change of the file always goes with a restart).
A token whose decimals() differs from the file, or that has none, is
refused: its deposits are recorded with the reason and never issued,
its burns are not paid, followers refuse both, and the status shows the
reason in chains[].tokens[].error with an ALERT in the log. Until the
call has succeeded once (an RPC that is down at start) the token is not
acted on either. When the file is fixed and the guardians restarted,
pending deposits recorded with the old decimals are recomputed from their
token amount on chain.
Payout depth
How many Argonmine blocks a burn waits before it is paid scales with its
amount: -l1-confirmation-tiers (default 1000:6,10000:30,100000:120)
is a list of amount:blocks, the amount in uARG (dollars), increasing.
A burn of at most 1000 uARG waits 6 blocks, up to 10000 waits 30, up to
100000 waits 120; -l1-confirmations is the floor of every tier (a burn
never waits fewer). A burn above the last tier is held, with the
reason in its error, for a manual release: once the operators have
checked it, argonmine-bridge approve-release <burn txid> on every
guardian that signs lets it be paid at the last tier's depth. Every
guardian applies its own tiers before it signs, so set the same values on
all of them; a follower asked too early answers "burn has n of m
confirmations". The depth a burn needs is in its confirmations field and
the tiers in the status.
Release transactions
The leader sends every release, and every sweep of a deposit address, from
its gas key (-gas-key): an EVM key of its own with a little native gas
on every chain, distinct from every guardian's signing key and from the
vault's owner. The daemon refuses to start a client that would send from
the guardian's own key, and on each chain it checks once that the gas key
is neither one of the vault's guardians nor its owner (isGuardian,
owner()); if it is, nothing is sent there and the chain's error says
why. The gas key's balance on every chain is in chains[].gas_balance
(wei), with gas_low and a warning in the log once an hour below the
chain's min_gas_balance (default 0.01 of the native coin, 1 POL on
Polygon). Losing the gas key loses that gas and nothing else: it signs no
release, it only carries the guardians' signatures to the vault.
Each release is tracked by its nonce, its fees and every hash broadcast for
it (send in the redemption). It is an EIP-1559 transaction (tip from
eth_maxPriorityFeePerGas, fee cap twice the base fee plus the tip) except
on BNB Chain, which gets a legacy transaction at the network's gas price
plus 10 percent. Each round the leader:
- reads the vault's
released(burnId): when true, the burn is paid, by whichever of the transactions sent for it (found by its receipt), and the redemption is confirmed. This is also what makes double payment impossible: the vault pays a burn id once, so a replacement and its original, or two transactions with different nonces, cannot both pay; - goes back to pending when one of them reverted;
- when the nonce was taken by a mined transaction that is none of the release's (it was replaced, or dropped and the nonce reused) and the burn is not paid, sends the same call again with a new nonce;
- when none is mined after
-release-timeout(default 5 minutes), sends it again with the same nonce at higher fees: tip and fee cap at least 12.5 percent up (legacy: gas price at least 10 percent up), and at least what the network asks now; it is also how a transaction dropped from the mempool is rebroadcast; - never pays more than the chain's
max_gas_price_gwei(defaults: 300 on Ethereum and Sepolia, 30 on BNB Chain, 20 on Base, OP and Arbitrum, 3000 on Polygon, 500 elsewhere): when the next raise would pass it, the transaction stays as it is and the redemption shows "not mined at the chain's gas price cap" until the network is cheaper again.
Before sending, a release is simulated on the latest block (eth_call):
one the vault would refuse because the burn is already paid confirms the
redemption; one refused with LimitExceeded or IsPaused waits.
The vault's limits and pause
Before it asks for signatures the leader reads, for the token, the vault's
paused(), maxRelease(token) and periodAvailable(token) (what the
24-hour window still allows), and its balance less the payouts of the
releases already in flight. A burn the vault cannot pay now waits with
the reason in its error and is tried again every round, never failed:
above the window's room it is paid when the window has room again; above
maxRelease it waits for the owner to raise the limit (through the
timelock); while the vault is paused it waits for the pause to be lifted;
short of the token it waits for liquidity. A LimitExceeded at the moment
of sending is handled the same way. The status shows, per chain and token,
available_now: the most one redemption in that token can be paid right
now (gross token units: the balance less the releases in flight, within
maxRelease and the window; "0" while the vault is paused), with
max_release, period_available and the chain's vault_paused, so the
apps can warn before a user burns more than can be paid at once. A vault
deployed before these views existed reads as unlimited and not paused.
Pauses
A pause stops a guardian from acting until its operator resumes it. It
is kept in the guardian's state (h/), survives restarts, is listed in
/api/status under paused and logged at start:
| Scope | Set when | Stops |
|---|---|---|
l1 |
Argonmine reorganised deeper than -l1-reorg-pause blocks |
every issuance and every release on that guardian; a follower refuses to sign either |
evm/<chain id> |
a deposit already issued (or signed) on that chain left it in a reorganisation, or its id now belongs to another transaction | issuance of that chain's deposits; a follower refuses to sign them |
A chain whose RPC providers disagree is not acted on either, but that
clears itself once they agree again and is not a stored pause; it shows in
chains[].paused. Every guardian pauses on its own, from its own view; the
leader cannot get signatures from a paused follower, so resuming means
resuming every guardian that paused.
Stuck items
Anything waiting longer than -stuck-after (default 1 hour) is listed in
/api/status under stuck (oldest first, at most 100) with a reason
stripped of URLs and secrets, and logged at warn level once an hour each:
a pending deposit (deposit: waiting for finality and the next batch, or
held with a reason), an issuance broadcast and not mined (issuance), a
pending burn (redemption: its depth, or why the vault cannot pay it) and
a release transaction not mined (release, with its nonce and fee
raises). An issuance that is neither mined nor in the leader's node's
mempool after -issue-rebroadcast (default 10 minutes) is broadcast again
(the same signed transaction, at most once per that interval); after 30
minutes its deposits are issued anew under the signing memory, as before.
Operator commands
An operator acts on a guardian with the same binary, on the same host:
argonmine-bridge -network <net> -datadir <dir> -key <guardian key file> -listen <the daemon's -listen> resume [all|l1|evm/<chain id>]
argonmine-bridge ... approve-release <burn txid>
argonmine-bridge ... mark-unclaimable <chain id>#<deposit id> [reason]
The flags come before the command. The command is signed with the
guardian's own L1 key (Schnorr over TaggedHash("ArgonmineBridge/admin-request/v1", network ‖ key ‖ path ‖ time ‖ nonce ‖ SHA-256(body))) and sent to POST /admin/<action> of the daemon on this host; the daemon takes it only from
a loopback address, not through a proxy (no X-Forwarded-For,
Forwarded, X-Real-Ip), within 60 seconds of its clock, once per nonce,
and signed by its own key, and answers with what it did and the pauses
left. When no daemon answers there (it is stopped), the command opens the
state directory (<datadir>/<network>/bridge-state) and applies itself
there. On Railway: railway ssh -s bridge-g2, then argonmine-bridge -network testnet -datadir /data -key /data/guardian.json -listen 127.0.0.1:8395 resume.
Runbook: a pause.
- Read
/api/status(paused,chains[].paused,stuck) and the guardian's log (lines withALERT). l1: compare the Argonmine chain of each guardian's node with the explorer and the seed nodes. A reorganisation deeper than expected may be an attack on the payout depth: before resuming, check every redemptionreleasedorconfirmedsince the fork height against the chain as it is now (/api/redemptions; a burn that left the chain and was paid is a loss to report). Raise the tiers if blocks are being reorganised that deep.evm/<id>: the alert names the deposit and its issuance. The deposit's dollars are not in the vault on the chain as it is now: the uARG is unbacked by that amount. Decide (with the other guardians) how to cover it, then resume. Deposits that only moved need nothing: they were re-located automatically.- Providers disagreeing: find out which one is wrong (compare
eth_getBlockByNumberof the height in the message on each); fix or replace it in the chains file. It clears by itself once they agree. - Resume every guardian that paused (each with its own key):
argonmine-bridge ... resume(orresume l1,resume evm/11155111).
Vault epochs
A burn names a chain, not a vault. Each chain in the configuration has an
l1_start_height, the first Argonmine height whose burns its current
vault pays; burns below it are never paid on that chain ("unreleasable",
needs a human). Each guardian also records the vault it last saw for
every chain: when the configuration names a different vault, it starts a
new epoch at its current Argonmine height plus one and pays no older burn
from the new vault, whatever l1_start_height says. The effective start
is the larger of the two. With the set of burns each guardian signed
(above), replacing a vault cannot pay an old burn again, even with the
step below forgotten, as long as the guardians keep their state; with a
rebuilt state, l1_start_height is what protects it.
Configuration
The daemon reads one JSON file, -chains <file>:
{"chains":[
{"name":"Ethereum Sepolia","chain_id":11155111,
"rpc":"https://ethereum-sepolia-rpc.publicnode.com",
"explorer":"https://sepolia.etherscan.io","native_symbol":"ETH",
"rpcs":["https://sepolia.example-provider.io/v2/KEY"],
"public_rpc":"https://ethereum-sepolia-rpc.publicnode.com",
"vault":"0x…","start_block":11997800,"confirmations":12,"finality":"finalized",
"max_gas_price_gwei":300,"min_gas_balance":"0.01",
"router":"0x…","router_block":12002681,"l1_start_height":0,
"tokens":[{"symbol":"USDC","address":"0x…","decimals":6,"mint":true,"max_deposit":"50000000000"},
{"symbol":"USDT","address":"0x…","decimals":6,"mint":true,"min_deposit":"2000000"}]}
]}
| Field | Meaning |
|---|---|
name |
shown on the page and in the API |
chain_id |
the EVM chain id; the daemon refuses an RPC that reports another |
rpc |
JSON-RPC URL the daemon uses first; transactions are sent through it. May carry an API key: it is never shown |
rpcs |
optional: more providers. The heads, block hashes, deposit logs and receipts are read from all of them and a deposit is acted on only when they agree ("Finality and providers"). rpc may be left out when rpcs is given (the first is then the sending one); at most 4 in all |
public_rpc |
optional: the URL the page hands to browser wallets (wallet_addEthereumChain) and the only RPC /api/status shows. No default: without it the status shows no rpc for the chain (it never falls back to rpc, which may carry an API key) |
finality |
optional: finalized or depth (defaults by chain id, see "Finality and providers") |
max_gas_price_gwei |
optional: the most a release transaction pays per gas, fee raises included (number, decimals allowed; defaults by chain id, see "Release transactions") |
min_gas_balance |
optional: the gas key balance, in the native coin ("0.05"), below which the status sets gas_low (default 0.01; 1 on Polygon) |
explorer, native_symbol |
for the page and for adding the chain to a wallet (default symbol ETH) |
vault |
the Vault contract on that chain |
start_block |
the vault's deployment block; public RPCs do not serve older logs |
confirmations |
a deposit's block must be at least this many blocks below the head to be final (and below the finalized block with finality: finalized); default 20 |
tokens[].symbol, address, decimals |
the allowlisted stablecoins; decimals 0 to 18 |
tokens[].mint |
the token is a test mock with a public mint(address,uint256): the page offers a "Get 100 test USDC" faucet. Never on mainnet |
tokens[].min_deposit |
smallest deposit paid out, in token units, measured before the vault's fee; default one token (10^decimals). The vault enforces its own minimum too. A deposit address is swept only at or above it |
tokens[].max_deposit |
optional: largest deposit issued without a human, token units, measured before the fee; a larger one is held ("Per-deposit cap"). Default none |
router |
optional: the chain's DepositRouter; turns on deposit addresses for the chain. Without it the chain has none |
router_block |
the router's deployment block, where the leader starts looking for transfers into deposit addresses (default start_block) |
l1_start_height |
the first Argonmine height whose burns this chain's vault pays (default 0); burns naming the chain below it are never paid. Set it to the current Argonmine height plus one before replacing a vault ("Replacing a vault") |
Validation: at least one chain with one token, unique chain ids, unique
token addresses per chain, valid addresses, a known finality, a positive
gas cap, max_deposit at or above min_deposit, unknown fields rejected. The
fees are not in the file: the daemon reads them from each vault. The
first chain's first token is what a BRG1 memo pays out, so order the
file accordingly. On Railway the file is the CHAINS_JSON variable, built
from per-chain env files by deploy/railway/chains.sh
(deploy/railway/README.md, "Bridge").
Components
| Piece | What it is |
|---|---|
contracts/Vault.sol |
The EVM side, one per chain: holds the allowlisted stablecoins, takes the deposit fee and emits Deposited, pays release less the release fee with threshold guardian signatures bound to the chain, vault and token, remembers paid burn ids, tracks obligations per token so the fee surplus is separable from the backing. Owner can rotate guardians, allow and disallow tokens, pause, cap releases, set per-token minimums and withdraw the surplus. |
contracts/DepositRouter.sol |
One per chain, bound to its vault, no owner: depositAddress(to), the CREATE2 address of a Forwarder per Argonmine address, and sweep(token, to), which moves what a forwarder holds into the vault as a deposit credited to to. |
contracts/MockUSDC.sol, MockUSDT.sol, MockUSD18.sol |
Six- and eighteen-decimal ERC-20s anyone can mint, for local chains and public testnets, never mainnet. With "mint": true the page offers their mint as a faucet. |
bridge/chains.go |
The chains file, its validation, and the unit conversions. |
bridge/custody.go |
The guardians' MuSig2 aggregate key: the uARG issuer and the holder of the ARG fee float. |
bridge/evm.go |
JSON-RPC client (one per chain), ABI encoding of release and the fee and obligation views, Deposited and Released decoding, EIP-191 digests, EIP-155 signing. No external EVM library. |
bridge/state.go |
LevelDB bookkeeping: deposits by (chain, id) with their fee and in-memory indexes by address and transaction, redemptions with their fee and net payout, custody coins, issuances, cursors, the signing memory, signed burns, vault epochs, the format migrations, the redemption and deposit-reference memo codecs. |
bridge/service.go |
The daemon: configuration, the rounds, deposit classification (caps, refused tokens), the status. |
bridge/evmscan.go |
Scanning the EVM chains: finality, the providers' agreement, checkpoints and reorganisations (re-location, reorged, alerts), the receipt check before signing, decimals, the vault's fees, reserves, limits and the gas key's balance and roles. |
bridge/release.go |
Paying out burns: payout tiers, the vault's limits and pause, release signing (once per burn), threshold release, following and resending release transactions. |
bridge/releasetx.go |
Release transactions from the gas key: EIP-1559 signing, fee raises under the cap, the reads for finality, receipts, limits, decimals and gas. |
bridge/ops.go |
Pauses, stuck items, the operator commands (/admin/*, signed with the guardian's own key, from this host only) and their offline form. |
bridge/l1index.go |
Indexing Argonmine: per-block undo records, reorganisations, settlement by deposit references, burns, vault epochs. |
bridge/issue.go |
Issuance: batching under the limits, coin selection under the signing memory, every guardian's checks, the MuSig2 rounds and the follower's signing sessions. |
bridge/auth.go |
The guardian-to-guardian requests: the leader's signature, the HMAC, the clock and replay checks. |
bridge/router.go |
The router's ABI: the deposit-address formula, EIP-55, sweep and depositAddress encoding, Transfer and Swept decoding, the revert reasons of a refused sweep, and the JSON-RPC calls for them. |
bridge/depositaddr.go |
Deposit addresses: the one-time router check, watches in the state, the leader's transfer scan and sweeps, the two endpoints, the per-client rate limit and /qr.svg. |
bridge/web.go |
The bridge page (chain and token pickers, deposit with a browser wallet, deposit address without one, redeem memo, tracking, reserves per chain), the public JSON API and the guardian endpoints. |
cmd/argonmine-bridge |
The binary: keygen, gaskey, address, the operator commands (resume, approve-release, mark-unclaimable), and run. |
Operations
Each guardian, once:
argonmine-bridge keygen guardian1.json # prints the L1 public key and the EVM address
and the leader, once more, its gas key:
argonmine-bridge gaskey gas.json # prints the address to fund on every chain
Deploy a vault on every chain with the guardians' EVM addresses, the
threshold and the timelock delay, allow the tokens, set the fees and the
release limits, finalize the setup and hand the vault to its owner (a
multisig of independent parties before real funds); on the testnets
deploy/railway/bridge-chain.sh deploy <chain> does all of it
(deploy/railway/README.md, "Contracts per
chain"). Write the chains file. Then on every guardian host:
ARGONMINE_BRIDGE_SECRET=<shared, 32+ characters> \
argonmine-bridge -network mainnet -key guardian2.json \
-guardians <k1,k2,k3> -threshold 2 \
-chains chains.json -leader-pubkey <k1> \
-max-issue 10000 -max-issue-day 100000 \
-l1-confirmation-tiers 1000:6,10000:30,100000:120 \
-listen 127.0.0.1:8395
Exactly one guardian runs with -leader -gas-key gas.json -peers https://g2…,https://g3… (and no -leader-pubkey); every other one names
the leader's key with -leader-pubkey and signs for nothing else. The followers expose only
/guardian/* to the leader (firewall or mTLS) and their own node RPC to
themselves.
Gas, per chain. The leader's gas key sends every release
transaction, and every sweep of a deposit address on chains with a router,
so it needs a little native gas on every configured chain
(ETH on Ethereum, Base and Arbitrum; BNB on BNB Chain; POL on Polygon);
keep it small and top it up when gas_low says so. A
release that a node rejects for lack of gas is retried every round and the
reason appears in the chain's error in /api/status ("no gas: the gas
key 0x… needs BNB on BNB Chain Testnet to send release transactions"). The
guardian keys and the followers send nothing and need no gas. The custody address (argonmine-bridge address) needs a small ARG balance for issuance fees; a few ARG lasts
years.
One chain down does not stop the others. Every round scans every chain; a chain whose RPC fails is reported in its status entry and retried next round, while deposits on the other chains are issued and burns to them released. Burns naming the failed chain wait.
Flags that matter: -chains (the file above), -leader-pubkey (on a
follower: the leader's L1 public key, required), -gas-key (on the
leader, required: the file from gaskey, or any JSON with evm_private,
or a hex key), -max-issue and -max-issue-day (issuance limits in uARG,
defaults 10000 and 100000, 0 = none), -secret-file (or
ARGONMINE_BRIDGE_SECRET; -secret only as a last resort),
-l1-confirmations (the floor of every payout depth, default 30),
-l1-confirmation-tiers (default 1000:6,10000:30,100000:120; empty: the
floor for every burn), -l1-reorg-pause (default 3, 0 = never),
-release-timeout (default 5 min), -issue-rebroadcast (default 10 min),
-stuck-after (default 1 h), -issue-interval (how often the leader
batches, default 2 min), -scan-interval (default 10 s), -asset-symbol
(UARG).
Replacing a vault
- Read the current Argonmine height (
l1_heightin/api/status) and set the chain'sl1_start_heightto that height plus one, next to the newvaultandstart_block. - Give every guardian the new chains file and restart them, followers first.
Burns below l1_start_height are never paid from the new vault; any
still unpaid in the old one need a human and show as unreleasable with the
reason. If step 1 is forgotten, a guardian that keeps its state still
refuses: it starts a new epoch when it sees the vault change, and it never
signs a burn for a second release.
State
The daemon keeps its bookkeeping in <datadir>/<network>/bridge-state
(LevelDB). The directory carries a format version (2 since the
multi-chain bridge, 3 since the fees, 4 since deposit references, the
per-block undo records, the signing memory, the signed burns and the vault
epochs). Nothing that records money moved or promised is ever wiped:
a format-3 directory is migrated in place (deposits, redemptions,
issuances, watches and cursors stay; only the custody coin set and the
Argonmine cursor go, and the next round rescans Argonmine from genesis to
rebuild them), and an older or unmarked one is moved under a/<format>/
and kept, with the signing memory, the signed burns and the epochs left
active; a redemption found there that was paid or sent stays so. A
directory of a newer format is refused. The second security pass
added only fields to the records (a deposit's block_hash and
log_index, a redemption's confirmations and send, an issuance's
signed hex) and new key prefixes (h/ pauses, v/ EVM block hashes,
z/ replaced deposits, y/ release approvals), so it changed no format:
a format-4 directory is used as it is. An EVM cursor from before kept no
block hash; the first round records the hash at the cursor. An empty directory is scanned
from Argonmine's genesis and every EVM chain's start_block; this takes a
few minutes and is logged. A rebuild is safe against double issuance: the
rounds scan the EVM chains before Argonmine, and a deposit reference seen
before its deposit is on record is kept and applied when the deposit is
seen. It is not safe against paying an old burn from a replaced vault:
that is what l1_start_height is for. A deposit's record carries its
chain id, chain name, token address and symbol, decimals, token amount
and fee (decimal strings) and uARG units; a redemption's record carries
the same for its payout, with the gross, the fee and the net paid.
The public testnet bridge runs on Railway: three guardian services from
the one image in deploy/railway/, with the mock tokens as the
stablecoins. The services, their variables, the per-chain contract
deployment (deploy/railway/bridge-chain.sh), the chains document
(deploy/railway/chains.sh) and the go-live steps are in
deploy/railway/README.md, section "Bridge".
Public API
Every /api/* response carries Access-Control-Allow-Origin: *, so the
desktop app and the website can read it from another origin. The status
and the unfiltered lists are computed at most once every 3 seconds and
served from memory; the filtered lists come from indexes kept in memory,
at most 100 values per filter. No error reaches the API with a URL path,
a query string or anything that looks like a credential: URLs are cut to
their scheme and host.
| Endpoint | Returns |
|---|---|
GET /api/status |
network, asset, asset_symbol, custody_address, custody_arg, guardians, threshold, l1_confirmations, l1_confirmation_tiers (up_to in uARG units and confirmations), l1_reorg_pause, l1_height, chains, reserve_units, backing_units, surplus_units, issued, redeemed, deposits, redemptions, pending, held (pending deposits that wait with a reason in their error: a limit, a cap, a refused token), reorged (deposits whose event left their chain before they were issued), issue_limit and issue_day_limit (uARG units, 0 = none), issued_day (what this guardian signed in the last 24 hours), paused (scope, reason, since: see "Pauses"), stuck (kind, id, chain_id, since, reason: see "Stuck items"), gas_address (the leader's gas key), leader, last_error |
GET /api/deposits |
the 200 most recent deposits, newest first, with status: pending, issued, confirmed, unclaimable, reorged. Add ?to=addr1,addr2 to keep only deposits paid to those addresses (a wallet following its incoming transfers; progress while pending is chains[].head - block + 1 of chains[].confirmations) |
GET /api/redemptions |
the 200 most recent redemptions (burns and swap address payments), newest first, with status: pending, released, confirmed, unreleasable. Add ?txids=a,b to keep only those ids (txid, or txid:vout for a swap payment) (a wallet following its payouts; progress while pending is l1_height - height + 1 of the redemption's confirmations) |
GET /api/redemption/{id} |
One redemption by its id (a burn's txid, or txid:vout for a payment to a swap address, which also carries vout and address), or 404 |
GET /api/swap-address?chain=<id>&token=<symbol>&to=0x… |
the swap address that pays uARG out as that token on that chain to that address: address, chain_id, chain, token (symbol), token_address, decimals, to (EIP-55), release_fee_bps. Computed, never stored. 400 for a chain or token the bridge does not serve, a malformed or zero recipient, or one of the bridge's own contracts |
GET /api/deposit/{chain_id}/{id} |
one deposit; GET /api/deposit/{0x tx hash} finds it by its deposit transaction on any chain |
POST /api/handoff |
Body {"amount","chain_id","token","to"} (or {"kind":"send","amount","to"}): a redemption prepared on a device without keys (the browser app). Returns a code of ten characters from 23456789ABCDEFGHJKLMNPQRSTUVWXYZ (no 0/O or 1/I) in two groups, K7M2Q-X9RT4, valid 15 minutes, with the resolved chain and token. Nothing secret: amount, route and payout address only. Per client: a burst of 10 and one every 20 seconds (429), at most 10 pending (429); 5000 pending in all (503) |
GET /api/handoff/{code} |
The prepared redemption, for the desktop app to fill its Redeem form from, once: the code is gone after the first successful read. Any case, with or without the separator. 404 when unknown, used or expired; per client a burst of 20 and one every 2 seconds (429) |
GET /api/memo?to=0x…&chain=<id>&token=0x… |
the BRG2 memo (hex) for a payout of that token on that chain to that address, with the chain and token echoed; without chain and token, the BRG1 memo for the default |
GET /api/deposit-address?chain=<id>&to=<address> |
the deposit address of that Argonmine address on that chain, which the leader starts watching (asking again only refreshes the watch): chain_id, chain, to (lowercased), address (EIP-55), router, explorer (the address on the chain's explorer, or empty), confirmations, deposit_fee_bps, tokens (symbol, address, decimals, min_deposit), note. 400 for an address of another network, an unknown chain or a chain without a router; 503 on a follower, when the router check failed or the chain cannot be reached, or at the watch limit; 429 over the rate limit |
GET /api/deposit-address/status?chain=<id>&to=<address> |
its progress: address, watched, confirmations, head, balances per token (symbol, token, decimals, amount at the address in token units: the last balance read plus the transfers seen since, min_deposit, last_tx), sweeps newest first (token_symbol, token, decimals, amount gross, tx, status: pending (not on chain yet; error says why it is retried), sent, confirmed or failed, error, attempts, deposit_id from the Swept event, created, updated), and deposits: the vault deposits made through the router for that address, as in /api/deposits, newest first |
GET /qr.svg?d=<text> |
a QR code (SVG) of a deposit address (0x and 40 hex digits) or of an Argonmine address, for the page |
chains is a list, in configuration order:
{"name":"Ethereum Sepolia","chain_id":11155111,"vault":"0x…","rpc":"https://…","explorer":"https://sepolia.etherscan.io",
"native_symbol":"ETH","confirmations":12,"head":7123456,"deposit_fee_bps":100,"release_fee_bps":100,"error":"",
"router":"0x…","deposit_addresses":3,"finality":"finalized","final":7123390,"providers":2,
"vault_paused":false,"gas_balance":"48000000000000000",
"tokens":[{"symbol":"USDC","address":"0x…","decimals":6,"mint":true,"min_deposit":"1000000",
"reserve":"1234567890","reserve_units":123456789000,"obligations":"1222222222","surplus":"12345668",
"max_deposit":"50000000000","available_now":"1234567890","max_release":"10000000000","period_available":"99000000000"}]}
rpc is the chain's public_rpc, absent when none is configured (the
page then has no RPC to hand to a wallet for that chain). head is the
chain's head at the last scan and final the last final block this
guardian scanned (deposits at or below it can be issued), with finality
and the number of providers. paused (absent when empty) says why
deposits on the chain are not issued now. vault_paused is the vault's
own pause; gas_balance (wei) and gas_low are the leader's gas key on
that chain. Per token, available_now, max_release and
period_available are described in "The vault's limits and pause",
max_deposit in "Per-deposit cap", and error (absent when empty) says
why the token is refused. error is the chain's last scan error, or the
leader's gas problem on that chain (for releases or sweeps, or a gas key
that is a guardian or the owner), or empty. router is the chain's
DepositRouter when deposit addresses are on there, else empty; when the
router failed its check, router_error says why. deposit_addresses is
how many deposit addresses the leader watches on the chain (0 on a
follower). deposit_fee_bps and release_fee_bps are the vault's
fees in basis points (100 is 1 percent). reserve is the vault's balance
of the token in token units (a decimal string), reserve_units the same
in uARG units; obligations is the part of the reserve that backs issued
uARG and surplus the rest, the fees collected (both token units, decimal
strings). The top-level reserve_units, backing_units and
surplus_units are the sums over every chain and token in uARG units:
the dollars in the vaults, the dollars backing uARG, the dollars earned.
All of it is read during each scan, so the status never waits on an RPC.
A deposit: chain_id, chain, id, evm_tx, from, token,
token_symbol, decimals, token_amount (token units credited after the
fee, decimal string), token_fee (token units the vault kept, decimal
string), amount (uARG units, from token_amount), to, block,
block_hash, log_index, status, issue_tx, error, attempts,
created, updated. A
redemption: txid, height, amount (uARG units burned), chain_id,
chain, token, token_symbol, decimals, token_amount (the gross in
token units, what release is called with), token_fee (what the vault
keeps), token_paid (token_amount − token_fee, what the recipient
gets), to, status, evm_tx (the latest release transaction sent, or the one
that paid), confirmations (the Argonmine depth it needs), send (the
release transaction as sent: from, nonce, type, gas_price or
fee_cap and tip_cap in wei, gas, data, hashes (every one
broadcast, oldest first), sent, last, bumps), error, attempts,
created, updated; a burn with a memo the bridge cannot route has chain_id 0,
an empty token and empty fee and paid fields.
Verified run (2026-10-26)
go test ./bridge/ runs a regtest node, three guardians with real MuSig2
and ECDSA keys, and three in-memory chains with vaults that check
signatures, take the fees and keep the obligations like the contract:
chain A holding USDC and USDT with 6 decimals and charging nothing, chain
B holding an 18-decimal USDT and chain C a 6-decimal USDC, both at 1
percent each way. It verifies: nine deposits (good ones in four tokens,
one below the minimum, one of exactly the minimum whose net is below it,
one to an invalid address, one in a token the vault does not accept, one
with more precision than uARG carries) are classified and converted
correctly, with the net credited and the fee recorded (100 USDC on C
becomes 99 uARG with a fee of 1; 3.5 of the 18-decimal USDT becomes
3.465); deposit ids are per chain; nothing is issued before each chain's
confirmation depth and the chains are issued in separate batches when
they confirm at different times; one issuance pays several tokens across
chains with the right asset id and net amounts; followers settle the
deposits from the chain; the status reports every reserve, obligation and
surplus per chain and token, the fee rates, and the dollar sums; a BRG1
burn is released as USDC on chain A at no fee, a BRG2 burn as the
18-decimal USDT on chain B (5 gross, 4.95 paid, 0.05 kept), a BRG2 burn
of 50 uARG as USDC on chain C (release called with 50, 49.5 paid, 0.5
kept, read back from the Released event), and a BRG2 burn naming an
unknown chain is unreleasable and refused by followers; releases are
idempotent; a chain whose RPC fails is reported in its status while a new
deposit on another chain is still issued, and the error clears when it
recovers; and the API shapes above. TestDecodeDepositLog,
TestDecodeReleasedLog and TestFeeOf pin the event layouts and the fee
rounding; TestParseChains, TestUnitConversion and
TestStateFormatRebuild cover the configuration rules (with
l1_start_height), the conversions and the state migrations (format 3
migrated in place, older formats archived with the signing memory and the
signed burns left active, a newer format refused).
bridge/security_test.go (2026-10-27) runs the proofs of concept of the
security review against the hardened daemon, each on its own regtest node
with three guardians, and each now fails to exploit.
TestPoCDuplicateIssuanceWithSecretOnly: with the shared secret alone
(the old HMAC over the body, or the new preimage signed by a key of the
attacker's) every follower answers 401 and the leader 403; with the
leader's own key a follower refuses a deposit without its confirmations,
both refuse a second issuance of a deposit already signed that spends
other custody coins and accept one that spends a coin of the first; the
user ends with the deposit's uARG exactly once.
TestPoCRewindSwallowsNextDeposit: a cursor hash that disagrees with the
node undoes nothing (the guardian's block record agrees), an unreachable
node is an error and not a reorganisation, and the next deposit with the
same address and amount is issued. TestPoCEVMReorgedDepositStillIssued:
a deposit whose event left its block waits with the reason, is counted in
held, and a follower asked directly refuses it.
TestPoCRedeployRepaysOldBurn: a vault replaced with every guardian's
state rebuilt and l1_start_height set pays neither of two old burns
(one already paid, one still pending) and the followers refuse both; the
same with the state kept and l1_start_height forgotten: the new epoch
refuses the pending burn.
TestGuardianRequestAuth refuses a replayed request, one signed by
another guardian's key, one 120 seconds old or ahead, one with the wrong
secret, one replayed on another path or with another body, one without
headers, and any request to the leader; a follower without the leader's
key, with a stranger's or its own as the leader's, or with a short secret
does not start. TestBurnSignedOnce: a follower asked twice for the same
release returns the same signature; after the vault is replaced and every
state is rebuilt from format 3 with the epochs and the redemption itself
deleted, the rescan finds the burn again and nobody signs it for the new
vault. TestSettlementAcrossReorg makes the regtest node really
reorganise (a two-block branch built on a second chain from the block
before the issuance): the issuance's memo names its deposit, every
guardian settled it from the memo, and after the reorganisation every
guardian has the deposit pending again (issued on the leader), the
custody coinbase and the change of the orphaned block gone and the coin
the issuance spent back; mined again, the issuance settles the deposit
again, and a new deposit of the same address and amount is issued.
TestIssuanceLimits: a deposit above -max-issue and one past
-max-issue-day wait with their reasons while the others are issued in
separate batches, the status reports held, the limits and issued_day,
and the followers refuse both when asked directly. TestIssueMemo pins
the BRI1 bytes, the 80-byte limit (15 Sepolia references fit, 16 do
not) and what decoding rejects. TestHandoffCodes: ten-character codes in
two groups, read once in any spelling, reads and creations rate-limited
per client, at most 10 pending per client. TestPublicAPIStatusCacheAndErrors:
the status is served from its cache until it expires, filtered lists come
from the indexes, and no path, query string or credential of an RPC URL
reaches any endpoint. TestIssuanceFeePolicy keeps the issuance fee in
step with the relay policy for asset outputs. The whole package runs in
about three and a half minutes.
bridge/hardening_test.go and bridge/anvil_test.go (2026-10-27, second
pass) cover finality, reorganisations, providers, payout depth, pauses,
caps, decimals, release transactions and the gas key.
TestEVMFinality: with the finalized tag 50 blocks behind, a deposit 10
blocks deep is recorded (with its block hash) and not issued, a follower
asked directly refuses it as not final, and it is issued once finalized.
TestEVMReorgRelocatesAndReorgs: below the final block one deposit's
transaction leaves the chain and another's moves three blocks; on every
guardian the first is reorged and the second re-located, then issued
from its new block. TestEVMReorgAfterIssueAlerts: an issued deposit
leaves the chain: every guardian raises the alert and pauses issuance on
the chain, a new deposit waits and a follower refuses it, and both are
issued after every operator resumes. TestProviderDisagreement: a deposit
only one of two providers reports, then a block they see differently,
pause the chain (status and follower refusal) until they agree; then it
is issued. TestPayoutDepthTiers: with tiers 50:3,500:6 a 396 uARG burn
waits 6 blocks on the leader and on a follower, and a 990 uARG burn is
held and refused until approved on every guardian. TestL1ReorgPause: a
three-block Argonmine reorganisation with -l1-reorg-pause 2 pauses every
guardian, a release waits and a follower refuses to sign, a resume signed
by a stranger, by another guardian, sent through a proxy or replayed is
refused, and after every operator resumes the burn is paid.
TestTokenDecimalsMismatch: USDT answering 8 decimals against 6 in the
file is refused (deposit held, follower refusal, status error) while USDC
works; with the file fixed and the guardians restarted the deposit is
recomputed and issued. TestPerDepositCap: a deposit above max_deposit
is held and refused, a smaller one issued, and the held one marked
unclaimable on every guardian. TestReleaseRebroadcast: a release not
mined after 5 minutes is sent again with the same nonce and fees 12.5
percent up; the first transaction lands late, the burn is paid once and
confirmed from it, and nothing more is sent; a release whose nonce went to
another transaction is sent with the next nonce; one whose raise would
pass max_gas_price_gwei waits and is listed as stuck.
TestReleaseLimitsAndPause: a burn over the vault's 24-hour room waits
(status available_now), a LimitExceeded at sending waits, a paused
vault holds payouts (vault_paused, available_now "0"). TestGasKey:
releases go from the gas key only, its balance and gas_low show, a
client sending from the guardian's own key is refused at start and a gas
key that is a vault guardian sends nothing. TestPublicStatusRPC: neither
rpc nor rpcs (with API keys) reach the status; public_rpc does, and
a chain without it has no rpc field. TestIssuanceRebroadcast: an
issuance missing from the node's mempool is broadcast again after 10
minutes and issued anew after 30. TestAdminOffline and
TestHardeningConfig pin the offline operator commands, the new
configuration rules, the tiers, the fee raises and the release call.
TestAnvilHardenedVault starts its own anvil (skipped without Foundry),
deploys MockUSDC and the hardened Vault.sol from the forge artifacts
(three guardians, a one-day delay, fees, a 300 USDC window, setup
finalized) and runs three guardians over JSON-RPC with finality: finalized: a deposit final on one branch is reorged after
evm_revert to another, and the next deposit takes over its id; four
deposits are issued; a release is sent from the gas key (no guardian key
ever sends) and pays 98.01 USDC; with automine off a release not mined in
time is replaced by one with the same nonce and fees at least 12.5 percent
up, the original is put back and mined, and the burn is confirmed from it
with one payment; a 198 USDC burn waits for the window and is paid after
a day; a burn on a paused vault waits until the pause is lifted through
the timelock.
TestDepositAddresses runs the same regtest node and three guardians
against a chain with a router and one without: the address is the CREATE2
formula over the router, keccak256(to) and the code hash, served in
EIP-55, the same for any spelling of to and registered once; a foreign
address, an unknown chain, a chain without a router and a follower are
refused; a new watch reads its balances once; 0.4 USDC is seen at once,
not touched before its confirmations, and then waits below the minimum
without being read again; a top-up to 1.2 is swept once, and while that
sweep is held unmined a further 0.5 arrives and the daemon is restarted,
yet no second sweep is sent; mined, it took all 1.7, the router's deposit
is issued and confirmed as 1.683 uARG and the followers agree; a USDT
balance below the vault's own minimum waits without error and goes with
the next transfer; a leader without gas shows it in the chain's error and
sweeps once it has gas; unused watches expire after 30 days and used ones
stay; the watch limit and the per-client rate limit answer 503 and 429;
and the chain without a router is never asked anything of a router.
TestDepositAddressRouterChecks turns the feature off for a router bound
to another vault or whose view disagrees with the formula.
TestDepositAddressFormula pins three addresses read from the deployed
routers on anvil and Sepolia with cast, and TestRouterAnvil (with
BRIDGE_ANVIL_RPC, BRIDGE_ANVIL_ROUTER, BRIDGE_ANVIL_VAULT and
optionally BRIDGE_ANVIL_TOKEN set) reads a live router through the
JSON-RPC client.
Against the local anvil (2026-10-26) a leader built from this tree, on its
own port and data directory, registered an address, saw 0.4 USDC wait
below the minimum, swept 1.2 USDC after a top-up (266,241 gas, the
forwarder's deployment included), read deposit #3 from the Swept event,
scanned the router's Deposited as pending, and kept the watch and the
sweep across a restart; the address matched cast call depositAddress
exactly.
forge test in contracts/ runs the Vault tests under solc 0.8.26.
The page has been checked headless in Edge at 390 px and 1440 px with a fake status of three chains (one of them unreachable): no horizontal overflow, selectors, faucets, memo and tracking working. The deposit address flow was checked the same way at 360, 390 and 1440 px against the anvil leader (entry button, wallet-modal button, form, address with QR, below the minimum, forwarding, forwarded) and with mocked answers for the issued and delayed states: no horizontal overflow in any of them.
What this is not
Not trustless. Not audited. Not a market: uARG is redeemable one to one for a dollar stablecoin, less the bridge fee, nothing else. Volatile deposits (ETH, BNB, BTC) are not accepted by the vaults; they come later as wrapped assets or by conversion to a stablecoin at entry, as decided in Assets. The bridge does not price USDC against USDT: both are a dollar to it, and a user who deposits one may redeem the other.