wative-core 2.4.0 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +163 -112
  3. package/dist/artifacts/evm/index.cjs +1 -1
  4. package/dist/artifacts/evm/index.d.cts +2 -2
  5. package/dist/artifacts/evm/index.d.ts +2 -2
  6. package/dist/artifacts/evm/index.js +1 -1
  7. package/dist/artifacts/svm/index.cjs +1 -1
  8. package/dist/artifacts/svm/index.d.cts +3 -3
  9. package/dist/artifacts/svm/index.d.ts +3 -3
  10. package/dist/artifacts/svm/index.js +1 -1
  11. package/dist/{container-provider-D2QluIe_.d.ts → container-provider-B8M0ylrK.d.ts} +1 -1
  12. package/dist/{container-provider-DAUXclw_.d.cts → container-provider-YxyMpkhG.d.cts} +1 -1
  13. package/dist/{evm-GxBuNjq7.d.ts → evm-Bo3Dy492.d.ts} +1 -1
  14. package/dist/{evm-qXdZWugm.d.cts → evm-DA6Sh6jv.d.cts} +1 -1
  15. package/dist/{file-sink-B0EjCBvG.d.ts → file-sink-Be5iLbuT.d.ts} +2 -2
  16. package/dist/{file-sink-DSOZbsJM.d.cts → file-sink-KCCpKHRj.d.cts} +2 -2
  17. package/dist/index.cjs +1 -1
  18. package/dist/index.d.cts +6 -6
  19. package/dist/index.d.ts +6 -6
  20. package/dist/index.js +1 -1
  21. package/dist/index.node.cjs +1 -1
  22. package/dist/index.node.d.cts +5 -5
  23. package/dist/index.node.d.ts +5 -5
  24. package/dist/index.node.js +1 -1
  25. package/dist/node/index.cjs +1 -1
  26. package/dist/node/index.d.cts +3 -3
  27. package/dist/node/index.d.ts +3 -3
  28. package/dist/node/index.js +1 -1
  29. package/dist/{svm-Ca4Xyw0Y.d.ts → svm-B51NevS8.d.ts} +1 -1
  30. package/dist/{svm-B_c0L-rJ.d.cts → svm-DswX6XRv.d.cts} +1 -1
  31. package/dist/{workspace-DdaY7sWa.d.cts → workspace-CBxKR80D.d.cts} +48 -10
  32. package/dist/{workspace-DdaY7sWa.d.ts → workspace-CBxKR80D.d.ts} +48 -10
  33. package/examples/03-pk-account.test.cjs +65 -0
  34. package/examples/15-package-resolution.test.cjs +4 -4
  35. package/examples/19-simple-provider.test.cjs +134 -0
  36. package/examples/README.md +11 -12
  37. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,94 @@
2
2
 
3
3
  All notable changes to `wative-core` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [2.4.2] — 2026-08-11
