Seedelf-Wallet

seedelf-wasm

The WebAssembly bindings the web wallet’s worker uses for Seedelf cryptography, and to build, read and sign its transactions. Every protocol rule is enforced in seedelf-crypto and every transaction is built by seedelf-core: this crate adapts them for JavaScript, reads the transactions others build (the dApp connector, the transaction view), and keeps every private key inside WebAssembly.

API

The arguments named …Json are JSON text (the extension passes JSON.stringify(request)), and so is every structure an export answers with but the Register and Proof objects: the extension parses it. Amounts are lovelace or token quantities as decimal strings, and tokens are [{ policyId, assetName, quantity }], hex.

Export What it does
SeedelfKey Holds the secret scalar inside WebAssembly memory.
SeedelfKey.fromPhrase(phrase, account) The wallet’s key from a 12-, 15- or 24-word phrase, using the frozen v1 derivation (seedelf-crypto::derivation). The wallet always uses account 0: one private balance for the whole phrase. Throws with a reason on an invalid phrase.
SeedelfKey.fromEntropy(entropy, account) The same key from the phrase’s BIP39 entropy, as the vault stores it. The key comes straight from the entropy: the phrase isn’t written out, in WebAssembly or in JavaScript.
SeedelfKey.random(), SeedelfKey.fromHex() Dev/test constructors only.
key.baseRegister() Returns the base register (G1, G1^x).
key.isOwned(register) Whether this key can spend a UTxO with this register.
key.createProof(register, vkh) Returns a Proof, { z, gR }, a Schnorr proof bound to the one-time key hash vkh (28 bytes, hex).
key.free() Drops the key and overwrites the scalar.
generatePhrase() A new 24-word recovery phrase from the secure random source.
validatePhrase(phrase) Accepts 12, 15 or 24 words, the lengths Lace accepts. Throws with a user-facing reason (word count, unknown word N, checksum). Case and extra whitespace are ignored.
phraseToEntropy(phrase), entropyToPhrase(entropy) Phrase ↔ BIP39 entropy (16, 20 or 32 bytes), with the same rules as validatePhrase. The vault stores entropy, not words. These, validatePhrase and the fromPhrase and fromEntropy constructors wipe WebAssembly’s copies of what they’re given and return.
bip39Wordlist() The 2048 BIP39 English words, for autocomplete.
CardanoAccount.fromPhrase(phrase, account) A public account: standard CIP-1852 keys (m/1852'/1815'/account'), the same as Lace, Eternl and Yoroi. The wallet’s public accounts are any CIP-1852 index but 24301', the one-time accounts’ (chunk 18); the Seedelf key stays account 0’s. The private keys stay in WebAssembly memory; free() drops them.
CardanoAccount.fromEntropy(entropy, account) The same account from vault entropy.
account.receiveAddress(network, index), account.changeAddress(network, index) Base addresses 0/index and 1/index, delegated to the staking key 2/0.
account.stakeAddress(network), account.accountPublicKey() The reward address, and the account xpub (hex).
account.paymentKeyHash(role, index) The payment key hash at role/index (0 receive, 1 change), hex: the payment credential of every address made from that key, whatever its staking part. Koios is asked for the account’s UTxOs under these.
account.stakePublicKey() The stake key 2/0’s Ed25519 public key, hex: what CIP-95’s getRegisteredPubStakeKeys and getUnregisteredPubStakeKeys give.
account.drepOf() The account’s own DRep (CIP-105’s key 3/0): { id, hash, publicKey }, its CIP-129 ID (drep1…), key hash and public key, hex. The public key is what CIP-95’s getPubDRepKey gives. Never the private key.
account.isOwnAddress(address, keys) Whether an address is the account’s own: under one of its payment keys, whatever its staking part (the first 20 of each chain, and keys, optional, hex key hashes as the last balance reading found them), or carrying its staking key, as every address a normal wallet shows for it does. An unreadable address isn’t.
Network.Preprod, Network.Mainnet The network for addresses.
OneTimeAccounts.fromPhrase(phrase), OneTimeAccounts.fromEntropy(entropy) Private sessions’ one-time accounts: CIP-1852 account 24301', session i with its own payment key 0/i and stake key 2/i. A separate type from CardanoAccount, so nothing that pays or shows the public account can be handed these keys. They never leave WebAssembly; free() drops them.
accounts.address(network, i), accounts.rewardAddress(network, i), accounts.keyHash(i) Session i’s address (its own payment and stake keys), its reward address (stake key 2/i, which the wallet never registers; a site connected to the session is given it), and its payment key hash, hex, which Koios is asked for its UTxOs under.
accounts.sharedStakeAddress(network, i) Session i’s address as sessions started before chunk 15b’s fix had it, with the shared Seedelf staking part: kept for what’s still at those.
Register { generator, publicValue }: compressed G1 points in hex. new Register(generator, publicValue) wraps two points and checks nothing; see isValidRegister.
rerandomize(register) Returns (g^d, u^d) with a fresh d that is thrown away.
isValidRegister(register) On-curve and torsion-free check.
registerToDatum(register) Inline-datum bytes (PlutusData CBOR).
verifyProof(register, z, gR, vkh) Off-chain mirror of the validator’s check.
buildMoveIn(account, key, requestJson) Builds and signs a move-in with seedelf-core’s build::move_in. The request carries network, Koios’s epoch_params row (params), the account’s UTxOs (utxos), each { utxo, role, index } with its role/index checked against the derived address, lovelace and tokens. Returns { txCbor, txHash, fee, lovelace, minimum, tokens, depositOutputs, withdrawal, changeLovelace, changeTokens, inputs, leftOut }. lovelace below what the deposit needs is raised to it (so "0" with tokens moves only that), and minimum says what that is; null is Max, with no minimum, and leftOut lists the UTxOs Max leaves where they are, and why. Keys never reach JavaScript.
buildAccountSend(account, requestJson) Builds and signs a payment from the Cardano account with build::account_send_many. The request is a move-in’s with payments in place of lovelace and tokens: one to 20, each { to, recipient, lovelace, tokens }, where to is a bech32 key address on this network or a Seedelf’s full name, and recipient, for a Seedelf, the contract UTxO holding it, as Koios returns it. Each amount is raised to its minimum as a move-in’s is; null is Max, for a single recipient only. An optional note is CIP-20’s message, which anyone can read: one line, 64 characters at most. Returns { txCbor, txHash, payments: [{ to, lovelace, minimum, tokens }], max, fee, withdrawal, note, changeLovelace, changeTokens, inputs, leftOut }.
withdrawal in a move-in’s, a send’s or an account mint’s request Optional: the stake key’s whole reward balance (lovelace, a decimal string), withdrawn along with the payment, with the stake key 2/0 signing too. The extension passes it only when the user spends rewards and the vote is delegated. The result’s withdrawal says what was withdrawn ("0" for none).
invalidHereafter in a move-in’s, a send’s, a staking transaction’s or an account mint’s request Optional: the slot the transaction stops being valid at (slotAt). Past it, one that never landed can’t land any more, so paying again can’t pay twice. An account mint’s draft holds it too, so its finish must be given the same slot. Without it, the transaction stays valid for as long as its inputs are unspent, byte for byte as before.
slotAt(network, unixMs) The slot a time (milliseconds since 1970, as Date.now() gives) falls in on a network, from its Shelley start: what invalidHereafter is counted in. Throws for a time that isn’t one.
buildStaking(account, requestJson) Builds and signs a staking transaction with build::account_staking (chunk 13; the DRep’s own actions, chunk 21). The request carries network, the epoch_params row, the account’s UTxOs (as for a move-in), action and state, the stake key’s standing from Koios’s account_info: { registered, deposit, rewards, drep }. action is { kind: "delegate", pool }, { kind: "vote", drep }, { kind: "withdraw" } or { kind: "stop" }; or, for the account’s own DRep, { kind: "drep-register", delegate, anchor } (with delegate, the account’s own vote goes to it too), { kind: "drep-update", anchor }, { kind: "drep-retire" } or { kind: "drep-vote", votes: [{ txHash, index, vote }] } (vote is yes, no or abstain), with drep, the DRep’s standing from Koios’s drep_info: { registered, deposit }. An anchor, optional, is { url, hash }. Signed with the spent UTxOs’ payment keys, and with the stake key and the DRep key 3/0 where its certificates or votes need them. Returns { txCbor, txHash, action, pool, drep, fee, deposit, drepDeposit, refund, withdrawal, changeLovelace, changeTokens, inputs }, drepDeposit (the DRep’s part of deposit) for a DRep action only.
poolId(id), drepId(id) A pool ID (bech32 or hex) as pool1…; a vote delegation as Koios names it (a DRep’s CIP-129 ID, from CIP-129 or CIP-105, or drep_always_abstain / drep_always_no_confidence). Each throws the reason to show the user.
drepProfile(requestJson) A DRep’s profile as CIP-119 metadata: { givenName, objectives, motivations, qualifications, doNotList } in (a name is required, 80 characters at most; each text at most 1,000), { file, hash } out: the file to publish exactly as it is, and its blake2b-256 hash, which the registration’s anchor carries. The wallet never publishes or fetches it. Throws the reason to show the user.
buildMint(key, requestJson) Creating a Seedelf from the private balance (build::mint_apart), in one step. The request carries network, the epoch_params row, the wallet’s spendable contract UTxOs (each checked: owned, no Seedelf, no reference script), the label (printable ASCII, 15 at most) and classes. Picks the UTxOs, proves them under a new one-time key, measures the scripts with the wallet’s own evaluator (Aiken’s uplc) and finishes the transaction: no draft carrying proofs goes to Koios. Returns the unsigned transaction and { txCbor, txHash, seed, tokenName, lovelace, fee: { size, compute, scriptReference, total }, changeLovelace, changeTokens, changeOutputs, inputs, classesMixed }.
draftAccountMint(account, key, requestJson) Creating a Seedelf paid by the Cardano account (build::account_mint), step 1. The request carries network, the epoch_params row, the account’s UTxOs (each with its role/index, checked as for a move-in), the label, and optionally collateral, the account’s collateral with its path: put up, never spent (without one, the mint puts up one of utxos). Picks the inputs and the collateral, and returns { draftCbor, inputs, collateral } for Ogmios.
finishAccountMint(account, key, requestJson) Step 2: the same request plus Ogmios’s evaluation. Returns the transaction signed with every input’s key and the collateral’s, ready to submit, and { txCbor, txHash, tokenName, lovelace, fee, withdrawal, changeLovelace, changeTokens, changeOutputs, inputs, collateral }.
buildTransfer(key, requestJson) Paying Seedelfs from the private balance (build::transfer_apart), in one step. The request carries network, the epoch_params row, the spendable contract UTxOs (checked as for a mint), classes, and payments: one to 20, each { to, recipient, lovelace, tokens }, where to is the Seedelf’s full name, recipient the contract UTxO holding it, as Koios returns it, and lovelace is raised to what the payment needs, if less. Checks each recipient’s UTxO and register, picks the UTxOs, proves them under a new one-time key, and measures and finishes it in the wallet, as buildMint does. Returns the unsigned transaction and { txCbor, txHash, seed, payments: [{ to, toSelf, lovelace, minimum, tokens }], fee, changeLovelace, changeTokens, changeOutputs, inputs, classesMixed }. toSelf says the Seedelf is this wallet’s own; paying it is allowed.
buildWithdraw(key, requestJson) Paying addresses from the private balance (build::sweep_many_apart, or sweep_all for Max), in one step. The request carries network, the epoch_params row, the spendable contract UTxOs (checked as for a mint), classes, and payments: one to 20, each { to, lovelace, tokens }, to a bech32 key address on this network and lovelace raised to what the payment needs, if less. null is Max, for a single address only: up to the 20 largest UTxOs, with every token. An optional funding ({ session }, the session’s own class for a top-up) marks a private session’s funding or top-up rather than a payment, which changes what is picked first. Measured and finished in the wallet. Returns the unsigned transaction and { txCbor, txHash, seed, payments: [{ to, lovelace, minimum, tokens }], max, fee, changeLovelace, changeTokens, changeOutputs, inputs, left, classesMixed }: what each address receives, what comes back, and how many UTxOs Max left.
buildRemove(key, requestJson) Removing a Seedelf (build::remove), in one step. The request carries network, the epoch_params row, the Seedelf’s utxo (checked: this wallet’s, holding exactly one Seedelf) and to (a key address, or null for the private balance). Measured and finished in the wallet. Returns the unsigned transaction and { txCbor, txHash, seed, name, to, lovelace, fee, inputs }, where lovelace is what comes back.
classes in a Seedelf mint’s, transfer’s or withdrawal’s request Optional: each spendable UTxO’s history, by txHash#index: { id, origin }, where origin is own, received, session, lovejoin or unknown. Selection keeps different histories apart where it can (build::Histories); a stealth mint takes received money first. The result’s classesMixed names the ones it spends together, when it has to.
checkPayableAddress(address, network) Throws the reason a withdrawal or a send can’t pay an address: not an address, a script, a stake address, the other network.
signScriptSpend(key, requestJson) At Send: { txCbor, seed, collateral }, where collateral is giveme.my’s answer. Refuses a transaction that wasn’t built with that seed’s one-time key, checks giveme.my’s signature against its public key over the transaction id, then adds it and the one-time key’s. Returns { txCbor, txHash }.
buildSessionReturn(accounts, key, requestJson) A private session’s return: everything at session index’s one-time account into the private balance, built and signed with its key. The request: { network, params, index, utxos, merge, own }, utxos every UTxO at the account, merge (optional) the funding’s Seedelf change to merge into, and own (optional) the session’s own transactions by hash, whose outputs come back first. Returns { txCbor, txHash, fee, lovelace, tokens, depositOutputs, inputs, merged, leftOut }, leftOut being what waits for a later return.
readDexOrder(requestJson) Whether an output at a contract is a DEX order the wallet can cancel itself, and the session’s own (chunk 24, seedelf_core::orders over the pinned orders.json): what the order check (checkOrder), a cancel’s signer and the look for what a Splash order’s partial fill left ask. { network, address, datum, session, inline, scriptRef } in: the output’s address bytes and its datum’s CBOR (inline, or the bytes its hash names), hex; the session’s bech32 address, a base address of its own payment and stake keys on this network (anything else throws); and, optional (false when left out), whether the output holds its datum inline and whether it carries a script. { known, protocol, ours, stakeSigns, reference, why } out. known: it sits at an order script orders.json pins on this network (otherwise the rest is empty). ours: cancelled by the session’s own key (its stake key’s for SundaeSwap V3 and Stableswaps), paying the session alone, and in a form its script can spend: never one carrying a script, nor a Plutus V1 order holding its datum inline. stakeSigns: its cancel needs the stake key 2/i too. reference: the hash#index its script is read from, when by reference. why: why it can never be cancelled, or why its datum couldn’t be read (one nested past 128 levels isn’t decoded at all). Takes no keys.
buildOrderCancel(requestJson) Stop’s cancel of a session’s DEX orders, built in the wallet (seedelf_core::orders::cancel_orders), never asked of Minswap. { network, params, session, orders: [{ utxo, datum }], funds, collateral, references } in: each order a Koios UTxO row with its datum’s CBOR, hex; funds, the session’s own UTxOs that help pay the fee (none with a datum or a script), its collateral among them or not; collateral, one of its UTxOs of ADA alone; references, Koios’s rows for the reference UTxOs the orders’ scripts are read from, spent or not (a spent one’s script, checked against its pinned hash, is carried in the transaction instead). A fund or collateral not at the session’s payment key is refused, and so is an order that isn’t the session’s at a pinned script, or in a form its script can’t spend. It cancels the first order and those of its Plutus version that can share its transaction (a Splash order goes alone, its exact refund first), everything going back to the session’s address. The fee comes from the orders’ ADA and the funds, and from the collateral only when they can’t pay it and a change output; a collateral spent so leaves an output of exactly 5 ADA behind where what’s left allows. The collateral covers 1.5 times the fee and must leave the least an output holds. Measured in the wallet with the V1, V2 and V3 cost models, its script data hash recomputed from its own witness bytes; an order script that refuses it is named, with its order. Returns it unsigned: { txCbor, txHash, fee, covers, stakeSigns }, covers the orders it cancels (hash#index; the next cancel takes the rest), stakeSigns that the session’s stake key signs too (signSessionTx).
checkOrderCancels(requestJson) Before a session signs a swap, the dry run of Stop’s cancel of each order it places (seedelf_core::orders::check_cancels): each order alone, built as buildOrderCancel builds it once the swap is on chain, with stand-in funds and collateral of ADA alone at the session’s address, and run through the order’s real script in the wallet’s evaluator. { network, params, session, txCbor, orders, references } in: the swap as built (neither signed nor on chain), hex; orders, its order outputs’ indexes (the order check’s); references, as buildOrderCancel takes them (a Plutus V1 order needs none). { orders: [{ index, order, protocol, cancels, fee, why }] } out, one for each index asked, in order: order its hash#index once the swap is on chain; protocol the DEX whose script it sits at (null when it’s none the wallet knows); cancels whether its cancel built and its script ran it; fee that cancel’s fee (null when it didn’t build); why why it can’t be cancelled (null when it can): a datum its script can’t decode, its datum inline at a Plutus V1 script, a script on it, an output that isn’t an order, a reference row missing. A swap that can’t be read, or a session address that isn’t one, throws. Takes no keys. The worker’s inspect calls it for every swap that places orders (cancelsChecked), the runner’s and Review it myself’s alike.
inspectSessionTx(accounts, requestJson), signSessionTx(accounts, requestJson) inspectDappTx and signDappTx for a transaction built for a session (a swap, a cancel), on its one-time account: keys is the session’s one path 0/i, and stakeIndex is i. Never governance.
sessionDataSigner(accounts, requestJson), signSessionData(accounts, requestJson) dataSigner and signDappData for a site connected to a session (private CIP-30), with keys and stakeIndex as above.
attachWitnesses(txCbor, witnessSet) A transaction someone else built (a session’s swap) with the wallet’s signatures (witnessSet, as signSessionTx gives them) added, ready to submit. The body and the builder’s witnesses are kept byte for byte.
planLovejoin(accounts, requestJson) Lovejoin (chunk 16): how many boxes a session’s spare ADA pays for, before anything is built. { network, index, utxos, collateral, depth, again } in, { boxes, spare, mixes, mixFees } out.
lovejoinFunding(requestJson) What mixing boxes boxes at depth takes, before anything is built: { network, boxes, depth, again } in, { lovelace, mixes, mixFees } out, lovelace being what a one-time account is funded with (its collateral besides).
buildLovejoinChain(accounts, key, requestJson) A session’s whole chain through Lovejoin, each transaction built, measured against the scripts and signed with its key: the deposit, the mixes, then the return; with again, the wallet’s boxes in the pool mixed again, with no deposit. The request: { network, params, index, utxos, collateral, pool, depth, boxes, merge, again, own }, pool the boxes at mix_box. Returns { txs: [{ kind, txCbor, txHash, fee }], boxes, depth, fees, returned, tokens, merged, leaves, leftOut, skipped }, txs in the order the worker sends them. A pool with too few boxes builds nothing and says why in skipped.
buildLovejoinFromAccount(account, key, requestJson), buildLovejoinAgainFromAccount(account, key, requestJson) Boxes from the public account straight into Lovejoin, the deposit and every mix; or the wallet’s boxes in the pool mixed again, paid by the public account, with no deposit. Built, measured and signed with the account’s keys. The request: { network, params, utxos, collateral, pool, depth, boxes }, utxos and collateral with their paths, as for a move-in. Returns a chain as buildLovejoinChain does, with no return: returned is the change that stays in the account.
lovejoinOwned(key, requestJson) The wallet’s boxes among the pool’s rows: { network, pool } in, { boxes, lovelace, others, otherBoxes } out, others being how many boxes a mix may take.
buildLovejoinWithdraw(key, requestJson), finishLovejoinWithdraw(requestJson) One of the wallet’s boxes into a fresh register: { network, params, pool, boxRef } (the first of the wallet’s boxes when boxRef is absent) in, the unsigned transaction and { txCbor, txHash, fee, lovelace, boxRef } out, for giveme.my. finishLovejoinWithdraw then takes { txCbor, collateral }, collateral being giveme.my’s answer, checks its signature against giveme.my’s key, adds it, and returns { txCbor, txHash }, ready to submit.
ogmiosUtxos(txCbor) A transaction’s outputs as Ogmios v6 UTxOs (JSON), for evaluateTransaction’s additionalUtxo: the network can then measure a child of it before it’s on chain.
declaredCovers(txCbor, answerJson) Whether the budgets a transaction declares cover what Ogmios measured (answerJson, its evaluateTransaction answer): { covers, reason }, the reason when they don’t, a failed script included. The extension checks a Lovejoin chain’s first mix with it.
cip30Utxos(rowsJson) The dApp connector (chunk 15, src/cip30.rs): Koios UTxO rows as CIP-30’s TransactionUnspentOutputs, hex each. The output is the legacy array unless it holds an inline datum.
cip30Value(lovelace, tokensJson), cip30ReadValue(hex) A balance as CIP-30’s Value (canonical order), and a Value a dApp asks for read back as { lovelace, tokens }, refused past 1,000 policies and tokens together.
cip30Address(bech32) An address as CIP-30 hands it over: its bytes, hex.
inspectDappTx(account, requestJson) What a dApp’s transaction does to the public account, for the signing prompt. The request: { network, txCbor, keys: [{ role, index }], inputs, partialSign, stakeDeposit, governance }, where inputs are the UTxOs it spends as far as the extension found them (Koios rows), stakeDeposit (optional) is what the stake key paid to register (Koios’s account_info.deposit), which an old deregistration certificate doesn’t say it returns, and governance says the site was given CIP-95, so the DRep key 3/0 signs its DRep certificates and votes. Returns the net change, who’s paid, the fee, the collateral at risk, minting, certificates (each with the stake key it names, credential, the account’s own or anyone’s: a registration’s, a stop’s or a delegation’s, alone or together, and a stake pool’s registration’s reward account’s; none for a script’s, a pool’s retirement, a DRep’s or the committee’s; 1.5.0’s cross-area review, X05, and 1.5.0’s last review, L1), withdrawals, votes (and how many are the account’s DRep’s), a CIP-20 note, which keys sign, unknown inputs, and whether the account’s signatures complete it. Throws for the other network, a collateral return to someone else, a transaction marked to fail, one that makes the stake key a pool’s owner, an old deregistration without stakeDeposit, or a payment key’s signature over an input it can’t find.
signDappTx(account, requestJson) The same request, signed with every key of the account’s it needs: { witnessSet, summary }, the vkey witnesses only, in the transaction’s set encoding. Refuses without partialSign when anyone else must sign too.
dataSigner(account, requestJson), signDappData(account, requestJson) CIP-8 for CIP-30’s signData: { network, keys, address, payload, governance } (the address in hex or bech32; with governance, it may name the account’s DRep, by its ID or its key’s enterprise address). dataSigner says which key signs, { address, key, role, index } with key payment (then its role and index), stake or drep, or null for an address that isn’t the account’s; signDappData returns { signature, key }, a COSE_Sign1 with the address in its protected header and the COSE_Key, hex.
decodeTx(network, txCbor) Everything in a transaction’s bytes, as JSON, for the transaction view (chunk 17, src/decode.rs): inputs, outputs, datums, scripts, certificates, votes, redeemers and metadata. It reads no account and asks nothing of the network; network only names Seedelf Wallet’s own contract. Every amount, slot and budget is a decimal string. Throws in words for bytes it can’t read.
start(), __wbg_reset_state() The module’s start function, which does nothing, and wasm-bindgen’s reset (build.sh passes --experimental-reset-state-function). The extension replaces an instance that trapped with the reset (extension/src/background/wasm.ts), which ends by calling start.

The one-time key of a script spend is HKDF-SHA-256 of the Seedelf scalar (salt seedelf-one-time-key-v1, info the 32-byte seed). The seed can wait in JavaScript between review and Send; the key is re-derived inside WebAssembly each time and never leaves it.

Build

./build.sh    # → pkg/seedelf_wasm.js, pkg/seedelf_wasm_bg.wasm, .d.ts

Requirements:

The output is an ES module (--target web). Load it with init() or initSync().

The build is reproducible: it takes only the crates Cargo.lock records (--locked), and no path on the machine goes into the module, so the same commit and toolchain (the pinned Rust, wasm-bindgen, and clang) give the same bytes (launch review #61). Nothing pins clang, which compiles blst’s C, so the script’s last line names it (C compiler: …) for the release record.

It’s built for size with the workspace’s wasm-release profile (opt-level = "z", LTO, one codegen unit, stripped). The CLI keeps the plain release profile. node bench.mjs [pkg-dir] prints a build’s size (raw, gzip, brotli), how long the module takes to compile, and the median time of a key derivation, the ownership check, a re-randomization, a proof, a transfer (buildTransfer) and a move-in.

Build Raw gzip Proof Transfer
release 7,100 KB 1,474 KB 1.8 ms 51 ms
wasm-release 2,632 KB 797 KB 1.8 ms 53 ms
wasm-release + wasm-opt -Oz (not used; 2026-09-24) 1,113 KB 448 KB 1.8 ms 54 ms (a draft)

Measured 2026-10-04 in Node 24.19.0’s V8, the engine Chrome runs the worker in: the median of eight bench.mjs runs. release is build.sh’s build with --profile release in place of wasm-release. The transfer is buildTransfer, which since the crypto review (2026-09-25) proves the inputs and measures the scripts in the wallet. The module has more than doubled since 2026-09-24 (1,223 KB raw, 424 KB gzip), with what chunks 15 to 21 put in it: the dApp connector, Lovejoin and the wallet’s own script evaluator (Aiken’s uplc), the transaction view, and the DRep.

The wasm-opt row is 2026-09-24’s and wasn’t measured again, since wasm-opt wasn’t installed: its module was that day’s, less than half today’s, and its transfer a draft for Ogmios (draftTransfer). Against that day’s wasm-release it was 9% smaller raw and 6% larger gzipped, which is why it isn’t used.

What a restore costs on a large contract (chunk 26’s Scale): node --expose-gc scale.mjs [rows] [checked] writes a synthetic contract/snapshot answer of rows rows in the data layer’s row shape, parses it as one string as response.json() does, and checks checked of them the way the worker does (the register out of the datum, then isOwned, 200 a turn). Measured 2026-10-09 in Node 24.19.0:

Rows Answer Parsed rows Peak RSS Check, every row
100,000 47 MB 105 MB 243 MB 1.2 min
1,000,000 473 MB 1,051 MB 1,291 MB 12.1 min

A row is about 473 bytes and its check about 0.73 ms, almost all of it isOwned’s scalar multiplication. Parsing took 0.2 s and 1.5 s. Mainnet’s contract held 33 unspent rows that day.

Test

From seedelf-platform/:

cargo test -p seedelf-wasm                                        # native, the plain-Rust layer
./seedelf-web-wallet/wasm/build.sh
node --test "seedelf-web-wallet/wasm/tests/*.test.mjs"            # the built package, from JS

CI (.github/workflows/web-wallet.yml) runs both: the native tests within cargo test --workspace --locked, then the build and the JS tests. One native test is ignored: session_test.rs’s record_the_extensions_session_swap_fixture, which rewrites the extension’s tests/fixtures/session-swap.json, so it runs only on purpose (cargo test -p seedelf-wasm --test session_test -- --ignored).

Both suites check the same pinned vectors as seedelf-crypto, so the WebAssembly build is known to match native Rust byte for byte, and the same recorded preprod data:

And natively only: