@terlos/stacks-client
v0.5.0
Published
Stacks Shield wallet client: private wallets and accounts under one password, root-checked sync, on-device STARK proving, shield / pay / withdraw / split / merge through relayers with automatic re-proving, payment requests. Framework-free; runs in a Web W
Maintainers
Readme
@terlos/stacks-client
Part of Terlos: private payments on Stacks with ZK-STARKs verified on chain. Architecture · Integration guide · Security
The high-level wallet client for Terlos, a zk-STARK shielded pool on Stacks. Use it to give users private balances in STX, sBTC and other SIP-010 tokens, to pay privately, and to ask to be paid with payment requests.
One object does everything:
- Wallets: several private wallets and accounts, all under one password.
- Sync: checked against the chain's root.
- Proving: STARK proofs are made on the device.
- Operations: shield, pay, withdraw, split and merge, submitted through relayers, with automatic re-proving when another user's transaction lands first.
It is framework-free and runs in Node or the browser, ideally inside a Web Worker.
Status: 0.x, testnet, unaudited. APIs may change between minor versions. Don't use it with mainnet funds until the protocol's external audit is done.
npm install @terlos/stacks-clientQuick start (browser, in a Web Worker)
// shield.worker.ts
import "@terlos/stacks-client/worker";// app
import { wrap, proxy } from "comlink";
import type { CoreApi } from "@terlos/stacks-client";
import engineUrl from "@terlos/stacks-sdk/wasm/stacks_shield_wasm.wasm?url"; // Vite
import engineSha256 from "@terlos/stacks-sdk/wasm/stacks_shield_wasm.sha256?raw";
const shield = wrap<CoreApi>(new Worker(new URL("./shield.worker.ts", import.meta.url), { type: "module" }));
import { TESTNET } from "@terlos/stacks-client"; // or MAINNET
await shield.init(
{
...TESTNET, // network, deployer, stacksApi
indexerUrl: "https://indexer.example", // see "Services" below
relayerUrls: ["https://relayer-1.example", "https://relayer-2.example"],
},
new URL(engineUrl, location.href).href,
engineSha256.trim(), // the prover engine is refused unless its SHA-256 matches
);
// a new private wallet (24 words: show them to the user to back up)
const words = await shield.generateWords();
await shield.createWallet(words, "a strong password");
await shield.sync();
const balances = await shield.balances(); // { stx: 12500000n, "1": 300n, ... } base units
const me = await shield.address(); // zst1… (testnet) / zsp1… (mainnet)
// pay 2.5 STX privately to a shielded address (notes are merged / split automatically)
await shield.pay("zst1…", 2_500_000n, null, proxy((step) => console.log(step)));In Node, create a ShieldClient directly:
import { readFileSync } from "node:fs";
import { ShieldClient, memoryStore } from "@terlos/stacks-client";
const file = (p: string) => readFileSync(new URL(import.meta.resolve(`@terlos/stacks-sdk/wasm/${p}`)));
const wasm = file("stacks_shield_wasm.wasm");
const bytes = wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength);
const sha256 = file("stacks_shield_wasm.sha256").toString("utf8").trim();
const client = await ShieldClient.create(config, { bytes, sha256 }, memoryStore());Pass your own KeyValueStore (get / set / remove) to persist state. idbStore() uses
IndexedDB in browsers. Everything it stores is sealed: the backup words with the password
(PBKDF2-SHA256, 600,000 iterations, then AES-256-GCM), history and labels with a key derived
from the words.
Testnet and mainnet
The same code runs on both networks; the deployment presets give the right contract addresses:
| Preset | Network | Deployment |
| --- | --- | --- |
| TESTNET | testnet | v5, ST2RD49DFQ4PM58SMR571CTPFF8JKXFA3FFJA22GK: the same contract code as mainnet |
| MAINNET | mainnet | v1, SP2G6D6AX0WJ4JPK8PK6EBW8QQXBGMWTXE4J0VABX: real funds, unaudited, deposits capped |
| TESTNET_V4 | testnet | the previous testnet deployment, for apps still on it |
DEPLOYMENTS[network] picks the current one by name. Test on TESTNET first: it behaves exactly
like mainnet, with free test funds. Each network needs its own indexer and relayers.
Wallets are separate per network. The same 24 words give different keys on mainnet and on
testnet, so a person's testnet and mainnet addresses (zst1… / zsp1…) cannot be linked. A
backup restores the wallet on whichever network the client is configured for.
API overview
| Area | Methods |
| --- | --- |
| Wallets | status, generateWords, validWords, createWallet, unlock, lock, addWallet, addAccount, switchAccount, renameWallet, renameAccount, revealWords, removeWallet, forget, wallets |
| Addresses | address (primary), newAddress (a fresh one per counterparty), addresses, setAddressLabel |
| State | sync, balances, notes, history, assets, stats, depositRoom, withdrawRoom |
| Operations | shield(amount, asset, sender, signer) (deposit, signed by the user's Stacks wallet), pay(to, amount, asset), send(note, to), split, merge, withdraw(note, recipient) |
| Payment requests | createPaymentRequest({ amount, asset, id?, label?, message?, expiresIn? }) → { request, uri, watchKey, fromLeaf }, readPaymentRequest(uri), payRequest(uri) |
| Relayers | quote(): the fees of the relayer the next operation uses, never above the signed fee table |
Amounts are bigint base units (micro-STX, satoshis for sBTC). Assets are null for STX, or
the asset's uid in the protocol's asset registry (sBTC = 1, USDCx = 2 on testnet v4).
Operations accept a progress(step, detail?) callback. The steps are syncing, proving,
signing, submitting, confirming and retrying. retrying means another transaction
landed first and the client is proving again against the new state, automatically.
Accepting payments (merchants)
const { uri, watchKey, fromLeaf, request } = await client.createPaymentRequest({
amount: 2_500_000n, asset: null, id: "order-42", label: "Coffee Shop", expiresIn: 3600,
});
// show `uri` as a QR code or link it (paymentLink in @terlos/stacks-pay);
// give `watchKey` + `fromLeaf` to your server's PaymentWatcher to confirm itEach request uses a fresh receiving address, so payments can't be linked to each other, and
the watch key reveals only that one address's incoming notes. See @terlos/stacks-pay.
Services
The client talks to:
- a Stacks API (Hiro or your own node): the source of truth for the root, spends and outcomes;
- an indexer (
services/indexer): bulk tree and memo data, always re-checked against the chain; - one or more relayers (
services/relayer): they submit private operations, so the user's Stacks address never appears. Relayers are used round-robin, with failover only for refusals another relayer can accept, never at a higher fee than shown.
Public hosted endpoints will be announced. Until then, run the services yourself (see the
protocol repository's services/ and scripts/testnet/services.ts).
Relayer fees: the signed fee table
Relayer fees come from a table signed by the protocol's fee admin (see
@terlos/stacks-sdk/relayer-fees). The client handles it as follows:
- Find the table in force. It reads
GET /v1/feesfrom every configured relayer, and keeps the newest table that verifies: signed by a wallet that holds the fee-admin role on chain now, and made for your deployment. A relayer serving an older or forged table can't raise what users pay. - Cap the fees. It uses a relayer only if its quote, for the operation and asset, is at or below that table.
- Refuse without a table. Until a table is published, relayed operations (pay, send, split, merge, withdraw) are refused with "No relayer fee schedule is in force yet". Deposits don't need a relayer and always work.
For a new deployment, the fee admin publishes the table once, from the governance console's Relayer fees page. Relayers that share a database keep it across restarts.
Withdrawals are also submitted with the checked protocol fee as their max-fee, so a protocol
fee raised before the transaction lands aborts it instead of charging more.
Security notes
- Keys never leave the client. In the browser, run it in a Web Worker so the page never holds keys.
- The prover engine is pinned by SHA-256 and refused if it differs.
- The client never asks a node about individual notes. Spends are synced in bulk, so nobody learns which notes are yours.
- Show users their 24 backup words and make them confirm the backup. Clearing browser storage without them loses the funds.
License: Apache-2.0.
