______
_| | port0.sh |
 ̄| | ̄ ̄ ̄ ̄ x父父x
| | ∩ 爻爻爻
___| |_,|_|__ ⌒VV⌒
¨¨¨| |¨゙丁|゚~n xXXx, } :{
____| |_,|_|_,,|:|__ 爻爻爻 } :{
¨¨¨¨¨| |¨゙丁|¨Π¨ ⌒VV⌒ } :{
┌ー‐| |─:| |‐:|:|‐━=/゙| ̄`'i========━ー─‐-- }::[ } :{ _____
___|__,| |_,|_|_,,|:|_ | :| | | }::[ } :{ .|\ _,ニ-
¨¨¨¨|¨¨¨| |¨゙丁|¨Π¨¨ :| :| | | ̄ ̄ ̄|_,|三| x父父x | }::[ } :{ _」 ``''"´,、,、,vivi
|¨¨¨¨| |¨¨:| |¨:|:|¨¨¨¨|/`丶、|丁 ̄ ̄ ̄ |三| '⌒}:[⌒ | }::{ _,.、} :{ ' ´ ┌;';';';';';';';';';';';';'
丕丕 | | | :| |_,,|:|"´ ̄| ̄ ̄¨¨¨ " i ___,,,|..、 --‐‐}:{…¬冖}::{' "´ } :{ |xX爻爻爻爻
|| | ̄ ̄ ̄ ̄| | ̄:| | |:|-‐…|¬ … ─--|:|__xXXx__}:{___}::{.____ } :{ xX爻淡淡淡淡淡
i¨¨¨¨¨¨¨||¨¨¨¨¨¨¨¨¨¨¨¨|二二二二.| |二:| |¨:|:|.:.:.:.:. | ::::::::::::::::: | ̄|______,}:{__}:{匚匚匚}::{匚匚]=} :{xX爻淡淡淡淡淡淡
ー┴ー──:||─────‐〈\.|¨¨¨¨¨¨| |¨¨:| |¨:|:|.:.:.:.:. | ::::::::::::::::: |_|「「「「「「「}:{vviVi}:{从从从}::{从v_从} :{爻淡淡淡淡淡㌻¨
 ̄| ̄| ̄| ̄|| ̄| ̄xi爻爻爻|\\ | ̄| | ̄:| |,_,|:|.:.:.:.:. | :::::::::::::::::::: |x爻爻爻}:{爻爻}:{爻爻爻}::{乂,| ̄;;} :{爻淡淡淡淡㌻
TTTTTTTTTTTTTTTTTTTT|∨ ̄ニ=- _|_|_| ̄|ニT冖冖¬ー┼YYYYY}:{爻淡}:{厂厂√}::{>'| } :{‐‐┴┐ ̄ ̄>''´
────────────‐|.:.:.  ̄ ̄|───‐ 「| ̄ ̄ ̄´""''ー=:}:{_i> ´};;{_,|_,,ノ}_,{ニ=- ┴>''´__
゚。.:.:. .ノ|¨| ´"''ー=ニ,,__.:.:.:.:.:.:.:>''´r─┤__
\j_,,.. -‐… "´ 」 ̄ |  ̄´´"" '' ー|__,|
| ¨二|  ̄¨¨'冖
| ./ /|. . . .
/ / :| : : : : : : : : . . . . . . . . .
| ̄| |: : : : : : : : : : : : : : :: : : : : : : :: : : : : : . . . . . ..
//| | | . . . . . . . : : : : : : : : : : . . . . .
port0.sh
port0.sh is an open-source exchange built on Miden. A constant-product automated market maker holds a pair of fungible assets in a pool, prices them by the product of the two reserves, and lets any account trade, deposit, or withdraw against the pool. Any token can dock.
The name comes from three places. A program that binds to port 0 asks the operating system for any free port. A free port is a harbor where any ship can dock. A free port charges no customs duties: goods land and leave without a tax. The third meaning is the product. The registry accepts a code commitment and lists the pool. There is no customs house.
Components
| Component | Repository | Role |
|---|---|---|
| Contracts | port0_contracts | Pool account component, registry account component, and note scripts |
| SDK | port0_sdk | TypeScript client for quotes, note building, pool reads, and pool operations |
| Indexer | port0_indexer | Reads the registry and pools over node RPC and serves an HTTP API |
| App | port0_app | Static web application to browse pools, trade, provide liquidity, and create pools |
Status
port0.sh runs on Miden testnet 0.17.1. The code is unaudited and no audit report exists. Pools and notes are public network accounts: pool state and XYK notes are visible on chain. Nothing here is financial advice.
Reading order
A reader new to the system starts with the constant-product AMM and accounts and notes. A builder reading the interfaces continues with contracts and the SDK. An operator deploying the stack reads the runbook. The repositories chapter lists the canonical source and every mirror.
The constant-product AMM
A port0 pool holds two fungible assets, token0 and token1, and enforces the
invariant x * y = k between their reserves. The invariant is maintained on
every operation, so a trade moves the price along the curve rather than
consuming the reserve at a fixed price.
Reserves and price
The reserve of each token is the pool account’s vault balance of that token minus the accrued protocol pot. The spot price of one token0 in token1 is the ratio of the two reserves. The pool does not store the reserves: they are read from the vault at each operation.
Liquidity
A liquidity provider deposits both tokens and receives LP tokens. The LP token
is a native fungible asset issued by the pool account itself, with no separate
faucet account. An amount a of LP tokens represents the fraction a / lp_supply
of the reserves.
- On the first deposit the pool mints
sqrt(x * y)LP tokens and retainsMINIMUM_LIQUIDITY = 1000permanently. The provider receives the remainder. - On later deposits the pool mints the smaller of the two token-contribution ratios. The consumed amounts round up in the pool’s favor, and the unused remainder returns to the provider.
- A withdrawal burns LP tokens and returns the proportional share of both
reserves. A burn that would leave less than
MINIMUM_LIQUIDITYreverts.
Swaps
A swap sends one token to the pool and receives the other. The output is
computed from the full input with the LP fee weight, so the effective input is
amount_in * (10000 - lp_fee_bps) / 10000. The reserve grows by the input minus
the protocol share. The protocol share is carved out of the LP fee, so the
swapper pays the LP fee tier and nothing more.
The min_amount_out field on a swap note bounds slippage. The pool reverts the
note when the output would be smaller than the bound.
The price oracle
The pool maintains two cumulative price accumulators and the timestamp of the last update. They update at the start of each mint, burn, and swap:
p0 = (reserve1 << 64) / reserve0
p1 = (reserve0 << 64) / reserve1
Each accumulator adds p * elapsed, where elapsed is the block timestamp
delta. The accumulators saturate at u128::MAX and never wrap, so a consumer
subtracts two readings to obtain the time-weighted price over the interval. A
backward timestamp yields an elapsed of zero, and a pool with a zero reserve
returns before updating the accumulators.
Quoting without moving assets
quote_swap runs the swap computation against the current reserves without
modifying state. It returns the amount out, the resulting reserves, and the
protocol fee, and it is callable by foreign procedure invocation. The SDK ports
the same arithmetic to TypeScript, so an application can quote before it builds
a transaction.
Accounts and notes
Miden state is held in accounts and moved by notes. Every port0 operation is a note that targets an account.
Accounts
Three account kinds appear in the system:
| Kind | Role |
|---|---|
| Wallet | Holds assets and signs transactions |
| Faucet | Issues a fungible asset |
| Contract | Runs account-component code against its own storage and vault |
A public account stores its full state on chain. A private account stores only a commitment on chain and keeps the state with its holder. A pool is a public account: its reserves, LP supply, fee rates, and oracle readings are readable by anyone.
An account id is two field elements, prefix and suffix, and renders as 0x-prefixed hex or as a bech32 address.
Network accounts
A network account installs the network-account auth component. The component carries a note-script allowlist, the fee policy, and the nonce handling, and the node’s transaction builder reads the allowlist to consume notes on the account’s behalf. No signing key is involved. Removing the component turns the account into an ordinary account the node does not service.
Every transaction executed by a network account must have an effect: an input note, an output note, or an account state change. An empty scriptless transaction is rejected. A new network account is therefore committed by consuming a funding note as its first transaction, which also pays the first transaction fee from the received funds.
Both the pool and the registry are network accounts.
Notes
A note carries a script, storage, and assets. The script runs in note context and calls an account procedure through the component interface. Note scripts may not create output notes or move assets directly; account procedures may.
| Visibility | Behavior |
|---|---|
| Public | Published on chain and discoverable by the target through chain sync |
| Private | Encrypted and delivered to the target through note transport |
A port0 feature note is a public network
note: it carries a
NetworkAccountTarget
attachment bound to the target account, so the node’s transaction builder can
route it. Each feature note travels with a separate FEE_SPONSORSHIP note that
pays the target’s configured note consumption fee.
Note fees
Notes carry a fee. A fee asset is required to consume a note, and the executing account pays it from its own vault. A pool’s fee faucet must equal the chain’s fee faucet, or the node never consumes the pool’s notes. On the Miden testnet the chain fee asset is USDCX. It comes from the network faucet; it is not minted by this project.
The registry prices two note kinds in its network auth: the register note at the configured register fee, and the registry-collect note at zero, because collection is permissionless and unsponsored. The registry pays the registry-collect transaction fee from its own vault.
Output notes and foreign calls
An account procedure can create output notes, which is how a pool returns LP tokens, refunds, swap output, and collected fees. Output notes use the P2ID script by default, with the recipient and the pool’s stored P2ID root.
A pool reads its protocol fee destination from the registry by foreign procedure invocation. The registry reads a pool’s code commitment and identity the same way at registration. The registry holds the protocol fee destination once for every pool, and it is read only on the collect path.
Glossary
The Miden documentation is the authority for Miden terms: Reference and Builder. The entries below state how the terms are used in this book.
| Term | Meaning | Miden reference |
|---|---|---|
| AMM | Automated market maker. A contract that prices an asset pair from its reserves. | |
| XYK | The constant-product invariant x * y = k. | |
| Berth | A pool. A single account holding two assets. | |
| Manifest | The registry. The account that holds the accepted pool code commitments and the pool set. | |
| Cargo | A token. A fungible asset held or traded by a pool. | |
| Crew | Liquidity providers. The accounts that deposit both assets and hold LP tokens. | |
| Account | A unit of Miden state holding storage, code, and a vault. | Accounts |
| Account component | Code installed on an account. | Accounts |
| Faucet | An account that issues a fungible asset. | Assets |
| Note | A Miden message carrying a script, storage, and assets to a target account. | Notes |
| Note script | The code a note runs. | Notes |
| Transaction | The execution that consumes notes and updates accounts. | Transactions |
| MASM | Miden assembly, the language the contracts compile to. | MASM |
| LP token | The fungible asset a pool issues to represent a share of its reserves. | |
| LP fee | The fee a swapper pays, in basis points of the input. | Fees |
| Protocol share | The registry’s share of the LP fee, in basis points of that fee. | Fees |
| Registry share | See protocol share. | |
| LP fee tier | One of the allowed lp_fee_bps values the registry lists. | |
| Protocol pot | The pool’s accrued protocol fees, held in the vault until collected. | |
| Network account | An account serviced by the node’s transaction builder through a note-script allowlist, with no signing key. | Network |
| Network note | A note carrying a NetworkAccountTarget attachment, so the node can route it to a network account. | Notes |
| FEE_SPONSORSHIP | The note that carries a feature note’s consumption fee and is consumed with it. | Fees |
| P2ID | The pay-to-id note script used for output notes. | Notes |
| FPI | Foreign procedure invocation. A call from one account’s procedure into another account’s procedure. | Cross-component calls |
| Slippage | The difference between a quoted output and the accepted minimum. | |
| Deadline | A block timestamp after which a note reverts. | |
MAX_AMOUNT | The u64 cap 2^57 on every reserve, amount, and lp_supply. | |
MINIMUM_LIQUIDITY | The 1000 LP tokens locked permanently on the first mint. | |
| Oracle | The two cumulative price accumulators and the last update timestamp. | |
| Collect | The operation that moves a protocol pot or a registry balance to the protocol account. |
Contracts overview
The contracts repository contains eight contracts: two account components and six note scripts.
| Contract | Kind | Purpose |
|---|---|---|
port0 | account component | Pool: reserves accounting, LP supply, fee rates, protocol pot, price oracle |
pool-registry | account component | Accepted pool code commitments, pool set, asset pair index, protocol fee policy |
mint-note | note script | Add liquidity |
burn-note | note script | Remove liquidity |
swap-note | note script | Trade |
collect-note | note script | Move a pool’s accrued protocol fees to the protocol account |
register-note | note script | Register a pool in the registry |
registry-collect-note | note script | Move the registry’s accumulated register fees to the protocol account |
The repository layout:
contracts/
port0/ pool account component
pool-registry/ registry account component
mint-note/ add-liquidity note script
burn-note/ remove-liquidity note script
swap-note/ swap note script
collect-note/ pool protocol-fee collection note script
register-note/ pool registration note script
registry-collect-note/ registry fee collection note script
integration/ MockChain integration tests and deployment binaries
Pool lifecycle
- Build the pool account. Its initial storage holds the token0 and token1 asset keys, the two fee parameters, the P2ID root, and the registry pointer. The LP token key is derived from the pool’s account id and is not stored.
- Fund the pool with a note and consume that note as the pool’s first transaction. A network account requires every transaction to have an effect, so an empty scriptless deploy is rejected. The funding note commits the account and pays its first transaction fee from the received funds.
- Emit the register note. The registry verifies the pool’s code commitment and identity by foreign procedure invocation, then records the pool and its asset pair.
- Liquidity providers emit mint notes and burn notes. Traders emit swap notes.
- The fee recipient emits collect notes against a pool, or registry-collect notes against the registry. Both are permissionless.
Execution model
Notes invoke pool procedures through the component interface. Pool procedures run in account context and may create output notes and move assets. Note scripts run in note context and may not perform those actions directly.
A pool’s fee parameters, P2ID root, registry pointer, and token keys are initial storage and hold for the pool’s lifetime. No pool parameter is settable after creation.
Asset model
The LP token is a fungible asset issued by the pool account. No separate faucet
account exists. The asset key is derived from the pool’s account id as
[0, 0, suffix | 0x11, prefix], because the key embeds the account id and the
account id is derived from the initial storage commitment, so seeding it would
be circular.
Build and test
Each contract is a standalone crate built with cargo-miden:
cargo miden build --manifest-path contracts/port0/Cargo.toml --release
The integration tests run against MockChain, an in-process chain that compiles the contracts and executes the note flows without a network:
cargo test -p integration --release
The SDK’s artifact exporter builds the packages itself. A manual build is needed only to seed a fresh checkout.
Pool
The port0 component is the pool account component. It holds the reserves
accounting, the LP supply, the two fee parameters, the protocol pot, and the
price oracle.
Storage
The pool component declares ten value slots.
| Slot | Type | Encoding |
|---|---|---|
token0_asset_key | Word | token0 asset key |
token1_asset_key | Word | token1 asset key |
lp_supply | Felt | [supply, 0, 0, 0] |
fees | Word | [lp_fee_bps, protocol_fee_share_bps, 0, 0] |
p2id_root | Word | P2ID script root used for output notes |
protocol_pot | Word | [amount0, amount1, 0, 0] |
price0_cumulative_last | Word | u128, four 32-bit limbs, least significant first |
price1_cumulative_last | Word | u128, four 32-bit limbs, least significant first |
block_timestamp_last | Felt | [timestamp, 0, 0, 0] |
registry_id | Word | [0, 0, suffix, prefix] of the registry account id |
Every slot except protocol_pot and the two accumulators is written at account
creation. protocol_pot accrues as swaps happen. The LP token key is not a
slot; the contract derives it from the pool’s account id.
Procedures
| Procedure | Purpose |
|---|---|
token0_asset_key | Reads the token0 asset key |
token1_asset_key | Reads the token1 asset key |
lp_supply | Reads the LP supply |
get_reserves | Reads both reserves |
get_fees | Reads both fee parameters |
mint | Adds liquidity and mints LP tokens |
burn | Removes liquidity and burns LP tokens |
swap | Trades one asset for the other |
quote_swap | Computes a swap without modifying state |
collect | Moves the protocol pot to the protocol account |
get_registry_identity | Returns the registry pointer, token keys, and fee parameters |
get_registry_identity returns twelve felts in the order
[prefix, suffix, token0[0..4], token1[0..4], protocol_fee_share_bps, lp_fee_bps]. The registry calls it by foreign procedure invocation at
registration.
Reserves
The pool does not store reserves. Reserves equal the vault balances of token0 and token1 minus the protocol pot. The vault is the source of truth, and the protocol pot is the only balance the pool withholds from the curve.
Mint
On the first deposit the pool computes root = isqrt(reserve0_after * reserve1_after) and requires root > MINIMUM_LIQUIDITY. It mints root LP
tokens and retains MINIMUM_LIQUIDITY = 1000; the provider receives
root - 1000.
On later deposits the pool mints the smaller of amount0 * lp_supply / reserve0
and amount1 * lp_supply / reserve1. The amount of each token used rounds up in
the pool’s favor with div_ceil; the remainder returns to the provider.
Burn
For a burn of amount LP tokens, the pool returns amount * reserve0 / lp_supply of token0 and amount * reserve1 / lp_supply of token1, then reduces
lp_supply. The burn reverts when amount > lp_supply - MINIMUM_LIQUIDITY and
when either payout rounds to zero.
Swap
The pool computes the LP fee on the full input, carves the protocol share out of that fee, and computes the output from the full input with the LP fee weight. The output does not depend on the protocol share.
lp_fee = amount_in * lp_fee_bps / 10000
protocol_fee = lp_fee * protocol_fee_share_bps / 10000
amount_in_with_fee = amount_in * (10000 - lp_fee_bps)
amount_out = amount_in_with_fee * reserve_out /
(reserve_in * 10000 + amount_in_with_fee)
The full input enters the vault. The output leaves the vault. The reserve grows
by amount_in - protocol_fee, and the protocol fee is recorded in
protocol_pot. The pool keeps the LP fee minus the protocol share.
Oracle
The oracle updates at the start of each mint, burn, and swap. It computes
p0 = (reserve1 << 64) / reserve0
p1 = (reserve0 << 64) / reserve1
and adds p0 * elapsed and p1 * elapsed to the two cumulative values, where
elapsed is the block timestamp delta. The accumulators saturate at
u128::MAX. A backward timestamp yields an elapsed of zero, and a pool with a
zero reserve returns before updating the accumulators.
Collect
collect reads the protocol fee destination from the registry by foreign
procedure invocation, moves the protocol pot contents to that account as a P2ID
note, and resets the pot. An empty pot reverts. The destination is not stored in
the pool, so it is rotated in one place and read only on the collect path.
Configuration
A pool’s configuration is its initial storage: lp_fee_bps,
protocol_fee_share_bps, p2id_root, registry_id, and the two token asset
keys. The LP token key is derived from the pool’s account id. No pool parameter
is settable after creation. The registry rejects a pool whose token pair, fee
share, LP fee tier, or registry pointer disagrees with the registry.
Registry
The pool-registry component is the registry account component. It holds the
canonical pool set, the ordered asset pair index, and the protocol fee policy.
Registration is the only way a pool becomes discoverable by the indexer and the
app.
Storage
The registry account declares seven storage slots.
| Slot | Type | Key | Value |
|---|---|---|---|
accepted_code_commitments | map | pool code commitment | 1 when accepted |
pools | map | pool id key [prefix, suffix, 0, 0] | pool code commitment |
assets_to_pool | map | ordered asset pair [lo.prefix, lo.suffix, hi.prefix, hi.suffix] | pool id key |
protocol_fee_to | value | account id word [0, 0, suffix, prefix] | protocol fee destination |
protocol_fee_share_bps | value | [bps, 0, 0, 0] | protocol share every pool must carry |
allowed_lp_fee_bps | map | LP fee tier in bps | 1 when a pool may charge it |
p2id_root | value | P2ID script root | script root of the payout note |
Keys are four-felt words. A pool id key is [prefix, suffix, 0, 0]. An ordered
asset pair is [lo.prefix, lo.suffix, hi.prefix, hi.suffix], ordered by
(prefix, suffix) as canonical integers.
Registration
register(pool_id, asset_0, asset_1) performs these checks in order:
- The two assets are not equal.
- The pool id is not already registered.
- The asset pair is not already registered.
- The pool’s code commitment is in
accepted_code_commitments. - The pool’s registry pointer equals the executing registry.
- The pool’s token keys match the claimed pair in either order.
- The pool’s protocol share equals the registry’s
protocol_fee_share_bps. - The pool’s LP fee is an entry in
allowed_lp_fee_bps.
It then writes pools and assets_to_pool. The commitment is read from the
pool’s CodeInspection::get_code_commitment, and the remaining fields from the
pool’s get_registry_identity, both by foreign procedure invocation.
Registration is not a judgment. The registry accepts a code commitment and records the pair. It does not endorse a token.
Code commitment
The accepted commitment covers the pool’s full component set: the port0
component, CodeInspection, the network-account auth, and the fee-policy
components. It changes when any of those change, and the registry must be
seeded with the value the deployed pool produces. The SDK prints the value with
npm run pool:commitment.
The current accepted commitment is
0x51c52cca051456d35329f2add92319f6447f1437a8b716279e536c1c0f6fab09.
Pool lookup
pool_for_assets(asset_0, asset_1) returns [found, prefix, suffix, 0]. The
found flag is 1 with the pool id, or all four felts are zero when the pair
has no pool. A zero account id is never returned as a pool.
is_pair_registered(asset_0, asset_1) returns the predicate behind that check.
pool_code_commitment(pool_id) reads the recorded commitment.
Fee policy
protocol_fee_to returns the protocol fee destination as four felts:
[prefix, suffix, 0, 0]. A pool reads it by foreign procedure invocation when
it collects, so the address is set in one place and read only on the collect
path. protocol_fee_share_bps returns the share every registered pool must
carry. allowed_lp_fee_bps holds the permitted tiers.
Registry collect
collect(asset_key, serial_num) moves the registry’s whole balance of
asset_key to protocol_fee_to as a P2ID note. It is permissionless: the
destination is fixed in storage, so a caller can only flush accumulated register
fees toward the protocol. The procedure reverts when p2id_root is unset, when
the asset key is empty, and when the registry holds none of the asset.
Foreign procedure roots
A pool addresses the registry’s protocol_fee_to, and the registry addresses
the pool’s get_registry_identity and the standard get_code_commitment, all
by hard-coded MAST root. Editing either contract shifts those roots, and the
call fails with a message that names a missing procedure root. The roots are
printed by:
cargo run -p integration --bin registry_roots --release -- <contract-dir>
cargo run -p integration --bin code_inspection_roots
Notes
Every pool operation is a note sent to the pool account. Registration and registry collection are notes sent to the registry account. This chapter lists each note, the assets it carries, and the felts it reads from note storage.
| Note | Assets | Note storage (felts, in order) |
|---|---|---|
| mint-note | token0, token1 | pool id (2), recipient (2), serial (4), tag (1), deadline (1), min_lp_out (1) |
| burn-note | LP token | pool id (2), recipient (2), serial (4), tag (1), deadline (1), min_amount0 (1), min_amount1 (1) |
| swap-note | token0 or token1 | pool id (2), recipient (2), serial (4), tag (1), deadline (1), min_amount_out (1) |
| collect-note | none | serial (4) |
| register-note | none | registry id (2), pool id (2), asset0 (2), asset1 (2) |
| registry-collect-note | none | registry id (2), asset key (4), serial (4) |
Account ids are two felts, prefix then suffix. A serial is four felts. deadline
is a block timestamp; the note reverts if the pool executes it after that time.
The min_* fields bound slippage.
mint-note
Deposits token0 and token1. The pool mints LP tokens to the recipient. On the
first mint the pool mints sqrt(x * y) LP tokens and retains
MINIMUM_LIQUIDITY = 1000 permanently; the provider receives the remainder. If
the deposited ratio differs from the pool ratio, the pool refunds the excess of
one token to the recipient.
burn-note
Burns LP tokens. The pool returns a proportional share of both reserves to the
recipient. The burn reverts when amount_to_burn > lp_supply - MINIMUM_LIQUIDITY and when either payout rounds to zero.
swap-note
Sends one token. The pool pays out the other token, less fees. min_amount_out
reverts the trade when the output would be smaller.
collect-note
Moves the pool’s accrued protocol fee to the protocol account the registry designates. The note is permissionless: the destination is fixed in the registry, so a caller can only flush fees toward it. The note reverts when the fee pot is empty.
register-note
Registers the pool in the registry. The note targets the registry account and
carries the registry id, the pool id, and the two token faucet ids. The
registry’s register procedure performs the checks listed in the
registry chapter.
registry-collect-note
Moves the registry’s whole balance of one asset to the protocol account. The note carries the registry id, the four-felt asset key, and a serial. Like a pool collect, it is permissionless and the destination is fixed in storage.
Fee sponsorship
A feature note does not carry its own consumption fee. A separate FEE_SPONSORSHIP note carries exactly one fungible asset, the fee amount in the fee faucet asset, and the feature note id in its storage. The two notes are consumed together by the node’s transaction builder. The registry-collect note is unsponsored and priced at zero, so the registry pays that transaction fee from its own vault.
Fees
Two basis-point parameters describe the fee on a swap. They are separate, and the protocol share does not add to the swapper’s cost.
| Parameter | Where it lives | Meaning |
|---|---|---|
lp_fee_bps | Pool initial storage, one value from the registry’s allowlist | The fee a swapper pays |
protocol_fee_share_bps | Registry value slot, copied into pool initial storage | The protocol’s share of the LP fee, in basis points of it |
Registry share
The registry holds one protocol share. The shipped value is 1200, which is 12
percent of the LP fee. Every registered pool must carry that share; the
register procedure rejects a pool whose share disagrees with the registry.
The registry also holds an allowlist of LP fee tiers. The shipped tiers are 5, 30, and 100 basis points, which are 0.05, 0.30, and 1.00 percent.
The swap computation
lp_fee = amount_in * lp_fee_bps / 10000
protocol_fee = lp_fee * protocol_fee_share_bps / 10000
amount_in_with_fee = amount_in * (10000 - lp_fee_bps)
amount_out = amount_in_with_fee * reserve_out /
(reserve_in * 10000 + amount_in_with_fee)
The full input enters the vault. The output is computed from the full input
with the LP fee weight, so amount_out does not depend on the protocol share.
The pool’s reserve grows by amount_in - protocol_fee; the pool keeps the LP
fee minus the share, and the share accrues to the protocol pot.
At the shipped share of 1200, the protocol fee at each tier is:
lp_fee_bps | LP fee | Protocol share of the fee | Protocol fee on a 10,000 input |
|---|---|---|---|
| 5 | 0.05% | 12% | 0.6 bps, or 0 base units after integer division |
| 30 | 0.30% | 12% | 3.6 bps, or 3 base units after integer division |
| 100 | 1.00% | 12% | 12 bps, or 12 base units |
The protocol fee is lp_fee_bps * protocol_fee_share_bps / 10000, computed
with integer division. The table shows the mathematical value and the value the
contract records after flooring.
Worked example
Reserves: reserve0 = 1,000,000, reserve1 = 2,000,000. A swap sends 10,000
of token0 at the 30 bps tier, with the shipped share of 1200.
| Step | Value |
|---|---|
lp_fee | 10,000 * 30 / 10000 = 30 |
protocol_fee | 30 * 1200 / 10000 = 3 (floored from 3.6) |
amount_in_with_fee | 10,000 * 9970 = 99,700,000 |
amount_out | 99,700,000 * 2,000,000 / (1,000,000 * 10000 + 99,700,000) = 19,743 |
reserve0_after | 1,000,000 + 10,000 - 3 = 1,009,997 |
reserve1_after | 2,000,000 - 19,743 = 1,980,257 |
The swapper pays the 30 bps tier. The protocol receives 3 base units of token0, and the LP side keeps the remaining 27 base units of the fee.
Collect
Two permissionless notes move fees to the protocol account:
| Note | Source | Destination |
|---|---|---|
| collect-note | The pool’s protocol_pot | The registry’s protocol_fee_to |
| registry-collect-note | The registry’s balance of one asset | The registry’s protocol_fee_to |
Both destinations are fixed in registry storage. A caller can trigger a payout but cannot redirect it.
The pool collect note reverts when the pot is empty. The registry collect note reverts when the registry holds none of the requested asset.
Limits and failure conditions
Numeric bounds
Amounts, reserves, and lp_supply are u64 values bounded by
MAX_AMOUNT = 2^57.
The binding constraint is the swap numerator,
amount_in_with_fee * reserve_out. amount_in_with_fee is at most
amount_in * 10000, so the worst case is MAX_AMOUNT^2 * 10000. The value must
stay below 2^128, which requires MAX_AMOUNT < 2^64 / 100. The value 2^57
satisfies the bound with margin. Mint and burn products stay below 2^126.
The oracle accumulators saturate at u128::MAX rather than wrap, so a consumer
subtracts two readings without modular arithmetic.
Failure conditions
Each procedure reverts under the conditions below.
Mint
- The deposit is missing one of the two tokens.
- A deposited amount is above
MAX_AMOUNT. - The first deposit mints at most
MINIMUM_LIQUIDITY. - A later deposit mints zero liquidity.
- The liquidity out is below
min_lp_out.
Burn
- The note carries multiple assets or an asset that is not the LP token.
- The LP amount is zero.
- The pool is empty.
- The amount is above
MAX_AMOUNT. - The burn leaves less than
MINIMUM_LIQUIDITY. - Either payout rounds to zero.
- A payout is below its minimum.
Swap
- The note carries multiple assets or an asset that is neither token.
- The input is zero.
- The pool is empty.
- The input or a reserve is above
MAX_AMOUNT. - The output is zero or at least
reserve_out. - The resulting reserve is above
MAX_AMOUNT. - The output is below
min_amount_out.
Collect
- The protocol pot is empty.
Registry collect
p2id_rootis unset.- The asset key is empty.
- The registry holds none of the asset.
Deadline
Every feature note except collect carries a deadline, a block timestamp. The
pool reverts the note when it executes after that time. A deadline is built
from the last seen block timestamp plus a margin, so a note does not sit
unbounded in the pool’s input queue.
Security model
The system is unaudited. No audit report exists. The statements below describe how the contracts behave, not a guarantee of safety.
Custody
A pool account holds the assets. No operator holds them. Every operation is a note that the node consumes against the pool, and the pool’s vault is the only store of reserves.
No admin and no upgrade path
A pool’s fee parameters, P2ID root, registry pointer, and token keys are initial storage. No procedure sets them after creation. A pool cannot be paused, upgraded, or reconfigured.
The registry’s accepted code commitments and fee policy are initial storage as
well. The registry has no setter for either, so changing an accepted commitment
or the protocol share means building and deploying a new registry account. The
register procedure only adds pool ids and asset pairs to its two maps.
Public state
Pools and notes are public network accounts. Pool reserves, LP supply, fees, pot, and oracle readings are on chain and readable by anyone. XYK notes are public; the protocol does not provide private trading.
Permissionless flows
Pool creation is permissionless. The registry accepts a code commitment; it does not screen a token or endorse a pool.
Both collect paths are permissionless. The pool collect note and the registry-collect note move fees to the destination fixed in registry storage, so a caller can trigger a payout but cannot redirect it.
Network auth
A pool and the registry install the network-account auth component. The component carries a note-script allowlist, a fee policy, and the nonce handling. The node’s transaction builder reads the allowlist to consume notes on the account’s behalf. There is no signing key.
The registry prices both of its admitted note kinds: the register note at the configured register fee, and the registry-collect note at zero. Every admitted script root must be priced, because a root the fee policy does not price aborts fee estimation and the account’s auth procedure rejects that note as an input. The allowlist is initial storage, and the procedures that mutate it are gated on an authority the account does not install.
Foreign procedure roots
A pool addresses the registry’s protocol_fee_to, and the registry addresses
the pool’s get_registry_identity and the standard get_code_commitment, all
by hard-coded MAST root. A contract edit that shifts a root breaks the call
until the constant is updated. The roots are printed by the integration
binaries and must be re-derived after a contract change.
Note fees
Notes carry a fee, and the executing account pays it from its own vault. A pool’s fee faucet must equal the chain’s fee faucet, or the node never consumes the pool’s notes. The chain fee asset on the testnet is USDCX. It comes from the network faucet and is not minted by this project.
Reporting
Vulnerability reports go to hello@port0.sh. The contact is listed in the contact chapter.
Install
The SDK is published as @port0.sh/sdk.
npm install @port0.sh/sdk @miden-sdk/miden-sdk@0.17.1
The SDK peers on @miden-sdk/miden-sdk and is developed and tested against
0.17.1. Pin the exact version the testnet runs. A client talks only to a node
with the same major and minor version, so the two move together.
Artifacts
The package embeds the eight compiled Miden packages from the contracts
repository: the pool and registry account components, and the mint, burn, swap,
collect, register, and registry-collect note scripts. CONTRACT_SHA256 holds
the digest of each package for build-time drift detection.
Source
The canonical source is the Radicle repository. GitHub and npm are mirrors. The repositories chapter lists every location.
Quick start
import { MidenClient } from "@miden-sdk/miden-sdk";
import { XykClient, deadlineFrom } from "@port0.sh/sdk";
const client = await MidenClient.create({
rpcUrl: "https://rpc.testnet.miden.io/",
feeFaucetId: "0x4cbdcaffe75f0a317482224dae6436", // chain fee faucet, required
proverUrl: "testnet",
autoSync: false,
});
await client.syncChain();
const xyk = XykClient.from(client);
const pool = xyk.pool({
poolId,
feeFaucetId,
noteFees: { swap: 133n }, // fee schedule fixed at pool creation
});
const state = await pool.state();
const result = await pool.swap({
sender,
recipient,
tokenIn: token0,
amountIn: 1000n,
slippageBps: 50n,
deadline: deadlineFrom(latestBlockTimestamp, 3600n),
// submit: true, waitForConfirmation: true,
});
// result.note, result.sponsorNote, result.request, result.quote,
// result.minAmountOut, result.tokenOut, result.fee, result.feeFaucetId
Notes on the example
feeFaucetIdis required byMidenClient.createand must be the chain fee faucet. On the testnet it is USDCX.proverUrlselects the remote prover. Omitting it falls back to the local prover, which proves slowly.syncChainrefreshes the local store, which is a cache of chain state.Poolfetches pool state internally, so each operation method is a single call.- All amounts are
bigintin the smallest unit of the asset. All account ids are the web-sdkAccountIdtype.
Amounts and words
type Word4 = readonly [bigint, bigint, bigint, bigint];
type SerialNumber = Word4;
Word4 is a four-limb value: an asset key, a registry id word, or a serial.
SerialNumber is the same shape.
Operations
Each operation runs the local quote, applies the slippage bound, builds the
feature note and its FEE_SPONSORSHIP note, and assembles one transaction
request. The shape is (client, config, state, params, options?).
swap(client, config, state, params: SwapParams, options?): Promise<SwapOutput>
addLiquidity(client, config, state, params: MintParams, options?): Promise<MintOutput>
removeLiquidity(client, config, state, params: BurnParams, options?): Promise<BurnOutput>
collectFees(client, config, state, params: CollectParams, options?): Promise<OperationOutput>
registerPool(client, config: RegistryConfig, params: RegisterPoolParams, options?): Promise<RegisterPoolOutput>
Outputs
OperationOutput is { note, sponsorNote, request, fee, feeFaucetId }.
| Output | Extra fields |
|---|---|
SwapOutput | quote, tokenOut, minAmountOut |
MintOutput | quote, minLpOut |
BurnOutput | quote, minAmount0, minAmount1 |
RegisterPoolOutput | note, sponsorNote, request, fee, feeFaucetId |
Submission
SubmitOptions is { submit?: boolean, waitForConfirmation?: boolean }.
submit defaults to false: the caller receives the request and hands it to a
wallet adapter. Setting submit: true sends the request through the
MidenClient.
Parameters
| Operation | Fields |
|---|---|
SwapParams | sender, recipient, tokenIn, amountIn, slippageBps, deadline, optional serial, tag |
MintParams | sender, recipient, amount0, amount1, slippageBps, deadline, optional serial, tag |
BurnParams | sender, recipient, amountLp, slippageBps, deadline, optional serial, tag |
CollectParams | sender, optional serial |
RegisterPoolParams | sender, poolId, asset0, asset1 |
Helpers
applySlippage(amount: bigint, slippageBps: bigint): bigint
resolveSwapSide(tokenIn: string, token0: string, token1: string): 0 | 1
deadlineFrom(blockTimestampSeconds: bigint, marginSeconds: bigint): bigint
applySlippage returns floor(amount * (10000 - slippageBps) / 10000) and
throws INVALID_ARGUMENT for slippageBps outside [0, 10000).
resolveSwapSide returns 0 for token0 and 1 for token1, and throws
INVALID_ARGUMENT when tokenIn matches neither side.
deadlineFrom returns blockTimestampSeconds + marginSeconds.
Pool configuration
type PoolConfig = {
poolId: AccountId;
feeFaucetId: AccountId;
noteFees: PoolNoteFees;
deadlineMarginSeconds?: bigint;
};
noteFee(noteFees: PoolNoteFees, kind: PoolNoteKind): bigint
DEFAULT_DEADLINE_MARGIN_SECONDS // 3600n
PoolNoteFees is Partial<Record<PoolNoteKind, bigint>>, and PoolNoteKind is
"mint" | "burn" | "swap" | "collect". noteFee returns the configured fee for
a kind, or 0n when unset. The fee schedule is fixed at pool creation and must
be kept by the application for building correct sponsorship notes.
Pure math
The pure math functions have no WASM dependency and mirror the contract arithmetic exactly, including check order and rounding.
isqrt(n: bigint): bigint
divCeil(numerator: bigint, denominator: bigint): bigint
amountOut(amountIn, reserveIn, reserveOut, lpFeeBps, protocolFeeShareBps): SwapQuote
quoteMint(amount0, amount1, reserve0, reserve1, lpSupply): MintQuote
quoteBurn(amountToBurn, reserve0, reserve1, lpSupply): BurnQuote
amountOut computes:
lp_fee = floor(lp_fee_bps * amount_in / 10000)
protocol_fee = floor(protocol_fee_share_bps * lp_fee / 10000)
amount_in_with_fee = amount_in * (10000 - lp_fee_bps)
amount_out = floor(amount_in_with_fee * reserve_out /
(reserve_in * 10000 + amount_in_with_fee))
The failure order is ZeroAmountIn, EmptyPool, Overflow for any input above
MAX_AMOUNT, ZeroAmountOut, InsufficientLiquidity when out >= reserve_out,
and Overflow when reserve_in_after >= MAX_AMOUNT.
quoteMint on the first deposit mints isqrt(x * y) LP tokens and retains
MINIMUM_LIQUIDITY. On later deposits it mints the smaller of the two
token-contribution ratios, rounds the consumed amounts up with divCeil, and
refunds the unused remainder.
quoteBurn returns the proportional share of both reserves. A burn that would
leave less than MINIMUM_LIQUIDITY throws InsufficientLiquidity, as does a
burn whose payout rounds to zero for either token.
Pool reads
decodePoolState(account: Account): PoolState
fetchPoolState(getAccount, poolId): Promise<PoolState>
decodePoolState reads the ten value slots and the vault. Reserves equal the
vault balances of token0 and token1 minus the protocol pot. It throws
NOT_A_POOL when the token asset keys are missing or zero, and INVARIANT when
a vault balance is below the recorded pot.
PoolState fields: accountId, token0Key, token1Key, token0FaucetId,
token1FaucetId, lpSupply, fees (lpFeeBps, protocolFeeShareBps),
registryId, p2idRoot, protocolPot (amount0, amount1),
price0CumulativeLast, price1CumulativeLast, blockTimestampLast,
reserve0, reserve1. The protocol fee destination lives on the registry and
is read only when the pool collects.
Request building
The SDK turns parameters into notes and assembles the transaction request a wallet executes. This chapter describes the layers below the operations.
Note builders
Each builder returns a web-sdk Note. The XYK feature builders produce a public
network note with a NetworkAccountTarget attachment to the target account.
buildMintNote(input: MintNoteInput): Note
buildBurnNote(input: BurnNoteInput): Note
buildSwapNote(input: SwapNoteInput): Note
buildCollectNote(input: CollectNoteInput): Note
buildRegisterNote(input: RegisterNoteInput): Note
buildRegistryCollectNote(input: RegistryCollectNoteInput): Note
buildFeeSponsorshipNote(input: FeeSponsorshipNoteInput): Note
p2idRoot(): Word4
noteScript(kind: NoteKind): NoteScript
POOL_NOTE_KINDS: readonly PoolNoteKind[] // ["mint","burn","swap","collect"]
NOTE_KINDS: readonly NoteKind[] // POOL_NOTE_KINDS plus "register" and "registry-collect"
Common input fields are sender, poolId, recipient where applicable,
deadline, and the min_* slippage bounds. The serial defaults to
randomSerial(), and tag defaults to accountTargetTag(recipient).
buildRegisterNote takes sender, registryId, poolId, asset0, and
asset1. The note targets the registry and carries the 8-felt register layout
with no assets.
buildRegistryCollectNote takes sender, registryId, assetKey, and an
optional serial. The note targets the registry and carries the 10-felt
registry-collect layout with no assets.
buildFeeSponsorshipNote takes sender, target, featureNote,
feeFaucetId, feeAmount, and optional reclaimer (defaults to sender) and
reclaimHeight (defaults to 0, disabled). The note carries exactly one
FungibleAsset(feeFaucetId, feeAmount) and no attachment.
noteScript(kind) deserialises the compiled package from
src/generated/contracts.ts once and caches it.
Note storage layouts
Each function returns bigint[] in the exact order the note script asserts.
| Function | Felts | Layout |
|---|---|---|
mintNoteStorage | 11 | pool, recipient, serial, tag, deadline, minLpOut |
burnNoteStorage | 12 | pool, recipient, serial, tag, deadline, minAmount0, minAmount1 |
swapNoteStorage | 11 | pool, recipient, serial, tag, deadline, minAmountOut |
collectNoteStorage | 4 | serial |
registerNoteStorage | 8 | registry, pool, asset0, asset1 |
registryCollectNoteStorage | 10 | registry, asset key (4), serial (4) |
feeSponsorshipStorage | 7 | feature note id (4), reclaimer [suffix, prefix], reclaim height |
Account ids inside layouts are [prefix, suffix]. The reclaimer in
feeSponsorshipStorage is the exception: it is [suffix, prefix], matching
FeeSponsorshipNoteStorage.
Encoders
accountIdFelts(id): [bigint, bigint] // [prefix, suffix]
fungibleAssetIdWord(faucetId): Word4 // [0, 0, suffix | 0x11, prefix]
accountTargetTag(id): bigint // top 14 prefix bits, low bits zeroed
randomSerial(): SerialNumber
randomSeed(): Uint8Array // 32 bytes
isWord4(value): value is Word4
ACCOUNT_TARGET_TAG_LEN // 14
accountTargetTag is computed in bigint. JavaScript number bitwise operators
truncate to int32 and produce a negative tag whenever bit 31 of the prefix is
set.
Combined request
ownOutputNotesRequest(
client: MidenClient,
notes: Note[],
targetPoolId: AccountId,
): Promise<TransactionRequest>
The function builds one TransactionRequest with notes as own output notes,
plus the 0.17 machinery a wallet-executed multisig request needs:
- A three-word
MultisigAuthArgspreimage,[boundBlock, 0, 0, 0] || SALT || CONVERSION_INFO(12 felts), committed withPoseidon2.hashElementsand declared withwithAuthArg, with the preimage in the advice map.SALTis fresh per request.CONVERSION_INFOis[feeFaucetId.suffix, feeFaucetId.prefix, 1, 1], read fromclient.feeFaucetId(). The bound block isclient.getSyncHeight(). - The target pool as a foreign account with empty storage requirements, so the
kernel prices the emitted network note through
estimate_note_fee.
Notes are pushed rather than constructor-consumed so the caller keeps valid
Note references.
Pool and registry creation
createPoolAccount(client, params): Promise<Account>
buildPoolAccount(client, params): Promise<Account>
poolCodeCommitment(client, params): Promise<string>
deployPoolAccount(client, poolId, fundingNoteId, options?): Promise<TransactionSubmitResult>
createFeeFaucet(client, params): Promise<Account>
createRegistryAccount(client, params): Promise<Account>
deployRegistryAccount(client, registryId, fundingNoteId, options?): Promise<TransactionSubmitResult>
CreatePoolAccountParams is { token0Key, token1Key, registryId, lpFeeBps, protocolFeeShareBps, p2idRoot?, feeFaucetId, noteFees?, seed? }.
createPoolAccount builds the pool component from the compiled package and the
ten init slots, installs the standard CodeInspection component, installs the
network auth with the fee schedule, and inserts the account into the client’s
local store. It does not deploy.
poolCodeCommitment returns the account code commitment as lowercase hex,
which is the value the registry’s accepted_code_commitments map must hold. It
is computed through the same path as createPoolAccount, so it cannot drift
from a deployed pool.
deployPoolAccount consumes the funding note as the pool’s first transaction.
The 0.17 network auth rejects a transaction with no effect, so a scriptless
deploy fails. The pool id is passed to transactions.consume as a hex string,
not as an AccountId, because the client re-parses account references
worker-side.
CreateRegistryAccountParams is { acceptedPoolCodeCommitment, feeFaucetId, registerFee, protocolFeeTo, protocolFeeShareBps, allowedLpFeeBps, p2idRoot, seed? }. createRegistryAccount seeds accepted_code_commitments and
allowed_lp_fee_bps, prices both registry note kinds in the network auth, and
inserts the account. deployRegistryAccount consumes a funding note as the
first transaction.
createFeeFaucet creates a public fungible faucet. It is not the pool’s network
note fee asset. A pool’s feeFaucetId must be the chain fee faucet, or the node
never consumes the pool’s notes. Use the helper for application-level faucets,
such as a launchpad token.
Discovery
isXykPool(account: Account): boolean
xykNoteScriptRoots(): Record<PoolNoteKind, string>
class PoolRegistry { register(poolId); get(canonicalId); list() }
isXykPool returns true when the account is a network account whose
note-script allowlist contains all four XYK pool note roots. The node RPC has no
account-listing-by-code-root endpoint, so chain-wide discovery belongs to the
indexer.
Errors
The SDK raises two error types. The pure math layer raises PoolMathError,
which mirrors the contract’s PoolMathError. Everything above the math layer
raises XykError, whose code is a stable string.
PoolMathError
| Kind | Condition |
|---|---|
EmptyPool | The pool is empty on one or both sides |
Overflow | A value would exceed the numeric bound |
ZeroAmountIn | The input amount is zero |
ZeroAmountOut | The output amount is zero |
InsufficientLiquidity | The pool cannot satisfy the operation |
Invariant | Stored reserves and LP supply disagree |
The message string matches the contract’s PoolMathError.
XykError
| Code | Condition |
|---|---|
NOT_A_POOL | The account has no token asset keys, or the keys are zero |
POOL_NOT_FOUND | The account or registry is not in the local store |
POOL_NOT_CONFIGURED | A required pool slot is missing |
MISSING_SLOT | A named storage slot is absent |
INVARIANT | A vault balance is below the recorded protocol pot |
INVALID_ARGUMENT | A parameter is out of range or inconsistent |
UNSUPPORTED_OPERATION | The requested operation has no implementation |
Failure order
amountOut raises in this order: ZeroAmountIn, EmptyPool, Overflow for
any input above MAX_AMOUNT, ZeroAmountOut, InsufficientLiquidity when the
output is at least reserve_out, and Overflow when the resulting reserve is
at least MAX_AMOUNT.
quoteMint checks addAmount(reserve, amount) against MAX_AMOUNT before the
ratio branch, so an over-sized deposit reverts even when part of it would be
refunded.
quoteBurn raises InsufficientLiquidity when the burn would leave less than
MINIMUM_LIQUIDITY, and when either payout rounds to zero.
The reference error table maps protocol rejections seen in operation to their cause and fix.
Indexer overview
The indexer reads the chain through the node RPC only. It uses no Miden client, no store, and no keystore. It polls the on-chain registry for pool ids and each pool for state, keeps a bounded history in SQLite, and serves the result over HTTP.
The indexer is the application’s pool source. The home page lists every pool it serves, and the pool page loads state and configuration from it. There is no local pool list and no manual import.
What it reads
| Data | Source |
|---|---|
| Pool set | Registry pools map |
| Reserves, LP supply, pot, fees, oracle | The pool’s ten value slots and its vault |
| Token symbols and decimals | Each faucet’s token_config slot |
| Note fee schedule and fee asset | The pool’s fee_asset_id value slot and the basic_constant_fee schedule map |
| Activity and 24 h aggregates | Classified reserve and pot deltas between polls |
Reserves equal the vault balances of token0 and token1 minus the protocol pot,
matching the contract’s get_reserves.
Process shape
The process starts the HTTP API, a registry poll loop, a block subscription, and a pool scheduler. The registry poll discovers pools. The block subscription marks a pool due when a block names it as changed. The scheduler fetches pools whose own cadence says they are due. A SQLite write cycle persists the newest bucket of each tier and the pending activities.
The API opens its own read-only connection to the same SQLite file. WAL lets the API read while the poller writes, so neither waits on the other.
Source
The canonical source is the Radicle repository. GitHub is a mirror. The repositories chapter lists every location.
Indexer configuration
Read from the environment at startup. A .env file in the current directory is
loaded first. Real environment variables take precedence, and a missing file is
not an error.
| Variable | Default | Meaning |
|---|---|---|
MIDEN_RPC_URL | https://rpc.testnet.miden.io | Node RPC endpoint |
PORT0_REGISTRY_ID | required | Registry account id, hex |
INDEXER_LISTEN | 127.0.0.1:8080 | HTTP listen address. Loopback by default, for an API behind a reverse proxy |
INDEXER_ALLOWED_ORIGINS | (empty) | Comma-separated origins allowed to read the API cross-origin. Empty means same-origin only; no wildcard is accepted |
POOL_POLL_SECS | 15 | Scheduler tick in seconds, clamped to 1..=5; per-pool cadence is internal |
REGISTRY_POLL_SECS | 60 | Registry poll interval |
INDEXER_DB | port0_indexer.sqlite | SQLite path; empty or off keeps the index in RAM only |
INDEXER_SNAPSHOT_RETENTION | (empty) | Empty prunes each resolution to its window; all keeps every bucket |
RUST_LOG | port0_indexer=info | Tracing filter |
Listen address
The API listens on loopback by default. A deployment that serves the API
directly sets INDEXER_LISTEN=0.0.0.0:8080. A deployment behind a reverse proxy
leaves the default and proxies to it.
Cross-origin reads
Cross-origin reads are closed by default. INDEXER_ALLOWED_ORIGINS lists the
origins the API answers, and only GET and POST are allowed, without
credentials. The refresh route is state-changing, so no wildcard is accepted. An
empty list means same-origin only, which is the default for a service behind a
proxy. A malformed entry is logged and dropped.
The shipped .env.example lists the Vite development ports,
http://localhost:5173 and http://localhost:5180, so a local app can fetch
the API cross-origin.
Database
INDEXER_DB holds the pool set, token metadata, one row per history bucket, and
one row per classified activity. An empty value or off keeps the index in RAM
only and forgets every series, activity, and yield window on restart.
INDEXER_SNAPSHOT_RETENTION=all keeps every bucket. The default prunes each
resolution to its window. See storage and retention.
Storage and retention
SQLite holds the pool set, token metadata, one row per history bucket, and one row per classified activity. History reads do not go through the in-memory model: the API opens its own read-only connection, and WAL lets it read while the poller writes, so neither waits on the other.
The RAM mirror keeps only the open bucket of each tier, which is all a poll
needs to extend it. Memory is therefore O(pools) rather than
O(pools x rows).
Column types
Every amount is a TEXT column. SQLite’s INTEGER is a signed 64-bit value, so a
u64 above 2^63 - 1 cannot be stored in it and a u128 never can; both
appear in pool state. rusqlite has no u64 binding. Amounts travel as the
decimal strings the API already uses, and block numbers, timestamps, and
resolution periods take an i64 that cannot overflow.
Row cost
The file grows by the measured cost of a row: 360.3 bytes per snapshot and 97.4 bytes per activity, including their indexes. The figures come from 200,000 rows of each type inserted into the shipped schema.
Retention ladder
Snapshots follow five tiers. Each tier is pruned to its own window on every write cycle except the last.
| Tier | Resolution | Retained | Rows per pool |
|---|---|---|---|
| T0 | 1m | 12 h | 720 |
| T1 | 5m | 7 d | 2,016 |
| T2 | 1h | 30 d | 720 |
| T3 | 6h | 180 d | 720 |
| T4 | 1d | forever | 365/year |
A tier is not computed from the one above it. Every poll feeds all five, and each tier merges the poll into its open bucket: open from the first sample, high and low from the extremes, close from the newest. The merge is exact for a candle.
At 10,000 pools the four bounded tiers hold 15.05 GB, and the daily tier adds
1.32 GB a year. INDEXER_SNAPSHOT_RETENTION=all keeps every bucket instead.
Activity retention
Activity rows are one per state change, so they scale with trading rather than with time alone. At 100, 1,000, and 10,000 activities per pool per day, one year costs 35.6 GB, 356 GB, and 3.56 TB. The default activity retention is 7 days, which holds the cost to 6.8 GB at 1,000 activities per pool per day.
A snapshot
A snapshot is the state at the end of its bucket, plus the bucket’s opening and extreme states. The extremes are stored as reserve pairs, not as a price, because a price is a ratio of two reserves and only the pair reconstructs it exactly.
Polling
The scheduler runs on a short tick, but a pool is fetched only when its own cadence says so. A block subscription moves that schedule forward whenever a block touches the pool.
| Idle for | Polled every |
|---|---|
| < 5 min | 15 s |
| < 1 h | 60 s |
| < 1 d | 10 min |
| >= 1 d | 1 h |
A block names the accounts it changed. The subscription turns activity into an immediate poll, so a timer is unnecessary for a quiet network. The arrangement is a fallback in both directions: a dropped stream reconnects from the last processed block and the cadence keeps polls flowing while it is down, and the cadence keeps working if the stream never connects. At 10,000 dormant pools the network cost settles at about 3 requests a second instead of 667.
Registry discovery
The registry’s pools map is the pool set. The node returns at most 1,000
entries inline. Above that cap the indexer detects the limit and discovers pools
through the paginated SyncAccountStorageMaps endpoint instead, which has no
entry limit. It reads a block range per poll, walks up to 64 pages, and resumes
from the block it reached. Discovery is idempotent, so an overlapping range
costs one read and nothing else.
Manual refresh
POST /pools/{id}/refresh marks one pool due at the next tick. The response is
202 because the fetch happens on the tick, not inline. The endpoint is limited
to one refresh per pool every 5 seconds and a global token bucket of 20 per
second. A caller that trips either limit gets 429 with Retry-After. An
unknown pool id gets 404 before either limit is consulted.
Vault reads
The vault is requested with the empty word as its asset_vault_commitment. The
field is a hint: omitting it means no vault data is returned, and the response
then carries an empty asset list that is indistinguishable from a genuinely
empty vault. A vault the node refuses to enumerate is an error, not a zero
balance.
Capacity
Measured row costs, 10,000 pools, one year. Retention runs five tiers: 1m for 12 h, 5m for 7 d, 1h for 30 d, 6h for 180 d, and 1d forever. The per-pool row counts are arithmetic on those windows (720 + 2,016 + 720 + 720 bounded, plus 365 a year for the daily tier). The byte figures are the measured 360.3 and 97.4 bytes per row.
INDEXER_SNAPSHOT_RETENTION=all keeps every bucket, whose per-pool yearly count
is 525,600 + 105,120 + 8,760 + 1,460 + 365 = 641,305.
| Policy | Snapshot rows | Snapshot storage |
|---|---|---|
| Keep every bucket | 6.41e9/year | 2.31 TB/year |
| Default (bounded tiers) | 4.18e7 total | 15.05 GB, plus 1.32 GB/year |
Activity rows are one per state change. At 100, 1,000, and 10,000 activities per pool per day, one year costs 35.6 GB, 356 GB, and 3.56 TB. The default activity retention is 7 days, which holds that to 6.8 GB at 1,000 per day.
Limits in order of arrival
| Wall | Detail |
|---|---|
| Registry read | The node does not return a storage map above 1,000 entries inline, so the inline read of the pools map stops at 1,001 pools. The indexer falls back to SyncAccountStorageMaps, which has no entry limit. |
| RPC rate | Polling 10,000 pools per 15 s is 667 get_account calls per second, each carrying the pool’s storage maps and vault. This is not serviceable by a public node. Event-driven fetching is the alternative. |
| Indexer RAM | The in-memory model keeps one open bucket per tier (5 snapshots) and up to 4,096 activities per pool with no global cap. At 10,000 pools the activity ring dominates at about 3.3 GB. |
| API payload | GET /pools returns one page, 50 by default and 500 at most, on the order of 1 KB per entry. A client that walks every cursor re-reads about 10 MB per sweep. |
| Disk | 2.31 TB a year under INDEXER_SNAPSHOT_RETENTION=all. VACUUM needs a second copy, so retention has to drop tables or partitions rather than rows. DELETE never returns space to the filesystem. |
| Browser | 10,000 rows re-rendered every 15 s once the user has loaded every page. The first page is 50 rows; full expansion needs virtualization. |
HTTP API
The indexer serves a read-only JSON API. Amounts are decimal strings because
u64 and u128 exceed the safe integer range of JSON numbers. spot_price and
fee_apr are display-only f64 values.
Cross-origin
The API answers cross-origin only for the origins in INDEXER_ALLOWED_ORIGINS.
Only GET and POST are allowed, without credentials. The refresh route is
state-changing, so no wildcard is accepted. An empty list means same-origin
only.
GET /status
Returns the indexer state.
| Field | Type | Meaning |
|---|---|---|
registry_id | string or null | Registry account id, hex |
last_registry_block | number or null | Last block a registry read covered |
known_pools | number | Pool ids discovered from the registry |
fetched_pools | number | Pools whose state has been fetched |
GET /pools
Returns one page of pool summaries, keyset paginated by pool id.
| Query | Default | Meaning |
|---|---|---|
token | none | Faucet id, hex or bech32, to keep only pools holding it |
limit | 50 | Page size, clamped to 1..=500 |
cursor | none | The next_cursor of the previous page |
Response:
{
"pools": [ { "id": "0x...", "...": "..." } ],
"next_cursor": "0x..."
}
next_cursor is absent on the last page. The list omits each pool’s
fee_schedule, which is the largest part of a summary and is only needed by a
pool page.
GET /pools/{id}
Returns one pool summary with its fee_schedule and the newest history points.
The response is the summary fields below, plus:
| Field | Type | Meaning |
|---|---|---|
fee_schedule | array | One { root, amount } per priced note script root |
history | array | Newest snapshots, default 1m resolution |
GET /pools/{id}/history
Returns the pool’s sampled history.
| Query | Default | Meaning |
|---|---|---|
resolution | 1m | One of 1m, 5m, 1h, 6h, 1d |
The response is an array of snapshot objects. An unknown resolution returns
400. Without a database the response is an empty array rather than an error:
the pool itself is still served.
POST /pools/{id}/refresh
Marks one pool due at the next scheduler tick. Returns 202 on acceptance,
404 for an unknown pool id, and 429 with Retry-After when the per-pool
cooldown or the global token bucket denies the request. The fetch happens on the
tick, not inline.
Pool summary fields
| Field | Type | Meaning |
|---|---|---|
id | string | Pool account id, hex |
token0, token1 | string | Faucet ids, hex |
token0_symbol, token1_symbol | string or null | Faucet symbol |
token0_decimals, token1_decimals | number or null | Faucet decimals |
fee_asset_id | string or null | Faucet of the network note fee asset |
fee_schedule | array or absent | { root, amount } per note script root; detail only |
reserve0, reserve1 | string | Reserves, vault balances minus the protocol pot |
lp_supply | string | LP supply |
k | string | reserve0 * reserve1 as u128 |
spot_price | number | reserve1 / reserve0, display only |
pot0, pot1 | string | Accrued protocol fees |
lp_fee_bps | number | The pool’s LP fee tier |
protocol_fee_share_bps | number | The pool’s protocol share |
block | number | Block of the last state change |
first_seen_unix | number | Wall-clock second the indexer first saw the pool |
last_change_unix | number | Wall-clock second of the last state change |
volume_24h_0, volume_24h_1 | string | Trailing 24 h volume per token |
lp_fees_24h_0, lp_fees_24h_1 | string | Trailing 24 h LP fees per token |
protocol_fees_24h_0, protocol_fees_24h_1 | string | Trailing 24 h protocol fees per token |
swaps_24h, adds_24h, removes_24h, collects_24h | number | Trailing 24 h activity counts |
fee_apr | number | Annualised fee yield for liquidity providers, as a fraction |
fee_apr_window_secs | number | Seconds the yield is annualised over |
Fee APR
Fees accrue in whichever token the swapper paid, so both sides are valued in
token0 at the pool’s own price before the ratio is taken. That price values the
reserves at 2 * reserve0 in token0 terms, which is the capital the yield is
taken against. The rate is annualised over fee_apr_window_secs, which is the
pool’s age while it is below 24 hours. A pool watched for ten minutes reports
ten minutes of yield stretched to a year.
Snapshot fields
| Field | Type | Meaning |
|---|---|---|
block | number | Block of the last state change in the bucket |
timestamp | number | The pool’s block_timestamp_last |
polled_at | number | Wall-clock second the bucket opened |
reserve0, reserve1 | string | State at the end of the bucket |
open_reserve0, open_reserve1 | string | State at the bucket’s first sample |
high_reserve0, high_reserve1 | string | Reserves at the bucket’s highest price |
low_reserve0, low_reserve1 | string | Reserves at the bucket’s lowest price |
lp_supply | string | LP supply at the close |
k | string | Constant product at the close |
pot0, pot1 | string | Protocol pot at the close |
price0_cumulative | string | Cumulative price0 at the close |
price1_cumulative | string | Cumulative price1 at the close |
A snapshot’s timestamp is the pool’s block_timestamp_last, so it is zero for
a pool that has never been operated on and unchanged across samples that saw no
state change. reserve0 and reserve1 are the close of the bucket. The open
and extreme reserves are stored as pairs because a price is a ratio of two
reserves and only the pair reconstructs it exactly.
App overview
The app is a static web application. Each page is a real HTML document with its own metadata, its own module entry, and a minimal amount of TypeScript. There is no application framework and no client-side router.
| Route | File | Purpose |
|---|---|---|
/ | index.html | Browse every registered pool from the indexer |
/pool.html?id=<poolid> | pool.html | Pool state, swap, add liquidity, remove liquidity |
/create.html | create.html | Deploy a pool and register it |
/about.html | about.html | What the pools are, and the risks of a testnet |
Every page carries the same chrome: the testnet notice as a strip above the
header, dismissed once and remembered under the key that <html data-notice-key>
names, the footer with the copyright year, and the chart attribution. The index
adds the hero drawing, the brand heading under it, a link to the launch page,
and the slogan.
Pool ids are canonical account-id hex strings with or without a 0x prefix.
Pool list
The home page lists the pools the indexer serves, 50 per page, polled every 15 s while the tab is visible. Each row carries the pair and its short pool id, the price of one token0 in token1, both reserves, 24-hour volume in both tokens, the LP fee, and the fee yield with the window it is annualised over. Rows are ordered by pair name so a row keeps its place between refreshes. A filter accepts a faucet id, and the Load more control walks the indexer’s keyset cursor.
The page needs no chain read and no WASM. Prices come from the indexer’s reserves through the same exact bigint arithmetic the pool page uses.
Pool page
The pool page renders the state summary, the swap, add-liquidity, and remove-liquidity forms, and the indexer’s sampled history. A pool is absent from the indexer between its registration transaction and the next registry poll, so the page waits instead of failing: it shows a spinner banner, re-fetches every 5 s, and loads the pool when it appears. After a minute it states that a pool which was never registered will not appear. While the page is open, pool metadata and history are re-read every 60 s.
A confirmed action does not change the visible state immediately. The wallet confirms at its own block, the pool account changes in the next block, and the indexer samples within 15 s. After a swap, deposit, or withdrawal, the page polls the chain state, wallet balances, the indexer record, and the history every 5 s for up to 60 s, and stops as soon as reserves or LP supply change.
History comes from GET /pools/{id}/history at 1m, 5m, 1h, 6h, or 1d
resolution and is drawn as candlesticks with a crosshair, a price line, and a
last-value label. The chart library is the largest dependency in the app and
only this page uses it, so it is imported on demand.
The pool page leads with the current price of one token0 in token1, computed by the contract’s own formula against the chain state. Until the first chain read returns, the headline is filled from the indexer’s reserves with the same ratio arithmetic.
Pool operations
All pool actions are notes to the pool account. The app builds the note and its
FEE_SPONSORSHIP note through the SDK with submit: false, then hands the
resulting TransactionRequest to the connected wallet, which signs, submits,
and reports confirmation.
Quoting is local and exact: the SDK’s contract math runs against the latest
fetched pool state. Slippage is a basis-point bound in [0, 9999]. Deadlines
are the maximum of the wall clock and the pool’s last-seen block timestamp, plus
one hour.
The deploy step of pool creation is an exception: the pool is a network account without a key. The wallet sends a small chain fee asset note to the pool address, and the app’s own client consumes that note as the pool’s first transaction, which commits the account and pays its first fee. There is no scriptless deploy, because the 0.17 network auth rejects a transaction with no effect.
Branding
The name and slogan are configuration. The two drawings are files.
| Item | Effect |
|---|---|
VITE_BRAND_NAME | Wordmark, page titles, og:site_name, footer |
VITE_BRAND_LOGO_TEXT | Text mark beside the header logo; falls back to the name |
VITE_BRAND_SLOGAN | One line under the home page name; empty hides it |
src/assets/branding/hero.html | The home page hero |
src/assets/branding/logo.html | The mark in the header of the other pages |
Both drawings are injected as they stand. A drawing may carry a <style>
element, which is where its own motion belongs.
Build
npm install
npm run dev # dev server with same-origin gRPC-web RPC proxy
npm test # vitest, pure-logic unit tests
npm run typecheck # tsc --noEmit
npm run lint # oxlint
npm run build # tsc -b && vite build, output in dist/
npm run preview # serve dist/ locally
The build output is plain static files: four HTML documents, one CSS file, one small JS bundle per page, and a shared lazy chunk containing the Miden SDK.
App configuration
Environment variables are read at build time (import.meta.env).
| Variable | Default | Meaning |
|---|---|---|
VITE_MIDEN_RPC_URL | https://rpc.testnet.miden.io | Miden node RPC for on-chain reads |
VITE_CHAIN_FEE_FAUCET_ID | 0x4cbdcaffe75f0a317482224dae6436 | Faucet of the chain’s fee asset |
VITE_PROTOCOL_FEE_RECIPIENT | (empty) | Expected protocol fee recipient; gates pool creation |
VITE_REGISTRY_ID | (empty) | On-chain pool registry account; empty skips registration |
VITE_REGISTRY_REGISTER_FEE | 0 | Register note consumption fee, in the registry’s fee asset |
VITE_INDEXER_URL | (empty) | Indexer base URL; empty disables browsing and the pool page |
VITE_PARA_API_KEY | (empty) | Para API key; empty keeps the Para option disabled |
VITE_PARA_ENVIRONMENT | BETA | Para environment; PRODUCTION for production builds |
Protocol fee recipient
VITE_PROTOCOL_FEE_RECIPIENT accepts a hex account id or a bech32 address. It
gates pool creation on the create page. The pool’s actual protocol fee
destination is protocol_fee_to in the registry; the create page reads the
registry’s fee policy and builds the pool with the registry’s rate.
Registry
VITE_REGISTRY_ID accepts a hex account id or a bech32 address. When set, the
create flow emits a register note after the pool is deployed. The register note
is a network note consumed by the registry account, so registration is
asynchronous: the wallet confirms emission and the node’s transaction builder
consumes the note in a later block. VITE_REGISTRY_REGISTER_FEE must match the
registry’s note fee schedule.
Indexer
VITE_INDEXER_URL is the application’s pool source. The home page lists every
pool the indexer serves, and the pool page loads its state and configuration
from it. The indexer reads the on-chain registry for the pool set and each pool
account for reserves, token metadata, and the network note fee schedule. An
empty value disables browsing and the pool page.
The browser fetches the indexer cross-origin in development, so the indexer’s
INDEXER_ALLOWED_ORIGINS must list the dev app origin. The shipped indexer
.env.example lists the Vite ports.
RPC
In development the RPC is reached through a same-origin proxy (/rpc.Api,
configured in vite.config.ts), so the browser never sends cross-origin
gRPC-web requests. Production builds talk to the RPC directly. The app targets
the 0.17 testnet.
SDK dependency
The app depends on the published @port0.sh/sdk, pinned to the exact version
the deployment is tested against. The runbook
covers the deployment.
Wallets
The app wires Bread Wallet through @miden-sdk/miden-wallet-adapter-miden. The
wallet picker shows its detection state and opens the install page when the
extension is missing. Para Wallet’s 0.17 packages are installed, and the login
flow is gated behind VITE_PARA_API_KEY: without a configured key the picker
explains the dependency instead of failing later.
Version matrix
| Package | Version |
|---|---|
@miden-sdk/miden-sdk | 0.17.1 |
@miden-sdk/miden-wallet-adapter-base | 0.17.0 |
@miden-sdk/miden-wallet-adapter-miden | 0.17.0 |
@miden-sdk/para | 0.17.0 |
@getpara/web-sdk | 3.18.0 |
@port0.sh/sdk | 0.1.0, pinned exactly |
Wallet menu
The header shows a Login button until a wallet connects. Afterwards it shows the account id. Pressing it opens a menu whose Utils group holds two utilities and whose last item disconnects.
| Utility | What it does |
|---|---|
| One-time mint | Creates a public faucet whose whole supply is minted to the wallet in one transaction, with maxSupply equal to that supply. Nothing can be minted afterwards. |
| Send public note | Sends the chain fee asset through the wallet’s own requestSend with a public note type, the only path that can target an account that does not exist on chain yet. |
Both build their form in a modal, so no page carries a utility it does not need.
Launchpad
The create page can mint a fixed-supply token before pool creation. The faucet
is created with maxSupply equal to the initial supply, and the whole supply is
minted to the creator’s wallet. The launched faucet id can fill either token
field of the pool form.
Funding
A fresh faucet has an empty vault and cannot pay the fee of its own first transaction. The connected wallet sends 0.000266 USDCX to the new faucet, which is exactly the two transaction fees it will pay: consume and mint, at 2 x 133 base units, the measured testnet fee. The faucet’s first transaction consumes that note, which creates the faucet on chain and pays its fee out of the received funds. The supply mint follows as the second transaction.
Validation
| Field | Rule |
|---|---|
| Symbol | 3 to 6 uppercase ASCII letters. The 0.17 protocol rejects digits. |
| Decimals | Integer from 0 to 8 |
| Initial supply | Entered in tokens, bounded by MAX_AMOUNT of 2^57 base units |
The supply bound is the AMM contract’s own limit: amounts are u64 with u128 intermediates and 10,000 bps fee headroom.
Network note fees
Network note fees are denominated in the chain’s fee asset, paid to the chain’s own fee faucet. Since 0.17 the node never runs network transactions for an account whose fee asset differs from the chain’s protocol configuration, so the app reads the faucet from the client instead of asking the user. The creator’s wallet must hold the chain fee asset to pay network note fees when acting on a pool afterwards.
Runbook
How to stand up the stack and deploy a pool. Four repositories take part.
| Repository | Role |
|---|---|
port0_contracts | Pool component, registry component, and note scripts |
port0_sdk | TypeScript SDK: contract artifacts, the registry account builder, and the pool operations |
port0_indexer | Rust indexer: reads the registry and pools over node RPC, serves HTTP |
port0_app | Static web app: browse pools, trade, provide liquidity, create pools |
Testnet version: 0.17.1. Every @miden-sdk/* and miden-node-proto pin matches
it.
Part 1: one-time operator setup
1. Contracts
In the port0_contracts checkout:
cargo miden build --manifest-path contracts/port0/Cargo.toml --release
The SDK’s artifact exporter builds the packages itself, so this step is only needed to seed a fresh checkout.
2. SDK artifacts
In the port0_sdk checkout:
npm install
npm run generate # exports the compiled packages into src/generated
npm run build
3. Pool code commitment
npm run pool:commitment
Prints the accepted pool code commitment, currently
0x51c52cca051456d35329f2add92319f6447f1437a8b716279e536c1c0f6fab09. The value
covers the pool’s full component set: the port0 component, CodeInspection,
the network-account auth, and the fee-policy components. It changes whenever any
of those change, and the registry must be seeded with the value the deployed
pool produces.
4. Registry account
cp .env.example .env # set PORT0_FEE_FAUCET_ID and PORT0_POOL_COMMITMENT
npm run registry:deploy
The tool is interactive. On first run it creates an operator wallet, prints its
bech32 address, and polls until the fee asset arrives. It consumes the incoming
note, then funds the registry with whatever the operator holds above the
transaction fee, capped at PORT0_FUND. Send at least 134 base units to
proceed; 1000 + 133 funds the registry at the default. The tool then builds
the registry with createRegistryAccount, funds it, and deploys it, printing
the registry id. No re-run is needed.
A stored registry is reused only when it accepts the configured pool code commitment and admits both registry note kinds. A registry that predates the registry-collect entry is reported and a fresh registry is deployed instead.
Wait timeout: PORT0_WAIT_SECS (default 600). On timeout it prints the address
and exits. Keys and store: ~/.miden/stores/<PORT0_STORE>/.
5. Indexer
In the port0_indexer checkout:
cp .env.example .env # set PORT0_REGISTRY_ID to the registry id from step 4
cargo run --release
Serves http://127.0.0.1:8080, loopback by default for an API behind a reverse
proxy. GET /status reports the registry id, the last registry block, and the
known and fetched pool counts. Pools appear after the registry poll (default
60 s) and the pool poll (default 15 s).
The API answers cross-origin only for the origins in INDEXER_ALLOWED_ORIGINS.
The shipped .env.example lists the Vite dev ports, so npm run dev at
http://localhost:5173 is admitted. A deployment that serves the app and the
API from one origin needs no entry.
6. App
In the port0_app checkout:
cp .env.example .env # set VITE_REGISTRY_ID and VITE_INDEXER_URL=http://localhost:8080
npm run dev
VITE_REGISTRY_ID enables registration during pool creation.
VITE_INDEXER_URL is the pool source for browsing and the pool page. An empty
value disables both.
Part 2: deploy a pool through the app
Prerequisite: the connected wallet holds USDCX. The wallet pays the pool funding note and the register note fee. The measured per-transaction fee is 133 base units.
- Open
/create.htmland connect the wallet. - Choose the token pair. Either launch a fixed-supply token, which creates a
faucet and funds it with
2 x 133 = 266base units of USDCX, or enter two existing faucet ids. - Choose the LP fee tier: 5, 30, or 100 basis points, whichever the registry lists. The protocol’s cut is a share of that fee, read from the registry and carved out of it, so the swapper pays the LP fee tier and nothing more.
- Submit. The page runs three on-chain steps and reports each.
| Step | What happens | Who pays |
|---|---|---|
| Create pool account | Built locally, not yet on chain. Fees, the P2ID root, and the registry pointer are initial storage; the LP token key is derived by the contract | Nothing |
| Deploy | The wallet sends 133 base units of USDCX to the pool; then the app consumes that note as the pool’s first transaction | Wallet funds; the pool pays its first fee from the received funds |
| Register | Register note via the wallet, sponsored with the registry’s register fee; the node’s transaction builder consumes it against the registry, which verifies the pool code commitment, the registry pointer, the token pair, and the fee values by foreign procedure invocation | Wallet |
- The page redirects to
/pool.html?id=<poolid>. The pool appears on the home page once the indexer has polled the registry and fetched the pool. Until then the pool page shows a spinner and re-checks the indexer every 5 s, so the redirect lands on a page that fills itself in.
If a stage fails, the page keeps the flow state and shows Retry and Restart. Retry resumes at the failed stage; it does not recreate the pool, and the deploy stage reuses an existing funding note instead of sending a second one. Restart clears the state and starts from the pool account.
Part 3: deploy a pool programmatically
The same steps without the UI. The wallet-signed steps can be submitted by the client directly when the account’s key is in the client keystore.
import { MidenClient } from "@miden-sdk/miden-sdk";
import { XykClient } from "@port0.sh/sdk";
const client = await MidenClient.create({
rpcUrl,
feeFaucetId,
proverUrl: "testnet",
autoSync: true,
});
const xyk = XykClient.from(client);
// 1. Build the pool account locally. Fees, the P2ID root, and the registry
// pointer are initial storage; the LP token key is derived by the contract.
const account = await xyk.createPoolAccount({
token0Key,
token1Key,
registryId,
lpFeeBps: 30n,
protocolFeeShareBps,
p2idRoot,
feeFaucetId,
noteFees,
});
const poolId = account.id();
// 2. Fund it, then 3. deploy by consuming the funding note.
const { txId: fundTx } = await client.transactions.send({
account: sender.toString(),
to: poolId.toString(),
token: feeFaucetId,
amount: 133n,
type: "public",
waitForConfirmation: true,
});
await client.syncChain();
const consumable = await client.notes.listAvailable({ account: poolId.toString() });
await xyk.deployPoolAccount(poolId, consumable[0].id().toString(), {
waitForConfirmation: true,
});
// 4. Register. The pool was configured when it was created: fees, the P2ID
// root, and the registry pointer are initial storage, and the LP token key is
// derived by the contract.
await xyk.registerPool(
{ registryId, feeFaucetId, registerFee },
{ sender, poolId, asset0: token0FaucetId, asset1: token1FaucetId },
{ submit: true, waitForConfirmation: true },
);
Failure conditions
| Symptom | Cause and fix |
|---|---|
PORT0_REGISTRY_ID is required | The indexer has no registry id. Set it in .env. |
registry response is missing slot pool_registry::pool_registry::pools | The indexer points at an account that is not the registry. Correct the id. |
| Pool absent from the home page | The indexer is not running, the registry id is wrong, or the register note has not been consumed by the node’s builder yet. Check GET /status. |
Pool code commitment not accepted at registration | The registry was seeded with a stale commitment. Re-run npm run pool:commitment and redeploy the registry, or register a pool built with the accepted component set. |
| Trading disabled on the pool page | The indexer could not read the pool’s fee asset id or fee schedule. Check the pool’s network-account slots. |
funding not received within Ns (balance X/Y) | The operator address was not funded within the wait. Send it fee asset base units and re-run, or raise PORT0_WAIT_SECS. |
SummaryAnchorMismatchError at register | The wallet captured its chain anchor at a block other than the request’s bound block; a block landed between the two syncs. The page syncs the wallet immediately before the build to narrow the window. Retry rebuilds the request at a fresh tip. |
ERR_NETWORK_ACCOUNT_TRANSACTION_HAS_NO_EFFECT | A network-account deploy with no effect. Deploy by consuming a funding note. |
procedure with root digest ... could not be found | The client minor is behind the node. Bump every @miden-sdk/* and miden-node-proto pin to the node’s version. |
Network
port0.sh runs on Miden testnet 0.17.1.
| Fact | Value |
|---|---|
| Protocol and testnet version | 0.17.1 |
| RPC | https://rpc.testnet.miden.io (gRPC, miden.node.v1.NodeService) |
| Remote prover | https://tx-prover.testnet.miden.io |
| Chain fee faucet | 0x4cbdcaffe75f0a317482224dae6436 |
| Chain fee asset | USDCX, 6 decimals |
| Block time | about 3 seconds |
| Measured transaction fee | about 133 base units of USDCX |
Version rule
A client talks only to a node of the same major and minor version. A stale minor
fails with errors that look like application bugs, such as a missing procedure
root, a null pointer passed to Rust, or a missing account header record. Pin
every @miden-sdk/* and miden-node-proto dependency to the exact version the
testnet runs, and bump them together.
| Package | Version |
|---|---|
@miden-sdk/miden-sdk | 0.17.1 |
@miden-sdk/miden-wallet-adapter-base | 0.17.0 |
@miden-sdk/miden-wallet-adapter-miden | 0.17.0 |
@miden-sdk/para | 0.17.0 |
@getpara/web-sdk | 3.18.0 |
@port0.sh/sdk | 0.1.0 |
Guest miden SDK | 0.15.0 |
miden-protocol, miden-tx, miden-standards | 0.17.1 |
| VM crates | 0.35.0 |
Fees and notes
Notes carry a fee, and the executing account pays it from its own vault. A pool’s fee faucet must equal the chain’s fee faucet, or the node never consumes the pool’s notes. USDCX comes from the network faucet. Do not mint it.
Node state window
The node serves account state for the last 50 blocks. Anchors and block numbers older than that fail with a pruned-block error. A network-note-emitting transaction expires 20 blocks after its reference block, which is about one minute, so slow proving can outrun the window.
Numeric bound
Every pool amount, reserve, and lp_supply is bounded by MAX_AMOUNT = 2^57.
The bound keeps the u128 intermediates inside u128::MAX.
Errors
Protocol and integration errors seen in operation, with the cause and the fix.
| Symptom | Cause | Fix |
|---|---|---|
null pointer passed to rust | A main-thread WASM object passed to the worker client | Pass hex strings into every client method |
expected instance of X or e | Two WASM copies in the bundle, or a cross-instance object | Force one copy with Vite resolve.dedupe; build all objects from one module |
procedure with root digest ... could not be found | The client minor is behind the node | Bump every @miden-sdk/* and miden-node-proto pin to the node’s version |
no account header record found for given ID | The account is not in the local store, or the handle is stale | Import the account; pass strings |
could not interpret input as a bech32-encoded account id ... invalid checksum | A bare hex id failed the hex test and was parsed as bech32 | Test with /^(?:0x)?[0-9a-f]+$/i, or prefix 0x |
| Empty-vault assertion on a faucet mint or deploy | A fresh account pays its own fee | Fund with a note and consume it as the first transaction |
ERR_NETWORK_ACCOUNT_TRANSACTION_HAS_NO_EFFECT | An empty scriptless deploy of a network account | Deploy by consuming a funding note |
advice stack read failed on a multisig | A two-word fee-conversion preimage where three words are read | Build the three-word MultisigAuthArgs |
account's ... component reuses the fee conversion salt ... | No salt declared for a multisig | Declare a fresh salt, or the full MultisigAuthArgs |
storage root not found ... slot ... block | Declaring specific storage slots on a public foreign account | Use empty storage requirements; a public target resolves lazily |
SummaryAnchorMismatchError ... | The wallet captures a chain anchor at its own sync height while a 0.17 multisig summary binds the request’s bound block | Sync the wallet immediately before the build. Retry rebuilds the request at a fresh tip |
private account ... cannot be retrieved | Importing a private account | Private accounts are not importable |
invalid amount: 250,000 | An amount parser rejects grouping commas while a formatter emits them | Strip commas in the parser |
Cannot access 'x' before initialization | A render helper runs before a const or let it reads is initialized | Declare the variable above the first call |
Cannot read properties of undefined (reading 'includes') | Module code runs before a const declared later | Move the bootstrap call to the end of the module |
Rule
A protocol rejection names a violated invariant. Resubmitting until the error stops hides the cause and turns a deterministic defect into an intermittent one. Change the code so the invariant holds on the first attempt.
Repositories
Radicle is the canonical source. GitHub is the mirror. Each repository README states the canonical source and links back to it.
Contracts
| Source | Location |
|---|---|
| Radicle | https://radicle.network/nodes/iris.radicle.network/rad%3Az2byaq213P9X1TgSh9N1b4dCG73VE |
| GitHub | https://github.com/port0-sh/port0_contracts |
SDK
| Source | Location |
|---|---|
| Radicle | https://radicle.network/nodes/iris.radicle.network/rad%3Az3qSaS569gAZiHRtjiAuxvrCWzhgp |
| GitHub | https://github.com/port0-sh/port0_sdk |
| npm | https://www.npmjs.com/package/@port0.sh/sdk |
App
| Source | Location |
|---|---|
| Radicle | https://radicle.network/nodes/iris.radicle.network/rad%3Az3fi1mouSisLs3PhaLxJiUZwhZnLd |
| GitHub | https://github.com/port0-sh/port0_app |
Indexer
| Source | Location |
|---|---|
| Radicle | https://radicle.network/nodes/iris.radicle.network/rad%3Azf39RzWpXwMZ43w5818EPy5jbg7M |
| GitHub | https://github.com/port0-sh/port0_indexer |
Book
The source of this book is the port0_book repository. It is built with mdBook
and served at https://docs.port0.sh.
Contact
| Channel | Location |
|---|---|
| Site | https://port0.sh |
| GitHub | https://github.com/port0-sh |
| X | @port0_sh |
| Telegram | t.me/port0_sh |
| hello@port0.sh |
General inquiries and vulnerability reports both go to hello@port0.sh.
The book points to the repositories for the source of truth. A claim that exists only on a social channel has no durable evidence.