6
+
7
+ Importing a private key gets easier and does more: you no longer have to say
8
+ which chain a key is for, more of the formats you actually hold are accepted,
9
+ and one import now gives you an address on both chains.
10
+
11
+ ### Changed
12
+ - **BREAKING. A PK wallet now holds two addresses instead of one.** Importing a
13
+ key gives you an address on the chain that key is for, plus one on the other
14
+ chain seeded by the same 32 secret bytes. The chain you imported for stays at
15
+ `addresses[0]`, so code reading that position is unaffected — but
16
+ `addresses.length` is now 2, and totals derived from it will differ.
17
+
18
+ ⚠️ **One secret controls both addresses.** There is only one key to import,
19
+ so both addresses come from it, and whoever learns that key holds both
20
+ chains. This is not how an HD account behaves: there a slot's two addresses
21
+ come from two derivation paths and are independent keys. Use an HD account,
22
+ or import a separate key per chain, if you need that separation.
23
+
24
+ Wallets already stored keep the single address they were written with. Only
25
+ new imports are paired.
26
+
27
+ ### Added
28
+ - **`importPrivateKey(pk)` no longer needs a `vm`.** The accepted formats do not
29
+ overlap — 64 hex characters for EVM, 80–96 base58 for Solana — so the chain is
30
+ read off the key. Passing `vm` explicitly still works and still wins.
31
+ - **`solana-keygen` JSON byte arrays are accepted.** The contents of
32
+ `~/.config/solana/id.json` can be handed to `importPrivateKey` as-is, instead
33
+ of being converted to base58 by hand first.
34
+
35
+ ### Fixed
36
+ - **An out-of-range EVM private key is now `INVALID_PRIVATE_KEY`.** 32 bytes of
37
+ hex is not necessarily a key: zero, and anything at or above the secp256k1
38
+ group order, previously reached the curve library and surfaced as a bare
39
+ `Error` with no `code`, thrown from inside a queued mutation. It is refused at
40
+ the boundary now, like every other malformed key.
41
+ - Corrected the README's account diagrams, which described a PK wallet as
42
+ carrying exactly one address as though it were a structural rule.
43
+
44
+ ## [2.4.1] — 2026-08-11
45
+
46
+ One breaking item, and it is the kind worth reading before upgrading: a
47
+ transaction no longer guesses which chain it is for. Alongside it, a browser and
48
+ a Node process now each get a working storage backend without being told which
49
+ one to use, and writing your own backend is documented with a runnable example.
50
+
51
+ ### Security
52
+ - **BREAKING. `EvmTransaction` requires `chainId` and no longer defaults to
53
+ Ethereum mainnet.** Building a transaction without naming a chain used to
54
+ produce chain 1. That is not a harmless default: a signed transaction is a
55
+ bearer instrument, so the result was a genuine, broadcastable Ethereum
56
+ mainnet signature that the caller never asked for. The same applied to anyone
57
+ who reached for a `network` field — transactions have never had one, the key
58
+ was discarded, and chain 1 came back. Both are now refused with
59
+ `PARAMETER_ERROR`, and the message names the `network` trap directly.
60
+
61
+ TypeScript callers using the object form were already required to pass
62
+ `chainId` and are unaffected. This changes behaviour for JavaScript callers,
63
+ and for the positional form, where `opts` and its `chainId` are now required.
64
+
65
+ Nothing changes for `address.buildTransaction()`, which continues to take the
66
+ chain from the address's own network — that remains the shortest correct way
67
+ to build a transaction, and `chainId` there is still optional.
68
+
69
+ ```js
70
+ // before — signed for Ethereum, silently
71
+ new EvmTransaction({ from, to, value });
72
+
73
+ // after — name the chain
74
+ new EvmTransaction({ from, to, value, chainId: 8453 });
75
+ // or let the address decide
76
+ address.buildTransaction({ to, value });
77
+ ```
78
+
79
+ ### Added
80
+ - **A default storage backend in both environments.** `Workspace.open(password)`
81
+ now works in a browser without importing a provider first, using IndexedDB.
82
+ Node continues to resolve its filesystem backend from `wative-core/node`.
83
+ - **A worked example of a custom storage backend**, at
84
+ `examples/19-simple-provider.test.cjs`: six methods that move bytes, running a
85
+ whole workspace on a `Map`. The published type declarations now ship the
86
+ documentation for the types you implement against.
87
+
88
+ ### Changed
89
+ - **Node's default backend is now `HybridProviderV3`**, falling back to
90
+ `HybridProvider` where the optional native dependency has no prebuilt binary.
91
+ Both read each other's containers, so the fallback costs no data.
92
+
5
93
  ## [2.4.0] — 2026-08-11
6
94
 
7
95
  Six breaking items, each one an operation that reported success without having
package/README.md CHANGED
@@ -44,28 +44,64 @@ Account "Trading Desk" (HD) one mnemonic
44
44
  └── … account.deriveWallets(n)
