@waaskey/sdk
v0.4.2
Published
WAASKey — embedded, non-custodial MPC wallets for your app. Official TypeScript SDK.
Downloads
1,192
Maintainers
Readme
@waaskey/sdk
Official TypeScript SDK for Waaskey — embedded, non-custodial MPC wallets for your app. Create wallets and sign transactions where the private key is never assembled in one place (2-of-3 threshold ECDSA, no seed phrase).
Early access (v0.2.x). The public API is taking shape and may change before
1.0.0. Pin an exact version.
Install
pnpm add @waaskey/sdk @waaskey/client-wasm@waaskey/client-wasm is the WASM MPC engine WasmMpcCore loads at runtime — it is
an exact-pinned optional peer (the SDK refuses a mismatched version), so install
it alongside the SDK for any app that creates wallets or signs. Passkey / step-up
flows additionally need the optional peer @simplewebauthn/browser.
Quickstart
import { Waaskey, WasmMpcCore, EncryptedShareStore, loadClientWasm } from '@waaskey/sdk';
// A secret YOU derive per user (session token, passkey PRF output, device secret) —
// it keys the AES-256-GCM sealing of the local key share; never hardcode it.
const sessionSecret = await deriveUserSessionSecret();
const waaskey = new Waaskey({
apiKey: process.env.WAASKEY_PUBLISHABLE_KEY!,
baseUrl: process.env.WAASKEY_API_URL!, // required — there is no default
// Non-custodial: the device runs its half of the ceremony and seals its key share.
mpc: new WasmMpcCore(loadClientWasm), // dynamic-imports @waaskey/client-wasm
shareStore: EncryptedShareStore.browser(sessionSecret), // sealed in IndexedDB
});
// Create an MPC wallet — runs the device keygen, seals the share, waits until active.
const wallet = await waaskey.wallets.create({ chain: 'ethereum' });
// → wallet.id, wallet.address, wallet.status === 'active'
// Sign a 32-byte digest (hex; 0x optional)
const signature = await wallet.sign(digestHex);Reusing an existing wallet: persist wallet.id (wlt_…) next to your user record
and load it later with waaskey.wallets.get(id) — the sealed device share is already
in the shareStore under that id, so sign/send work as soon as the sealing secret
is available again. Forgot the ids? waaskey.wallets.list() pages through the
tenant's wallets. On a brand-new device the share is restored via Multi-factor
recovery (below), not re-created.
Node.js (no browser)
The same flow works server-side on Node ≥ 22 (global fetch, WebSocket and WebCrypto
are built in). Two things differ from the browser: the wasm engine is loaded from disk
(Node's fetch can't read file paths), and there is no IndexedDB — pass
MemoryKeyValueStore (or your own KeyValueStore, e.g. DB-backed). Generate the
Paillier primes before the ceremony with a PrimePool — inline generation takes
minutes of single-threaded CPU and can outlive the server party's ceremony timeout:
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { Waaskey, WasmMpcCore, EncryptedShareStore, MemoryKeyValueStore, PrimePool, generateRecoveryCode } from '@waaskey/sdk';
const require = createRequire(import.meta.url);
// Node wasm loader: read the bytes from the installed package and initialize with them.
async function loadClientWasmNode() {
const mod = await import('@waaskey/client-wasm');
const bytes = await readFile(require.resolve('@waaskey/client-wasm/client_wasm_bg.wasm'));
await mod.default(await WebAssembly.compile(bytes));
return mod;
}
const mpc = new WasmMpcCore(loadClientWasmNode);
const waaskey = new Waaskey({
apiKey: process.env.WAASKEY_API_KEY!,
baseUrl: process.env.WAASKEY_API_URL!,
mpc,
shareStore: new EncryptedShareStore(new MemoryKeyValueStore(), process.env.SHARE_SECRET!),
primePool: new PrimePool(mpc),
});
// Off the hot path (boot/idle): pre-generate the primes so create() takes seconds.
await waaskey.wallets.prewarm('ethereum');
const recoveryCode = generateRecoveryCode(); // show it to the user once
const wallet = await waaskey.wallets.create({ chain: 'ethereum' }, { backup: { recoveryCode, totpSecret, email } });
const signature = await wallet.sign(digestHex);A wallet created this way is non-custodial with the server you run this on acting as the "device" — its share lives in your
shareStore. Guard that store accordingly.
Send a transaction — WaaS signs, you broadcast
WaaS is a signing service, not a broadcaster (non-custodial: it never owns the
mempool, nonce, or status-tracking). wallet.send(...) builds and MPC-signs the
transaction and returns the signed raw tx — you submit it from your own
node/provider:
const { signedTx, txHash } = await wallet.send({ chainId: 'evm:1', to, value });
// signedTx → the signed raw tx to broadcast; txHash → a deterministic offline id (not a confirmation)
// Broadcast from YOUR node — either the optional helper…
const { txHash: broadcastHash } = await waaskey.broadcast(signedTx, { rpcUrl: 'https://your-rpc' });
// …or your own submitter / provider (recommended for production — you own tracking & retries).The broadcast helper is best-effort by design — one eth_sendRawTransaction
call, no status polling and no retries. Tracking the tx to confirmation is the
integrator's concern.
Custody policy — choose the threshold topology (advanced)
wallets.create({ chain }) defaults to the non-custodial 2-of-3 topology
[device, server, user_backup] — the platform holds 1 share < t=2, so it can never
sign alone (custodyType: 'shared', isNonCustodial: true). To change the (t, n)
threshold or the custody guarantee, pass a custody policy; the SDK validates it
client-side and the backend enforces the attested invariant platformShares < t ⟺
non-custodial:
const wallet = await waaskey.wallets.create({
chain: 'ethereum',
threshold: 2, // t
parties: ['device', 'user_backup', 'server'], // n = 3 well-known party roles
custodyKinds: ['user_device', 'user_backup', 'platform_signer'], // parallel to parties
custodyType: 'shared', // requested posture — the server refuses (400) on a mismatch
});
// The returned wallet surfaces the attestation so you can display the guarantee:
wallet.threshold; // 2
wallet.parties; // ['device','user_backup','server']
wallet.custodyKinds; // ['user_device','user_backup','platform_signer']
wallet.platformShareCount; // 1
wallet.custodyType; // 'shared'
wallet.isNonCustodial; // true — platform's 1 share alone can't reach t=2CustodyKind ∈ user_device | user_backup | platform_signer | platform_recovery |
external_party; CustodyType ∈ embedded | shared | self_custody. The standalone
isNonCustodial(wallet) helper is also exported.
The device runs whatever
t-of-nthe create ceremony describes — keygen is not pinned to 2-of-3. Coordinating an interactivet-of-nsign across more than one user-held party (each running its half over the relay) is a separate follow-up; today'swallet.sign(...)covers the device+server quorum.
Read the wallet's signing activity (raw signs + send/sweep signed txs, newest first):
const { items } = await wallet.signatures({ page: 1, limit: 20 });API
| Method | Description |
| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| new Waaskey({ apiKey, baseUrl, mpc, shareStore, fetch? }) | Create a client. mpc + shareStore are required to create wallets. |
| waaskey.wallets.create({ chain, label?, threshold?, parties?, custodyKinds?, custodyType? }, options?) | Create (keygen + sealed share). Optional custody policy (default: non-custodial 2-of-3 [device, server, user_backup]). Returns a Wallet. |
| waaskey.wallets.list(query?, options?) | List the tenant's wallets, newest first (paginated WalletData rows). |
| waaskey.wallets.get(id, options?) | Load an existing wallet. |
| wallet.sign(digestHex, options?) | Sign a 32-byte digest with the wallet's MPC key. |
| wallet.send(params, options?) | Build + MPC-sign a tx. Returns the signed raw tx (you broadcast). |
| wallet.signatures(query?, options?) | The wallet's signing activity (paginated). |
| waaskey.broadcast(signedTx, { rpcUrl }) | Optional best-effort submit of a signed tx from your own node. |
create / sign accept { signal } for cancellation; create also takes
{ waitForActive?, activationTimeoutMs?, pollIntervalMs? }.
Errors are thrown as WaaskeyError with a typed code (e.g. unauthorized,
forbidden, validation, device_core_required, keygen_failed, aborted) plus
status? and details? — branch on error.code, never on the message text.
Secure share storage
The device key share is the user's half of the key. EncryptedShareStore seals it
with AES-256-GCM (key derived from a secret you supply — the user's session,
a passkey, or a device secret — never embedded) and persists the ciphertext in
IndexedDB (MemoryKeyValueStore for tests/SSR). Wipe everything on logout:
await shareStore.clear();Passkey-derived secret (recommended)
The strongest source for the sealing secret is a passkey with the WebAuthn PRF
extension (PasskeyPrfSecretProvider): the 32-byte secret only materializes after
the user touches their authenticator (or passes biometrics) and never sits on disk —
malware that steals the IndexedDB ciphertext cannot decrypt it in the background.
Needs the optional peer @simplewebauthn/browser.
import { PasskeyPrfSecretProvider, isPrfSupported } from '@waaskey/sdk';
const prf = new PasskeyPrfSecretProvider();
// Onboarding — register a PRF-capable passkey (one authenticator touch).
// Persist credentialId + salt anywhere (not secret); keep `secret` in memory only.
const { credentialId, salt, secret } = await prf.enroll();
// Every later session — unlock with the same passkey (another touch).
const { secret: sessionSecret } = await prf.unlock(credentialId, { salt });
const shareStore = EncryptedShareStore.browser(sessionSecret);Feature-detect with isPasskeySupported() / isPrfSupported() and fall back to a
session-derived secret where PRF is unavailable.
Multi-factor recovery
Back up the device share so a user can recover after losing their device. The share is encrypted client-side with a recovery code (the server only ever stores the opaque ciphertext), and its release is gated behind 3 factors: recovery code, TOTP, and email OTP.
// Enrol — show the returned recoveryCode to the user once (it's the only key).
const { recoveryCode } = await waaskey.recovery.register(wallet.id, {
share: (await shareStore.get(wallet.id))!,
totpSecret, // base32 authenticator secret
email,
});
// Later, on a new device — verify factors, re-key, and restore the share locally.
const { challengeId, requiredFactors } = await waaskey.recovery.challenge(wallet.id);
await waaskey.recovery.recover(wallet.id, {
challengeId,
recoveryCode,
verifications: [
{ type: 'recovery_code', token: recoveryCode },
{ type: 'totp', token: otpFromAuthenticator },
{ type: 'email_otp', token: otpFromEmail },
],
});| Method | Description |
| ------------------------------------------ | -------------------------------------------------------------- |
| recovery.register(walletId, params) | Encrypt the share + enrol factors. Returns the recoveryCode. |
| recovery.getInfo(walletId) | The wallet's registered factors. |
| recovery.challenge(walletId) | Start a recovery session (challengeId + required factors). |
| recovery.recover(walletId, params) | Verify, re-key, decrypt, and restore the share to the store. |
| recovery.retrieveShare(walletId, params) | Verify + decrypt the share without re-keying (read-only). |
Balances (client-side, no backend)
WaaS is non-custodial and the backend does not index chain state — balances are read directly from a chain provider, dApp-style. EVM chains work out of the box via a default public RPC; override per chain (or plug a custom provider for non-EVM):
const waaskey = new Waaskey({
apiKey,
chains: { ethereum: { rpcUrl: 'https://your-rpc' } }, // optional; EVM has defaults
});
const eth = await waaskey.balances.getBalance('ethereum', wallet.address!);
// → { raw: 1000000000000000000n, decimals: 18, symbol: 'ETH', formatted: '1' }
// (`symbol` is optional in the type — present for built-in EVM chains, may be
// undefined for a custom provider that doesn't supply one)
const usdc = await waaskey.balances.getTokenBalance('ethereum', usdcAddress, wallet.address!, { symbol: 'USDC' });Balances are exact bigint base units plus a formatted decimal string (no float).
formatUnits(raw, decimals) is exported for rendering.
Development
pnpm install
pnpm test:unit # vitest
pnpm types # tsc --noEmit
pnpm lint
pnpm build # tsup → dist (esm + cjs + d.ts)License
MIT © WAASKey
