wative-core
v2.5.4
Published
Hyperscale crypto wallet management library — multi-workspace, multi-chain, originally designed for on-chain market makers
Maintainers
Readme
Wative Core 2
Hyperscale crypto wallet management library — multi-workspace, multi-chain, originally designed for on-chain market makers.
Runs in Node and in the browser environments. Everything a workspace persists — wallets, accounts, networks, assets — is encrypted Record layer, sealed by the library exclusively seen by creators.
Where those records live is yours to choose: files, IndexedDB, Docker, NSA, databases, CDN, RAM, etc. Any encryption algorithm, any format, any storage media.
How it fits together
A workspace is the container. Everything else nests inside it:
Workspace one encrypted container, one password
│
├── Account an identity — HD (from a mnemonic) or PK (imported keys)
│ └── Wallet one slot in that account
│ └── Address one key on one chain — this is what signs
│
├── Network the chains you can reach (RPC, chain id)
└── Asset the tokens tracked on each networkTwo kinds of Account
The nesting is the same for both, but a Wallet means something different in
each — and that is the part most people get wrong first.
HD — one mnemonic, many slots. A Wallet is a derivation slot, and it holds
the EVM and Solana keys derived from that same slot:
Account "Trading Desk" (HD) one mnemonic
├── Wallet 0 m/44'/60'/0'/0/0
│ ├── Address vm: "evm" 0x9858EfFD…
│ └── Address vm: "svm" HAgk14JpMQ… same slot, both chains
├── Wallet 1 m/44'/60'/0'/0/1
│ ├── Address vm: "evm" 0x6Fac4D18…
│ └── Address vm: "svm" Hh8QwFUA6…
└── … account.deriveWallets(n)PK — imported keys. Each import allocates its own Wallet, and that wallet
gets an address on both chains: the key signs on its own curve, and the same
32 secret bytes seed a keypair on the other one. The chain you imported for
comes first.
Account "Cold Storage" (PK) no mnemonic
├── Wallet 0 the key passed to accounts.create()
│ ├── Address vm: "evm" 0x90F8bf6A… the imported key
│ └── Address vm: "svm" HaWmh8svNQ… same secret, other curve
├── Wallet 1 account.importPrivateKey(evmKey)
│ ├── Address vm: "evm" 0xFFcf8FDE…
│ └── Address vm: "svm" Fgdy5QRxtP…
└── Wallet 2 account.importPrivateKey(svmKey)
├── Address vm: "svm" 3QVq8D876h… the imported key
└── Address vm: "evm" 0x1a740984… same secret, other curveA PK account can hold as many keys as you like, on any mix of chains — but it refuses the same identity twice, on either chain.
wallet.addressesis a list. It holds one address per(vm, network)pair and is free to carry several. Read it rather than assuming a position:const evm = wallet.addresses.find((a) => a.vm === "evm");
Which chain a key is for is read off the key — vm is optional:
await acc.importPrivateKey(evmKey); // 64 hex chars -> evm
await acc.importPrivateKey(solanaKey); // base58 -> svm
await acc.importPrivateKey(readFileSync("id.json")); // solana-keygen -> svm
await acc.importPrivateKey(key, "evm"); // or say so explicitlyThe formats do not overlap, so this is a decision rather than a guess. A value
that is neither is refused with INVALID_PRIVATE_KEY — including 32 bytes of
hex that is not a valid secp256k1 scalar, and a bare 32-byte base58 value,
which is indistinguishable from a Solana public key.
| | HD | PK |
| ---------------------- | ----------------------------- | ------------------------------- |
| Created from | a BIP-39 mnemonic | one private key |
| Grows with | deriveWallets(n) | importPrivateKey(pk, vm) |
| A Wallet is | a derivation slot | one imported key |
| Addresses per Wallet | 2 — one evm, one svm | 2 — imported chain first |
| Those two addresses | independent keys, two paths | one key, both curves |
| dumpMnemonic() | ✔ | throws UNSUPPORTED_OP |
| sliceWallets(n) | ✔ | throws UNSUPPORTED_OP |
| importPrivateKey() | throws UNSUPPORTED_OP | ✔ |
// HD — one slot gives you the matching keys on both chains
const hd = await ws.accounts.create("Trading Desk", password, mnemonic);
await hd.deriveWallets(5); // slots 1..5, alongside slot 0
hd.wallets[0].addresses.find((a) => a.vm === "evm");
hd.wallets[0].addresses.find((a) => a.vm === "svm");
// PK — each import is its own wallet, with an address on both chains
const pk = await ws.accounts.create("Cold Storage", password, privateKey);
await pk.importPrivateKey(anotherEvmKey); // -> a new Wallet, vm inferred
await pk.importPrivateKey(aSolanaKey); // -> another new WalletInstall
pnpm add wative-core
# or
npm i wative-coreNode.js 22.12+ for the filesystem backend. The browser build has no Node requirement.
Quick start
The API is the same in both environments. Only storage differs: Node writes encrypted files to disk, the browser writes to IndexedDB.
Node
import { Workspace } from "wative-core";
import "wative-core/node"; // installs the filesystem backend
const ws = await Workspace.open({ password: "your-workspace-password" });
const account = await ws.accounts.create(
"Trading Desk",
"your-workspace-password",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
);
await account.deriveWallets(5);
const wallet = account.wallets[0];
const evmAddress = wallet.addresses.find((a) => a.vm === "evm")!;
const svmAddress = wallet.addresses.find((a) => a.vm === "svm")!;
await ws.lock();Browser
import { Workspace, IdbProvider } from "wative-core";
const provider = await IdbProvider.create("my-dapp");
const ws = await Workspace.open({ provider, password: "your-workspace-password" });
// everything from here is identical to the Node example
const account = await ws.accounts.create(
"Trading Desk",
"your-workspace-password",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
);
await account.deriveWallets(5);
await ws.lock();What differs
| | Node | Browser |
| -------------- | -------------------------------------- | -------------------------------- |
| Extra import | import "wative-core/node" | none |
| Storage | encrypted files on disk | IndexedDB |
| Where it lives | Workspace.open({ path }), or default | IdbProvider.create(name) |
| Extra setup | none | Buffer polyfill |
HybridProvider, HybridProviderV3 and FileSink are re-exported from the
package root in Node.
Take the provider from the same entry point as Workspace:
// Right — one entry point.
import { Workspace, HybridProvider } from "wative-core";
const ws = await Workspace.open(new HybridProvider("~/wallets"), password);
// Wrong — rejected with a PARAMETER_ERROR naming the fix.
import { Workspace } from "wative-core";
import { HybridProvider } from "wative-core/node";Each entry point is a separate bundle with its own class identities and module
state, so a provider built by one is not recognised by a Workspace from the
other. Workspace.open rejects it with an error naming the fix. A bare
import "wative-core/node" for its side-effects is unaffected.
Browser: the Buffer polyfill
The Solana libraries read a global Buffer, which browsers do not provide.
pnpm add buffer// once, before importing wative-core
import { Buffer } from "buffer";
globalThis.Buffer = Buffer;Browser: keeping keys from disappearing
Browser storage is not permanent. A browser may clear IndexedDB when disk runs low, and Safari clears script-writable storage after about seven days without user interaction. Losing it means losing the keys.
IdbProvider.create() asks the browser for persistent storage and reports the
answer:
const provider = await IdbProvider.create("my-dapp");
provider.durability; // "persistent" | "best-effort"Creating a new workspace in non-persistent storage is refused, because keys written there can disappear without warning:
// throws STORAGE_NOT_DURABLE when the browser refused persistence
await Workspace.open({ provider, password });
// proceed anyway — only if the keys are backed up elsewhere
await IdbProvider.create("my-dapp", { acknowledgeEvictionRisk: true });Opening a workspace that already exists is never blocked.
Browsers are far more likely to grant persistence when the request comes from a
user action, so call IdbProvider.create() from a click rather than on page
load. Whatever the answer, give users a backup — exportContainer() returns the
encrypted records, and they import into a Node workspace unchanged:
const backup = await provider.exportContainer(); // still encrypted
await otherProvider.importContainer(backup); // same password opens itKey derivation
Passwords are stretched with Argon2id (RFC 9106) before they ever become a key. That is deliberately expensive, which is why unlocking takes a moment and why the sections below are about speed rather than correctness — every option produces the same keys.
Nothing to install: it runs on a bundled WebAssembly build by default, with no native binary and no build step.
One derivation per container: HybridProviderV3 (Node only)
import { Workspace, HybridProviderV3 } from "wative-core";
const ws = await Workspace.open(new HybridProviderV3("~/wallets"), password);HybridProvider derives a key per stored secret, so unlocking an account costs
one derivation per address and gets slower as the account grows.
HybridProviderV3 derives once per workspace instead — unlocking a 50-address
account goes from about 7 seconds to under 200 ms, and stays there however many
addresses you add.
It is the Node default where @node-rs/argon2 is installed, and Node-only: that
package has no browser build. Construct it yourself only to pick a location, or
to be sure you get it rather than the fallback.
Checking which implementation ran
import { argon2BackendInfo } from "wative-core";
argon2BackendInfo();
// -> { backend: "wasm" | "noble" | "unresolved", wasm, reason?, overrides }backend is the process-wide default. A provider may carry its own — a
HybridProviderV3 does — so check overrides to confirm the native path
took. It is a pure read and resolves nothing, so before the first unlock it
honestly answers "unresolved".
Runnable examples
An examples/ folder ships with the package — runnable JavaScript files demonstrating every domain's use cases. Read them top-to-bottom as walkthroughs, or run them as integration tests via Node's built-in test runner.
Copy them out of node_modules first, then run the whole set:
cp -R node_modules/wative-core/examples wative-examples
node --test wative-examples/The copy is required: Node's test runner skips anything under node_modules, so pointing --test there finds no files. To run a single example without copying, execute it directly — a node:test file runs itself:
node node_modules/wative-core/examples/01-quick-start.test.cjsFiles cover: quick start, HD vs PK accounts, network management, asset management, address signing, custom storage backends, persistence, workspace search, default-network selection, subpath imports, the workspace logger, workspace config, the ESM entry, package resolution, known-answer vectors, locked-state behaviour, one-key-per-workspace derivation, adding a custom chain, and token transfers. See examples/README.md for the full index — it is the list that stays current.
What you can do
The library is organized around seven things you'll work with:
Workspace— your top-level container. Holds the password, your accounts, your network and asset list, and a built-in logger.Account— either an HD account (one BIP-39 mnemonic, can derive many wallets) or a PK account (raw private keys you import one at a time). Each account can have its own password or share the workspace password.Wallet— a unit inside an account, holding one address per(vm, network). Both kinds give you an EVM and a Solana address: an HD slot derives one per chain from the mnemonic, an import derives its sibling from the imported key. Treatwallet.addressesas the list it is.Address— an on-chain identity. Sign messages, build transactions, transfer native coins or tokens, and send them.Network— chain metadata. 14 networks ship pre-loaded.Asset— token metadata. 29 tokens ship pre-loaded.Transaction—EvmTransactionorSvmTransaction. Subscribe to lifecycle events (change/confirmed/failed) or await terminal states (whenSubmitted/whenMined/whenConfirmed/whenFinalized). Tracking stops at first inclusion, sowhenConfirmed(n)withn > 1rejectsUNSUPPORTED_OP— call it with no argument and pollreceipt.blockNumberagainst the chain head if you need depth — andwhenFinalized()rejectsUNSUPPORTED_OPunless the node reported finality directly.
Everything is encrypted on disk under your workspace password.
Pre-loaded networks and tokens
| Networks (14) | Tokens (29) | |---|---| | ethereum, base, bnbchain, arbitrum, optimism | native gas + USDC + USDT on each EVM mainnet (BSC versions are 18-decimal Binance-Peg); plus WETH on ethereum | | hyperevm, robinhood | native HYPE on HyperEVM; native ETH on Robinhood Chain | | sepolia, arbitrum-sepolia | native ETH on both testnets; plus USDC on arbitrum-sepolia | | hyperevm-testnet, robinhood-testnet | native HYPE / native ETH, mirroring their mainnets | | solana, solana-testnet, solana-devnet | native SOL + USDC + USDT + WSOL on solana mainnet; native-only on testnets |
Examples by domain
Workspace — open / unlock / lock
import { Workspace } from "wative-core";
const ws = await Workspace.open({ password: "wsp-pwd" });
const ws2 = await Workspace.open({ path: "/var/lib/wative-prod", password: "wsp-pwd" });
const locked = await Workspace.open({ path: "/var/lib/wative-prod" });
console.log(locked.locked);
await locked.unlock("wsp-pwd");
ws.logger.info("trading session opened", { desk: "alpha" });
await ws.logger.setLevel("debug");
await ws.lock();Creating vs. opening — it's automatic
You don't tell open() whether to create or open. It decides from what's already at the location:
- Nothing there (or an empty folder) → a new workspace is created.
- A wative workspace already there → it's opened, and your password is checked.
- Something else there (a folder with unrelated files) → the call is refused with a
PARAMETER_ERROR, so a mistyped path never turns one of your other folders into a workspace.
Pass the password to get an unlocked workspace, or omit it to get a locked one you unlock later:
const ws = await Workspace.open({ path: "/var/lib/wative-prod", password: "wsp-pwd" }); // unlocked
const later = await Workspace.open({ path: "/var/lib/wative-prod" }); // locked
await later.unlock("wsp-pwd");open() also accepts the older positional form — Workspace.open(pathOrProvider, password) — which works exactly the same way.
Where the workspace lives on disk
Most apps pass an explicit path as the first argument — that path is used verbatim (with ~ expansion). When you omit the path, the library resolves it through a 3-tier strategy, in order:
WATIVE_WORKSPACE_PATHenvironment variable — if set and non-empty, this wins. Use it for ops/CI overrides without touching code:WATIVE_WORKSPACE_PATH=/var/lib/wative-prod node app.js WATIVE_WORKSPACE_PATH=~/.wative2 node app.js # ~ is expanded<process.cwd()>/.wative2— used when the env var is unset and this directory already exists on disk. Lets a project pin its workspace by simply having a.wative2/folder at the repo root.<os.homedir()>/.wative2— last-resort fallback. WhereWorkspace.open({ password })lands on a fresh machine when no env var is set and there's no project-local.wative2/. Acts as the user-wide default workspace.
So a typical lifecycle looks like:
// First run on a fresh machine — env unset, no <cwd>/.wative2 → creates ~/.wative2
await Workspace.open({ password: "wsp-pwd" });
// After `mkdir .wative2`, <cwd>/.wative2 exists and tier 2 wins
await Workspace.open({ password: "wsp-pwd" });
// Or set WATIVE_WORKSPACE_PATH=/srv/wative-staging to override
await Workspace.open({ password: "wsp-pwd" });If you'd rather not rely on the resolver, just pass the path explicitly — it short-circuits all three tiers.
Account — HD vs PK, derive wallets, import keys
const hd = await ws.accounts.create(
"Trading Desk",
"wsp-pwd",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
);
await hd.deriveWallets(10);
await hd.sliceWallets(6);
const pk = await ws.accounts.create(
"External Hot Wallet",
"wsp-pwd",
"0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d",
undefined,
{ kind: "PK" },
);
await pk.importPrivateKey(
"xtGWcHvQsr5ue8zG2F5fm31bW8vyn597Y92gUWaZZg3S1Z6FeJMATL8KU3xJMGbLfALnokcct9y5wYtCRrwNXZW",
"svm",
);
const sub = await ws.accounts.create(
"Sub Account",
"sub-pwd",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
undefined,
{ hasOwnPassword: true },
);
await sub.tryUnlock("sub-pwd");
await sub.resetPassword("sub-pwd", "new-sub-pwd");
console.log(hd.dumpMnemonic());
hd.lock();
await pk.drop();Wallet — derivation slot, tags, drop
const wallet = hd.wallets[2];
console.log(wallet.id);
console.log(wallet.addresses.length);
await wallet.addTag("hot");
await wallet.addTag("alpha-strategy");
await hd.wallets.drop(hd.wallets[10]);Address — sign messages, build transactions
⚠️ One secret controls both addresses in a PK wallet. There is only one key to import, so both addresses come from it. Whoever learns that key holds both chains.
This is not how an HD account works. There, a slot's two addresses come from two different derivation paths — the mnemonic is shared, the private keys are independent. If you want independent keys per chain, use an HD account, or import a separate key for each and read
addresses[0]of each wallet.
const evmAddr = hd.wallets[0].addresses.find((a) => a.vm === "evm")!;
const svmAddr = hd.wallets[0].addresses.find((a) => a.vm === "svm")!;
const sig = evmAddr.signMessage("hello hedgue");
const tx = evmAddr.buildTransaction({
to: "0x1234567890123456789012345678901234567890",
value: 1_000_000_000_000_000n,
chainId: 1,
});
await tx.sign(); // resolves once the signature exists
const tracker = await evmAddr.sendTransaction(tx);
tracker.on("change", (state) => console.log("state →", state.status));
tracker.on("confirmed", (receipt) => console.log("confirmed", receipt));
tracker.on("failed", (err) => console.error("failed", err));
const hash = await tracker.whenSubmitted();
const receipt = await tracker.whenConfirmed(); // no argument — see note below
const sim = await evmAddr.simulateTransaction(tx);
console.log(sim.gasUsed);address.signTransaction(tx) returns straight away and does not wait for the signature. To use the signed bytes (tx.rawTransaction, tx.hash), await tx.sign() first — it resolves once signing is done and is safe to call more than once.
buildTransferPayload() is the one-call path for moving value — it works out the right payload for the asset (native value, ERC-20 calldata, or SPL instructions). You then pass that payload to buildTransaction() and send it like any other transaction:
// native coin (omit `asset`)
const nativePayload = evmAddr.buildTransferPayload({ to: recipient, amount: 1_000_000_000_000_000n });
// an ERC-20 / SPL token, by contract address or mint
const tokenPayload = evmAddr.buildTransferPayload({ to: recipient, asset: { address: usdcAddress }, amount: 1_000_000n });
// or by symbol, resolved against the network's known tokens
const bySymbol = evmAddr.buildTransferPayload({ to: recipient, asset: { symbol: "USDC" }, amount: 1_000_000n });
// build the transaction from the payload, then sign/send it
const tx = evmAddr.buildTransaction({ ...nativePayload, chainId: 1 });
await tx.sign();Amounts are in the token's smallest unit. buildTransaction fills in nonce/gas/fees for you when you omit them, or uses the values you pass. An SPL transfer to a recipient without a token account prepends the account-creation step for you.
Signing bytes you built yourself
If you build and broadcast transactions yourself and only need a signature, two methods sign exactly the bytes you give them. Neither hashes its input, adds a prefix, or touches the network.
// EVM: sign a 32-byte digest you computed (for example the keccak of an encoded transaction)
const { r, s, recovery, signature } = evmAddr.signDigest("0x" + "ab".repeat(32));
// r, s: 32-byte hex (s is low); recovery: 0 or 1; signature: 65 bytes, v = 27 + recovery
// SVM: sign arbitrary bytes, for example a serialized Solana transaction message
const { signature: sig58, signatureHex } = svmAddr.signBytes(messageBytes);- Both take a
Uint8Arrayor a0x-prefixed hex string of whole bytes. Base64 and hex without the prefix are refused; decode base64 yourself first. signDigestrequires exactly 32 bytes.signBytesaccepts any length, including binary data thatsignMessageEncoded(..., "ed25519")cannot carry, because that method takes text.- The 65-byte
signatureuses the message-signing convention forv. For a typed transaction userecoveryas the y-parity, and for a legacy EIP-155 transaction use35 + 2 * chainId + recovery. signDigestworks only on an EVM address andsignBytesonly on an SVM address. Each needs the account unlocked.
⚠️ These sign anything you hand them, with none of the checks
signTypedDataapplies. Only pass digests and bytes you assembled yourself.
Network — pre-loaded networks + your own
import { Network, type ChainId, type Slug } from "wative-core";
console.log(Network.Ethereum.chainId);
console.log(Network.Solana.vm);
const eth = ws.networks.bySlug("ethereum" as Slug)!;
const arb = ws.networks.byChainId(42161 as ChainId)!;
const monad = new Network({
slug: "monad-testnet" as Slug,
name: "Monad Testnet",
chainId: 10143 as ChainId,
rpcUrl: "https://testnet-rpc.monad.xyz",
nativeCurrency: { name: "Monad", symbol: "MON", decimals: 18 },
vm: "evm",
});
await ws.networks.add(monad);
const customEth = new Network({
...eth,
rpcUrl: "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY",
});
await ws.networks.update(customEth);
await ws.networks.drop(monad);
await hd.setDefaultNetwork("base");
await hd.setDefaultNetwork(8453 as ChainId);
await hd.setDefaultNetwork("0x2105");
await hd.setDefaultNetwork(Network.Base);Asset — pre-loaded tokens + custom tokens
import { Asset, Network, type AssetId } from "wative-core";
const ethAssets = await ws.assets(Network.Ethereum);
console.log(ethAssets.map((a) => a.symbol));
const myToken = new Asset({
id: 200 as AssetId,
symbol: "PEPE",
name: "Pepe",
decimals: 18,
contractAddress: "0x6982508145454Ce325dDbE47a25d4ec3d2311933",
network: Network.Ethereum,
});
await ws.addAsset(myToken);
await ws.dropAsset(200 as AssetId);
const found = await ws.filter("USDC", "Asset");
const byAddr = await ws.filter("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "Asset");Transaction — build, sign, simulate, send
import { EvmTransaction, Network, type EvmAddress } from "wative-core";
const evmTx = new EvmTransaction({
from: evmAddr.publicKey as EvmAddress,
to: "0x1234567890123456789012345678901234567890" as EvmAddress,
value: 10_000_000n,
chainId: 8453 as ChainId, // required, no default; there is no `network` field
gasLimit: 21000n,
maxFeePerGas: 50_000_000_000n,
maxPriorityFeePerGas: 1_000_000_000n,
});
evmAddr.signTransaction(evmTx); // checks `from` and attaches the transaction to this address
await evmTx.sign(); // resolves once the signature exists
const evmTracker = await evmAddr.sendTransaction(evmTx);
const receipt = await evmTracker.whenConfirmed();For Solana transactions, build instructions with @solana/web3.js (or your preferred Solana toolkit) and pass them to new SvmTransaction({ from, recipient, amount, instructions }). The signing, sending, and tracker lifecycle then mirror the EVM flow.
Hand off to an external signer / RPC client
Both EvmTransaction and SvmTransaction expose toRawTx() for routing the transaction through any chain-native tool you already use:
// EVM — synchronous; returns a plain web3.js / ethers / viem TransactionRequest
const evmTx = evmAddr.buildTransaction({
to: "0x1234567890123456789012345678901234567890" as EvmAddress,
value: 10_000_000n,
gasLimit: 21000n,
});
const rawEvm = evmTx.toRawTx();
// { from, to, value, data, type, chainId, gasLimit, ... } — hand to web3/ethers/viem// Solana — async; returns the underlying @solana/web3.js Transaction
const svmTx = svmAddr.buildTransaction({
recipient: "11111111111111111111111111111112",
amount: 1_000_000n,
});
const rawSvm = await svmTx.toRawTx();
// then rawSvm.add(...) / .sign(...) / connection.sendRawTransaction(rawSvm.serialize())For SVM, if you supply recentBlockhash at construction time, toRawTx() builds entirely offline — no Address binding required.
Workspace search
const account = await ws.filter("Trading Desk", "Account");
const wallet = await ws.filter("alpha-strategy", "Wallet");
const address = await ws.filter("0xab12345678…cdef", "Address");
const asset = await ws.filter("USDC", "Asset");Which storage backend you get
Workspace.open() picks one for you unless you pass a provider:
| environment | default | notes |
|---|---|---|
| Node, with import "wative-core/node" | HybridProviderV3, falling back to HybridProvider | encrypted files on disk |
| Browser | IdbProvider | IndexedDB, in the current origin |
| Node, without the node import | none — throws, and the message tells you to add the import | |
The optional first argument means a path in Node and a database name in the browser, so the same call reads sensibly in both:
await Workspace.open("my-wallet", password); // path in Node, database name in a browser
await Workspace.open({ password }); // omit it for the conventional location / "wative"Two things to know about the Node default:
- It uses
HybridProviderV3when@node-rs/argon2is installed, andHybridProviderwhen it is not. V3 derives one key per workspace instead of one per stored secret, so unlocking an account with many addresses is a single derivation rather than one each.@node-rs/argon2is an optional dependency because a few platforms have no prebuilt binary. - Either provider opens a workspace the other wrote, so falling back costs speed, not access. ⚠️ Older versions of this library cannot open a V3 workspace, and there is no way back — check
argon2BackendInfo()if you need to know which you got.
Pass a provider explicitly whenever you need to configure it — a non-default location, a named database, or acknowledging evictable browser storage:
import { Workspace, IdbProvider } from "wative-core";
const provider = await IdbProvider.create("my-wallet", { acknowledgeEvictionRisk: true });
const ws = await Workspace.open({ provider, password });Records
Everything a workspace persists is a Record — an account, a network, an asset, the config. You mostly work with the domain objects (Account, Network, …) and never touch a Record directly; you need this when you write a custom backend, or when you read records straight off a provider.
A Record loaded from an unlocked provider arrives already decrypted — the provider opens it with the workspace password as it hands it back. Only a record sealed under its own separate password comes back locked:
const record = await provider.loadRecord("ACCOUNTS", "alice");
record.locked; // false — the workspace password opened it
record.value; // your data
record.value.displayName = "Alice Desk";
await record.save(); // re-encrypts and writes back
// A record sealed under its OWN password is the locked case:
const sealed = await provider.loadRecord("ACCOUNTS", "bob");
sealed.locked; // true
sealed.value; // throws RECORD_LOCKED
sealed.unlock(ownPwd); // decrypts in memory
sealed.value; // your data| member | behaviour |
|---|---|
| locked | false when the workspace password opened the record; true when it did not, until unlock() succeeds |
| value | your data when unlocked; throws RECORD_LOCKED while locked |
| unlock(password) | BAD_PASSWORD if wrong, DECRYPT_FAILED if the record cannot be read at all |
| save() | throws if locked, or if the workspace that issued it has since been locked |
Two things to plan for:
- A handle does not outlive its session. A Record kept from an earlier unlock stops working once the workspace is locked, and cannot write over a record that has since been replaced or deleted. Reload it rather than holding it.
- Records are backend-independent.
exportContainer()in a browser imports into a desktop workspace and back — the storage you chose does not change what a workspace is.
Custom storage backends
To store a workspace somewhere else — Redis, S3, SQLite, OPFS, SFTP — extend ContainerProvider and implement six methods that move bytes. The session, the record API and all the encryption come from the base class, so this is the whole backend:
import { Workspace, ContainerProvider, WativeError } from "wative-core";
class MyProvider extends ContainerProvider {
#store = new Map<string, Uint8Array>();
protected _exist(path: string) { return this.#store.has(path); }
protected _read(path: string) {
const v = this.#store.get(path);
if (!v) throw new WativeError("RECORD_NOT_FOUND", path);
return v;
}
protected _write(path: string, data: Uint8Array) { this.#store.set(path, data); }
protected _remove(path: string) { this.#store.delete(path); }
protected _ensureDir(_path: string) {}
protected _listItems(prefix: string) { /* names directly under prefix */ return []; }
}
const ws = await Workspace.open({ provider: new MyProvider("mem://desk"), password });Each method may be sync or async — return a value or a promise — so a synchronous store and a network one share the same contract.
Your code never encrypts anything: the base class hands you sealed bytes and opaque paths. A runnable version, including a check that no readable secret reaches the store, is in 19-simple-provider.test.cjs.
Taking over the record layer as well
Extend Provider instead when the storage system has its own encryption or its own idea of a record. You then implement the six primitives and the container session and record API — see 07-custom-provider.test.cjs for a complete one:
import { Workspace, Provider, Record, type RecordInit, type RecordType } from "wative-core";
import type { Slug } from "wative-core";
class MyDatabaseProvider extends Provider {
constructor(connectionString: string) {
super(connectionString);
}
protected async _exist(path: string) { return false; }
protected async _read(path: string) { return new Uint8Array(); }
protected async _write(path: string, b: Uint8Array) {}
protected async _remove(path: string) {}
protected async _ensureDir(path: string) {}
protected async _listItems(path: string) { return []; }
async unlockContainer(password: string): Promise<boolean> { return true; }
async lockContainer(): Promise<void> {}
isContainerUnlocked(): boolean { return true; }
async loadRecord<T>(type: RecordType, slug?: Slug): Promise<Record<T>> { throw new Error(); }
async loadRecords<T>(type: RecordType): Promise<readonly Record<T>[]> { return []; }
async writeRecord<T>(type: RecordType, slug: Slug, value: T): Promise<void> {}
async dropRecord(type: RecordType, slug: Slug): Promise<void> {}
async close(): Promise<void> {}
}
const ws = await Workspace.open({ provider: new MyDatabaseProvider("postgres://..."), password: "wsp-pwd" });Workspace.open() accepts any Provider subclass, and no library code branches
on which one you passed.
Two obligations the abstract signatures do not state, and one set of defaults you may need to override:
loadRecordmust distinguish "absent" from "unreadable". A record that is not there MUST reject withRECORD_NOT_FOUND. A record that is there but cannot be decrypted MUST come back as a lockedRecord, not as a throw and not by being omitted. A provider that returns a lockedRecordfor a row it does not have makes every name look taken, and no account can be created at all.loadRecordsmust include the rows it could not open, as lockedRecords.Workspacederives its damaged-record list from exactly that.occupiedAccountStems()defaults to empty, which is right for a store whose keys collide only on exact equality. On a store whose collision rule is wider — a case-insensitive filesystem, an S3-style key space, autf8_general_cicolumn — override it to return the lowercased stems. Otherwise a damagedAlice-Deskrow leavesalice-desklooking free and account creation is refused as a name collision. Your bytes are safe either way; the caller just cannot create the account.accountRecordExists()andunreadableRecordSlugs()carry defaults in the same family — see their JSDoc before overriding either.
Adding a chain
To support a blockchain the library does not ship — its own address format, signing and transactions — subclass ChainDialect and register it under a vm token. This is the chain-side parallel to extending Provider for storage: Workspace and Address route every per-chain operation through the registered dialect, so no library code branches on which chain you added.
import { ChainDialect, registerDialect, type ChainCtx } from "wative-core";
class MyDialect extends ChainDialect {
readonly vm = "mychain";
readonly curve = "ed25519"; // reuse a built-in signing curve
signMessage(ctx: ChainCtx, message: string): string { /* build a digest, sign via ctx._signBytes, format */ }
buildTransaction(ctx, params) { /* return your chain's transaction */ }
buildTransferPayload(ctx, req) { /* lower { to, asset, amount } to a transfer payload */ }
derive(seed, index) { /* return { publicKey, privateKey } at this index */ }
addressFromPrivateKey(pk) { /* the address a key controls */ }
privateKeyMatches(pk, publicKey) { /* whether a key controls an address */ }
// signMessageEncoded / signTypedData round out the contract
}
registerDialect("mychain", () => new MyDialect());Your dialect never receives the private key: it signs through a keyless view (ctx._signBytes) the library hands it and declares which built-in curve to use. A runnable end-to-end example — a fictional chain that signs, builds a transaction, transfers and derives — is in 20-custom-chain-dialect.test.cjs.
Imports from sub-paths
Heavier chain helpers are kept on separate import paths so you only pull them in if you need them:
import { ERC20 } from "wative-core/artifacts/evm";
import { TokenProgram, Token2022Program } from "wative-core/artifacts/svm";Compatibility notes
- Content-Security-Policy (browser): grant
'wasm-unsafe-eval'inscript-src, or key derivation silently degrades to a ~17x slower pure-JS path. See Key derivation. - Runtime: Node.js 22.12+. Deno / Bun / browser support is partial. Some chain libraries that ship as compiled dependencies do not have universal builds yet.
- Platform builds: a few compiled dependencies lack pre-built binaries for Windows-ARM64 and Alpine-musl. Most installs on macOS / Linux x64 / Linux ARM64 won't notice.
- One process at a time: opening the same workspace from two Node processes simultaneously isn't supported.
License
Modified MIT — MIT terms with one added condition: products or services earning over US$50,000 monthly revenue must display "Wative" in their user interface. Everything else is standard MIT. The LICENSE file also carries third-party notices for the compiled components embedded in dist/.