45
45
  ```
46
46
 
47
- **PK** — imported keys. A `Wallet` is one key you handed in, on the one chain
48
- you imported it for. Wallets have no relationship to each other:
47
+ **PK** — imported keys. Each import allocates its own `Wallet`, and that wallet
48
+ gets an address on **both** chains: the key signs on its own curve, and the same
49
+ 32 secret bytes seed a keypair on the other one. The chain you imported for
50
+ comes first.
49
51
 
50
52
  ```
51
53
  Account "Cold Storage" (PK) no mnemonic
52
54
  ├── Wallet 0 the key passed to accounts.create()
53
- │ └── Address vm: "evm" 0x90F8bf6A…
54
- ├── Wallet 1 account.importPrivateKey(pk, "evm")
55
- │ └── Address vm: "evm" 0xFFcf8FDE…
56
- └── Wallet 2 account.importPrivateKey(pk, "svm")
57
- └── Address vm: "svm" 7WktogJEd… one chain per wallet
55
+ │ ├── Address vm: "evm" 0x90F8bf6A… the imported key
56
+ │ └── Address vm: "svm" HaWmh8svNQ… same secret, other curve
57
+ ├── Wallet 1 account.importPrivateKey(evmKey)
58
+ │ ├── Address vm: "evm" 0xFFcf8FDE…
59
+ │ └── Address vm: "svm" Fgdy5QRxtP…
60
+ └── Wallet 2 account.importPrivateKey(svmKey)
61
+ ├── Address vm: "svm" 3QVq8D876h… the imported key
62
+ └── Address vm: "evm" 0x1a740984… same secret, other curve
63
+ ```
64
+
65
+ A PK account can hold as many keys as you like, on any mix of chains — but it
66
+ refuses the same identity twice, on either chain.
67
+
68
+ > ⚠️ **One secret controls both addresses in a PK wallet.** There is only one
69
+ > key to import, so both addresses come from it. Whoever learns that key holds
70
+ > both chains.
71
+ >
72
+ > This is not how an HD account works. There, a slot's two addresses come from
73
+ > two different derivation paths — the mnemonic is shared, the private keys are
74
+ > independent. If you want independent keys per chain, use an HD account, or
75
+ > import a separate key for each and read `addresses[0]` of each wallet.
76
+
77
+ > **`wallet.addresses` is a list.** It holds one address per `(vm, network)`
78
+ > pair and is free to carry several. Read it rather than assuming a position:
79
+ >
80
+ > ```ts
81
+ > const evm = wallet.addresses.find((a) => a.vm === "evm");
82
+ > ```
83
+
84
+ **Which chain a key is for is read off the key** — `vm` is optional:
85
+
86
+ ```ts
87
+ await acc.importPrivateKey(evmKey); // 64 hex chars -> evm
88
+ await acc.importPrivateKey(solanaKey); // base58 -> svm
89
+ await acc.importPrivateKey(readFileSync("id.json")); // solana-keygen -> svm
90
+ await acc.importPrivateKey(key, "evm"); // or say so explicitly
58
91
  ```
59
92
 
60
- A PK account can hold as many keys as you like, and they can be on different
61
- chains — but it refuses the same identity twice.
93
+ The formats do not overlap, so this is a decision rather than a guess. A value
94
+ that is neither is refused with `INVALID_PRIVATE_KEY` — including 32 bytes of
95
+ hex that is not a valid secp256k1 scalar, and a bare 32-byte base58 value,
96
+ which is indistinguishable from a Solana *public* key.
62
97
 
63
98
  | | HD | PK |
64
99
  | ---------------------- | ----------------------------- | ------------------------------- |
65
100
  | Created from | a BIP-39 mnemonic | one private key |
66
101
  | Grows with | `deriveWallets(n)` | `importPrivateKey(pk, vm)` |
67
102
  | A `Wallet` is | a derivation slot | one imported key |
68
- | Addresses per `Wallet` | **2** — one evm, one svm | **1** — the chain you imported |
103
+ | Addresses per `Wallet` | **2** — one evm, one svm | **2** — imported chain first |
104
+ | Those two addresses | independent keys, two paths | **one key, both curves** |
69
105
  | `dumpMnemonic()` | ✔ | throws `UNSUPPORTED_OP` |
70
106
  | `sliceWallets(n)` | ✔ | throws `UNSUPPORTED_OP` |
71
107
  | `importPrivateKey()` | throws `UNSUPPORTED_OP` | ✔ |
@@ -150,7 +186,6 @@ await ws.lock();
150
186
  | Storage | encrypted files on disk | IndexedDB |
151
187
  | Where it lives | `Workspace.open({ path })`, or default | `IdbProvider.create(name)` |
152
188
  | Extra setup | none | `Buffer` polyfill |
153
- | Keys can vanish| no | yes — storage is evictable |
154
189
 
155
190
  `HybridProvider`, `HybridProviderV3` and `FileSink` are re-exported from the
156
191
  package root in Node.
@@ -225,72 +260,13 @@ await otherProvider.importContainer(backup); // same password opens it
225
260
 
226
261
  ## Key derivation
227
262
 
228
- Passwords are stretched with Argon2id (RFC 9106) at t=3, m=64 MiB, p=1 before
229
- they ever touch a key. That is deliberately expensive, and an account unlock
230
- runs one derivation per sealed secret — so the implementation matters.
231
-
232
- By default it runs on a vendored WebAssembly build: no native binary, no build
233
- step, nothing to install. Measured over a full workspace lifecycle it is about
234
- **25x** faster than the pure-JS implementation it replaced.
235
-
236
- ### Browser: grant `'wasm-unsafe-eval'`
237
-
238
- ```
239
- Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'
240
- ```
241
-
242
- Without it the browser blocks WebAssembly compilation entirely — synchronously
243
- and asynchronously, main thread and worker alike. **Nothing throws.** Key
244
- derivation quietly falls back to a pure-JS implementation that produces
245
- identical bytes roughly 17x slower, so unlocking a wallet with a dozen addresses
246
- goes from under a second to tens of seconds. The fallback is announced once on
247
- the console and reported permanently by `argon2BackendInfo()`.
248
-
249
- ## Upgrading to 2.3.0
250
-
251
- **v1 containers can no longer be opened.** Support for the v1 envelope
252
- (PBKDF2-HMAC-SHA256, written by wative-core 1.x) was removed and there is no
253
- migration path in this release. Containers written by any 2.x version are v2 and
254
- open unchanged.
255
-
256
- If you hold a v1 container, recover it with **2.2.x or earlier** before
257
- upgrading. `exportContainer()` will not do it — it copies sealed bytes without
258
- decrypting, so the export is as unreadable as the original. Use the accessors
259
- that return plaintext:
260
-
261
- ```ts
262
- // under wative-core 2.2.x — NOT 2.3.0, which can no longer open a v1 container
263
- import { Workspace, HybridProvider } from "wative-core";
264
-
265
- const ws = await Workspace.open(new HybridProvider("~/wallets"), password);
266
-
267
- for (const account of ws.accounts) {
268
- await account.tryUnlock(password);
263
+ Passwords are stretched with Argon2id (RFC 9106) before they ever become a key.
264
+ That is deliberately expensive, which is why unlocking takes a moment and why
265
+ the sections below are about speed rather than correctness — every option
266
+ produces the same keys.
269
267
 
270
- // HD accounts: the mnemonic regenerates every derived key, so it is the only
271
- // thing you need.
272
- if (account.organizationType === "HD") {
273
- console.log(account.slug, "mnemonic:", account.dumpMnemonic());
274
- continue;
275
- }
276
-
277
- // PK accounts: there is no mnemonic, so every imported key must be kept.
278
- // `dumpPrivateKey` lives on the WALLET and takes the VM of the address you
279
- // want — a wallet holds at most one address per VM.
280
- for (const wallet of account.wallets) {
281
- for (const address of wallet.addresses) {
282
- console.log(account.slug, wallet.id, address.vm, wallet.dumpPrivateKey(address.vm));
283
- }
284
- }
285
- }
286
- ```
287
-
288
- Keep that material somewhere safe, upgrade, then re-import it with
289
- `accounts.create(...)` and `importPrivateKey(...)`.
290
-
291
- Three public members were removed with v1: `Provider.passwordTanren`,
292
- `ContainerProvider.passwordTanren` and `HybridProvider.kdfIterations`. All three
293
- existed only to expose the PBKDF2 derivation.
268
+ Nothing to install: it runs on a bundled WebAssembly build by default, with no
269
+ native binary and no build step.
294
270
 
295
271
  ### One derivation per container: `HybridProviderV3` (Node only)
296
272
 
@@ -300,34 +276,15 @@ import { Workspace, HybridProviderV3 } from "wative-core";
300
276
  const ws = await Workspace.open(new HybridProviderV3("~/wallets"), password);
301
277
  ```
