Swap offers and coin lists in wallets
How Arrekta wallets hold many coins safely, and how two people swap coins without an intermediary.
Implemented in both SDKs (swapOffer.ts, swap_offer.py), @arrekta/wallet-core and the desktop,
mobile and extension wallets.
1. What other wallets do (research)
- One wallet, many coins. No major wallet ships an app per coin. Phantom hides tokens it judges to be spam automatically (third-party signals, ML models, user reports; any fungible token with a URL in its name), lets the user unhide or report them, and warns that unhiding does not make a token safe (Phantom: flagged as spam, token security signals).
- Opt-in chains. Stellar and XRPL require a trustline before an account can hold a token, and each Stellar trustline locks 0.5 XLM, which makes spam costly (Stellar wallets). Xaman's "+Add" offers a vetted list (Xaman).
- In-wallet swaps are standard and earn their wallets a fee: MetaMask 0.875 %, Phantom 0.85 % (comparison); Xaman routes through the XRPL DEX and AMM for 0.8 % and refuses swaps far below the spot price (Xaman 4.0). All of them need a market (liquidity) behind the button.
- Channels in the most successful Lightning wallet are invisible: Phoenix keeps one channel per user and resizes it by splicing (The Block). Running that service is regulated risk: Phoenix left the US in May 2024 over whether Lightning service providers are money transmitters (report).
What we do differently:
- Arrekta has no opt-in (anyone can pay any coin to any address), so the wallet curates.
Tickers are unique on chain (PROTOCOL.md §7.1), so the impersonation risk is a look-alike
ticker (
MIC0,M1COforMICO), which the wallet detects. - "Verified" needs no gatekeeper: a coin is verified when it is registered with the issuer service and its public log (hash-chained, ML-DSA-signed) verifies under the issuer-service key the wallet pinned on first use.
- Swaps are peer to peer with no fee: two parties, one transaction, both legs or neither. The market board lists offers for discovery; pools come later, once there is liquidity.
- Channels stay an Advanced feature (the user's own channel node). No hosted channel service.
2. Coin lists
Wallet.coinList() sorts every coin the account holds (plus coins the user added) into:
| Section | Coins | Shown |
|---|---|---|
| main | ARRK, verified, added, and unchecked (no issuer service configured or reachable) | always |
| unverified | the issuer service is reachable but does not vouch for the coin | collapsed, dimmed |
| hidden | hidden or reported by the user | collapsed |
Each coin carries lookAlikes: other known coins (held, added or registered) whose
tickerSkeleton is equal. The skeleton folds characters that read alike (0/O/Q, 1/I/L, 5/S, 8/B,
2/Z, 6/9/G, U/V, RN/M, VV/W), after the idea of Unicode TR39 confusable skeletons applied to the
ticker alphabet. The genuine coin only gets a muted "imitated by" note; the impostor gets the warning.
Sends and swaps add preview warnings: unverified-asset (caution) and lookalike-ticker
(danger; the apps require an explicit acknowledgement before signing).
Settings: setIssuerServiceUrl(network, url) (a gateway; /issuer/v1/public/* is anonymous).
Defaults: regtest http://127.0.0.1:3000, mainnet and testnet off until a public gateway exists.
A changed issuer-service key is refused (coins fall back to "unchecked") until the user resets it.
3. Swap offers
Protocol
Built only from the PST format (PROTOCOL.md §16; roles in §16.3):
- Maker builds a version-2 transaction: its inputs (the coin it gives), an output paying the
coin it wants to its own address, and its change.
valid_untilis set to the offer's expiry height, so the swap can never be mined later. It wraps it in a PST with both construction flags set and shares it as anofferenvelope. Nothing is signed. - Taker appends inputs paying what the maker wants (plus the network fee) and an output taking
what the maker gives, signs its own inputs (signing closes construction, §16.2 rule 8), and
returns an
acceptenvelope. - Maker checks
swapAcceptanceMatches(offer, accepted)(every offer input and output in place and unchanged, no issuer actions added), checks its preview's net change is exactly −give/+want, signs, finalizes and broadcasts.
Signatures commit to the whole transaction (§6.4), so neither side can change the deal after the other signs. The maker signs last, so an offer never executes without the maker's final approval, and withdrawing an offer needs no transaction. When the maker gives ARRK, the fee comes out of it and the taker needs no ARRK of its own.
Envelope
arrektaswap: + base64url (no padding) of canonical JSON (sorted keys, no spaces, UTF-8):
{"expires":1900000000,"give":{"amount":"10","asset":"4747…"},"kind":"offer","memo":"10 GOLD for 500 ARRK",
"network":"regtest","pst":"ZHBzdP8B…","v":1,"want":{"amount":"500","asset":"0000…"}}| Field | Meaning |
|---|---|
v | 1 |
kind | offer (unsigned, flags = 3) or accept (signed, flags = 0) |
network | mainnet, testnet or regtest; MUST match the PST's magic |
expires | unix seconds; wallets refuse to take or complete after it (valid_until is the chain's limit) |
give, want | the maker's terms: {asset: 64 lowercase hex, amount: base units as a decimal string} |
pst | base64 PST |
memo | optional, ≤ 140 characters |
For an offer, give/want MUST equal swapOfferTerms(pst): the one asset leaving the maker's
inputs and the one its outputs ask for; every other asset must balance. Errors: swap-decode,
swap-version, swap-network, swap-terms, swap-expired. Fixture shared by both SDKs:
sdk/fixtures/swap_offers.json.
An offer (no signatures, no public keys: signing fills them in) fits one QR code. An acceptance
carries the taker's ML-DSA-65 signature and public key (about 5 KB) and travels as a link, a file,
or an animated QR (UR type arrekta-swap).
Wallet API (@arrekta/wallet-core)
| Step | Call |
|---|---|
| maker: create | createSwapOffer({ give, want, expiresInMinutes, memo, post, autoComplete }) → record with offer (and board when listed) |
| anyone: read | inspectSwap(text) → terms, role, problems (expired, maker coins spent), warnings |
| taker | prepareSwapTake(text) → preview; confirmSwapTake(id) → record with acceptance |
| maker | prepareSwapComplete(acceptance) → preview; confirmSwapComplete(id) → txid |
| either | swaps(), cancelSwap(id) |
Coins behind an open offer (maker) or an unanswered acceptance (taker) are reserved: ordinary sends do not spend them until the swap completes, is cancelled or expires.
Market board
Offers can be listed on a public board for discovery and price: MARKET_BOARD.md.
Later
- Exchange listings for ARRK, for a dollar value. (Instant swaps against liquidity: POOLS.md.)
- Cross-chain swaps with Bitcoin and Ethereum (the SDKs' HTLC helpers,
docs/INTEROP.md) in the desktop wallet first: they need a counterparty online and the app open until refund deadlines.