Skip to content
LogoLogo

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, M1CO for MICO), 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:

SectionCoinsShown
mainARRK, verified, added, and unchecked (no issuer service configured or reachable)always
unverifiedthe issuer service is reachable but does not vouch for the coincollapsed, dimmed
hiddenhidden or reported by the usercollapsed

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):

  1. 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_until is 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 an offer envelope. Nothing is signed.
  2. 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 accept envelope.
  3. 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…"}}
FieldMeaning
v1
kindoffer (unsigned, flags = 3) or accept (signed, flags = 0)
networkmainnet, testnet or regtest; MUST match the PST's magic
expiresunix seconds; wallets refuse to take or complete after it (valid_until is the chain's limit)
give, wantthe maker's terms: {asset: 64 lowercase hex, amount: base units as a decimal string}
pstbase64 PST
memooptional, ≤ 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)

StepCall
maker: createcreateSwapOffer({ give, want, expiresInMinutes, memo, post, autoComplete }) → record with offer (and board when listed)
anyone: readinspectSwap(text) → terms, role, problems (expired, maker coins spent), warnings
takerprepareSwapTake(text) → preview; confirmSwapTake(id) → record with acceptance
makerprepareSwapComplete(acceptance) → preview; confirmSwapComplete(id) → txid
eitherswaps(), 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.