302
278
 
303
- The default provider derives an Argon2 key per sealed secret, so unlocking an
304
- account costs one derivation per address — at production parameters that is
305
- essentially the whole cost, and it grows with the account. `HybridProviderV3`
306
- derives once per container instead. Measured: unlocking a 50-address account
307
- goes from 7.3 s to 144 ms, and the v3 figure does not grow with the address
308
- count.
309
-
310
- It is opt-in, and Node-only: it derives through native `@node-rs/argon2`, which
311
- has no browser build. In a browser use `HybridProvider`, which needs no binary
312
- and no install and is already about 25x faster than what preceded it.
313
-
314
- ⚠️ **The speedup applies to records written as v3, not to ones you already
315
- have.** Pointing `HybridProviderV3` at a container built by 2.x keeps every
316
- existing secret on its own salt, so it keeps costing one derivation each — the
317
- container still opens, it just does not get faster. Rewriting an account's
318
- secrets with `account.resetPassword(old, new)` converts them. A container
319
- reaches one derivation once all of its records have been written as v3, which
320
- happens on its own only for containers created under v3.
279
+ `HybridProvider` derives a key per stored secret, so unlocking an account costs
280
+ one derivation per address and gets slower as the account grows.
281
+ `HybridProviderV3` derives once per workspace instead — unlocking a 50-address
282
+ account goes from about 7 seconds to under 200 ms, and stays there however many
283
+ addresses you add.
321
284
 
322
- The first v3 unlock of an existing container costs one extra derivation, once,
323
- and converts its CONFIG record to v3 — after which wative-core 2.2.x and earlier
324
- can no longer open it.
325
-
326
- Reads do not depend on the choice — every record carries its own version byte,
327
- so either 2.3.0 provider opens either format, in both directions. ⚠️ But **older
328
- library versions cannot read v3**: wative-core 2.2.x and earlier know only v1 and
329
- v2 and will fail with `Unknown envelope version: 3`. Adopting v3 is a one-way
330
- door with respect to the library version.
285
+ It is the Node default where `@node-rs/argon2` is installed, and Node-only: that
286
+ package has no browser build. Construct it yourself only to pick a location, or
287
+ to be sure you get it rather than the fallback.
331
288
 
332
289
  ### Checking which implementation ran
333
290
 
@@ -352,7 +309,7 @@ An `examples/` folder ships with the package — runnable JavaScript files demon
352
309
  Copy them out of `node_modules` first, then run the whole set:
353
310
 
354
311
  ```bash
355
- cp -R node_modules/wative-core/tests wative-examples
312
+ cp -R node_modules/wative-core/examples wative-examples
356
313
  node --test wative-examples/
357
314
  ```
358
315
 
@@ -362,7 +319,7 @@ The copy is required: Node's test runner skips anything under `node_modules`, so
362
319
  node node_modules/wative-core/examples/01-quick-start.test.cjs
363
320
  ```
364
321
 
365
- Files 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, and package resolution. See [examples/README.md](./examples/README.md) for the index.
322
+ Files 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, and one-key-per-workspace derivation. See [examples/README.md](./examples/README.md) for the full index — it is the list that stays current.
366
323
 
367
324
  ## What you can do
368
325
 
@@ -370,7 +327,7 @@ The library is organized around seven things you'll work with:
370
327
 
371
328
  - **`Workspace`** — your top-level container. Holds the password, your accounts, your network and asset list, and a built-in logger.
372
329
  - **`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.
373
- - **`Wallet`** — a unit inside an account. HD wallets carry one EVM and one Solana address; PK wallets carry one address.
330
+ - **`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. Treat `wallet.addresses` as the list it is.
374
331
  - **`Address`** — an on-chain identity. Sign messages, build transactions, send them.
375
332
  - **`Network`** — chain metadata. 10 networks ship pre-loaded.
376
333
  - **`Asset`** — token metadata. 25 tokens ship pre-loaded.
@@ -518,11 +475,10 @@ const svmAddr = hd.wallets[0].addresses.find((a) => a.vm === "svm")!;
518
475
 
519
476
  const sig = evmAddr.signMessage("hello hedgue");
520
477
 
521
- import { Network } from "wative-core";
522
- const tx = await evmAddr.buildTransaction({
478
+ const tx = evmAddr.buildTransaction({
523
479
  to: "0x1234567890123456789012345678901234567890",
524
480
  value: 1_000_000_000_000_000n,
525
- network: Network.Ethereum,
481
+ chainId: 1,
526
482
  });
527
483
  await evmAddr.signTransaction(tx);
528
484
  const tracker = await evmAddr.sendTransaction(tx);
@@ -606,7 +562,11 @@ const evmTx = new EvmTransaction({
606
562
  from: evmAddr.publicKey as EvmAddress,
607
563
  to: "0x1234567890123456789012345678901234567890" as EvmAddress,
608
564
  value: 10_000_000n,
609
- network: Network.Base,
565
+ // The chain is set HERE, by id. `chainId` is required and has no default,
566
+ // and there is no `network` field on a transaction — both are refused
567
+ // rather than guessed, because a signed transaction for the wrong chain is
568
+ // still a valid transaction on that chain.
569
+ chainId: 8453 as ChainId, // Base
610
570
  gasLimit: 21000n,
611
571
  maxFeePerGas: 50_000_000_000n,
612
572
  maxPriorityFeePerGas: 1_000_000_000n,
@@ -617,7 +577,7 @@ const evmTracker = await evmAddr.sendTransaction(evmTx);
617
577
  const receipt = await evmTracker.whenConfirmed(3);
618
578
  ```
619
579
 
620
- For Solana transactions, build instructions with `@solana/web3.js` (or your preferred Solana toolkit) and pass them to `new SvmTransaction({ from, instructions, network })`. The signing, sending, and tracker lifecycle then mirror the EVM flow.
580
+ 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.
621
581
 
622
582
  #### Hand off to an external signer / RPC client
623
583
 
@@ -658,9 +618,100 @@ const address = await ws.filter("0xab12345678…cdef", "Address");
658
618
  const asset = await ws.filter("USDC", "Asset");
659
619
  ```
660
620
 
621
+ ## Which storage backend you get
622
+
623
+ `Workspace.open()` picks one for you unless you pass a provider:
624
+
625
+ | environment | default | notes |
626
+ |---|---|---|
627
+ | Node, with `import "wative-core/node"` | `HybridProviderV3`, falling back to `HybridProvider` | encrypted files on disk |
628
+ | Browser | `IdbProvider` | IndexedDB, in the current origin |
629
+ | Node, without the node import | *none* — throws, and the message tells you to add the import | |
630
+
631
+ The optional first argument means a **path** in Node and a **database name** in the browser, so the same call reads sensibly in both:
632
+
633
+ ```ts
634
+ await Workspace.open("my-wallet", password); // path in Node, database name in a browser
635
+ await Workspace.open({ password }); // omit it for the conventional location / "wative"
636
+ ```
637
+
638
+ Two things to know about the Node default:
639
+
640
+ - It uses `HybridProviderV3` when `@node-rs/argon2` is installed, and `HybridProvider` when 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/argon2` is an optional dependency because a few platforms have no prebuilt binary.
641
+ - 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.
642
+
643
+ Pass a provider explicitly whenever you need to configure it — a non-default location, a named database, or acknowledging evictable browser storage:
644
+
645
+ ```ts
646
+ import { Workspace, IdbProvider } from "wative-core";
647
+
648
+ const provider = await IdbProvider.create("my-wallet", { acknowledgeEvictionRisk: true });
649
+ const ws = await Workspace.open({ provider, password });
650
+ ```
651
+
652
+ ## Records
653
+
654
+ 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.
655
+
656
+ A Record arrives **locked** and holds ciphertext until you unlock it:
657
+
658
+ ```ts
659
+ const record = await provider.loadRecord("ACCOUNTS", "alice");
660
+
661
+ record.locked; // true
662
+ record.value; // throws RECORD_LOCKED
663
+
664
+ record.unlock(password); // decrypts in memory
665
+ record.value; // your data
666
+
667
+ record.value.displayName = "Alice Desk";
668
+ await record.save(); // re-encrypts and writes back
669
+ ```
670
+
671
+ | member | behaviour |
672
+ |---|---|
673
+ | `locked` | `true` until `unlock()` succeeds |
674
+ | `value` | throws `RECORD_LOCKED` while locked |
675
+ | `unlock(password)` | `BAD_PASSWORD` if wrong, `DECRYPT_FAILED` if the record cannot be read at all |
676
+ | `save()` | throws if locked, or if the workspace that issued it has since been locked |
677
+
678
+ Two things to plan for:
679
+
680
+ - **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.
681
+ - **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.
682
+
661
683
  ## Custom storage backends
662
684
 
663
- By default, `Workspace.open(...)` stores everything as encrypted files under the path you pass it. If you want to store the workspace somewhere else — a database, an object store, a browser's IndexedDB — extend the `Provider` base class and pass an instance to `Workspace.open()`:
685
+ 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:
686
+
687
+ ```ts
688
+ import { Workspace, ContainerProvider, WativeError } from "wative-core";
689
+
690
+ class MyProvider extends ContainerProvider {
691
+ #store = new Map<string, Uint8Array>();
692
+
693
+ protected _exist(path: string) { return this.#store.has(path); }
694
+ protected _read(path: string) {
695
+ const v = this.#store.get(path);
696
+ if (!v) throw new WativeError("RECORD_NOT_FOUND", path);
697
+ return v;
698
+ }
699
+ protected _write(path: string, data: Uint8Array) { this.#store.set(path, data); }
700
+ protected _remove(path: string) { this.#store.delete(path); }
701
+ protected _ensureDir(_path: string) {}
702
+ protected _listItems(prefix: string) { /* names directly under prefix */ return []; }
703
+ }
704
+
705
+ const ws = await Workspace.open({ provider: new MyProvider("mem://desk"), password });
706
+ ```
707
+
708
+ 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.
709
+
710
+ 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](./examples/19-simple-provider.test.cjs).
711
+
712
+ ### Taking over the record layer as well
713
+
714
+ 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](./examples/07-custom-provider.test.cjs) for a complete one:
664
715
 
665
716
  ```ts
666
717
  import { Workspace, Provider, Record, type RecordInit, type RecordType } from "wative-core";