wative-core 2.4.0 → 2.4.1

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 (36) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +127 -91
  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--npoeR2N.d.ts} +1 -1
  12. package/dist/{container-provider-DAUXclw_.d.cts → container-provider-Ca_5z1uQ.d.cts} +1 -1
  13. package/dist/{evm-GxBuNjq7.d.ts → evm-EHpaW_sK.d.ts} +1 -1
  14. package/dist/{evm-qXdZWugm.d.cts → evm-hSJJOVG3.d.cts} +1 -1
  15. package/dist/{file-sink-DSOZbsJM.d.cts → file-sink-7ZAutH45.d.cts} +2 -2
  16. package/dist/{file-sink-B0EjCBvG.d.ts → file-sink-Bk5zYHw-.d.ts} +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-B5q8YaV9.d.ts} +1 -1
  30. package/dist/{svm-B_c0L-rJ.d.cts → svm-CXFwmeMo.d.cts} +1 -1
  31. package/dist/{workspace-DdaY7sWa.d.cts → workspace-kq3QyAgm.d.cts} +46 -9
  32. package/dist/{workspace-DdaY7sWa.d.ts → workspace-kq3QyAgm.d.ts} +46 -9
  33. package/examples/15-package-resolution.test.cjs +4 -4
  34. package/examples/19-simple-provider.test.cjs +134 -0
  35. package/examples/README.md +10 -11
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
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.1] — 2026-08-11
6
+
7
+ One breaking item, and it is the kind worth reading before upgrading: a
8
+ transaction no longer guesses which chain it is for. Alongside it, a browser and
9
+ a Node process now each get a working storage backend without being told which
10
+ one to use, and writing your own backend is documented with a runnable example.
11
+
12
+ ### Security
13
+ - **BREAKING. `EvmTransaction` requires `chainId` and no longer defaults to
14
+ Ethereum mainnet.** Building a transaction without naming a chain used to
15
+ produce chain 1. That is not a harmless default: a signed transaction is a
16
+ bearer instrument, so the result was a genuine, broadcastable Ethereum
17
+ mainnet signature that the caller never asked for. The same applied to anyone
18
+ who reached for a `network` field — transactions have never had one, the key
19
+ was discarded, and chain 1 came back. Both are now refused with
20
+ `PARAMETER_ERROR`, and the message names the `network` trap directly.
21
+
22
+ TypeScript callers using the object form were already required to pass
23
+ `chainId` and are unaffected. This changes behaviour for JavaScript callers,
24
+ and for the positional form, where `opts` and its `chainId` are now required.
25
+
26
+ Nothing changes for `address.buildTransaction()`, which continues to take the
27
+ chain from the address's own network — that remains the shortest correct way
28
+ to build a transaction, and `chainId` there is still optional.
29
+
30
+ ```js
31
+ // before — signed for Ethereum, silently
32
+ new EvmTransaction({ from, to, value });
33
+
34
+ // after — name the chain
35
+ new EvmTransaction({ from, to, value, chainId: 8453 });
36
+ // or let the address decide
37
+ address.buildTransaction({ to, value });
38
+ ```
39
+
40
+ ### Added
41
+ - **A default storage backend in both environments.** `Workspace.open(password)`
42
+ now works in a browser without importing a provider first, using IndexedDB.
43
+ Node continues to resolve its filesystem backend from `wative-core/node`.
44
+ - **A worked example of a custom storage backend**, at
45
+ `examples/19-simple-provider.test.cjs`: six methods that move bytes, running a
46
+ whole workspace on a `Map`. The published type declarations now ship the
47
+ documentation for the types you implement against.
48
+
49
+ ### Changed
50
+ - **Node's default backend is now `HybridProviderV3`**, falling back to
51
+ `HybridProvider` where the optional native dependency has no prebuilt binary.
52
+ Both read each other's containers, so the fallback costs no data.
53
+
5
54
  ## [2.4.0] — 2026-08-11
6
55
 
7
56
  Six breaking items, each one an operation that reported success without having
package/README.md CHANGED
@@ -225,13 +225,13 @@ await otherProvider.importContainer(backup); // same password opens it
225
225
 
226
226
  ## Key derivation
227
227
 
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.
228
+ Passwords are stretched with Argon2id (RFC 9106) before they ever become a key.
229
+ That is deliberately expensive, which is why unlocking takes a moment and why
230
+ the sections below are about speed rather than correctness — every option
231
+ produces the same keys.
231
232
 
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.
233
+ Nothing to install: it runs on a bundled WebAssembly build by default, with no
234
+ native binary and no build step.
235
235
 
236
236
  ### Browser: grant `'wasm-unsafe-eval'`
237
237
 
@@ -239,58 +239,10 @@ step, nothing to install. Measured over a full workspace lifecycle it is about
239
239
  Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'
240
240
  ```
241
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);
269
-
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.
242
+ Optional. Without it nothing breaks and nothing throws — key derivation falls
243
+ back to a slower implementation that produces identical results, so unlocking a
244
+ wallet with a dozen addresses takes tens of seconds instead of about one. Grant
245
+ it if that matters to you. `argon2BackendInfo()` reports which one ran.
294
246
 
295
247
  ### One derivation per container: `HybridProviderV3` (Node only)
296
248
 
@@ -300,34 +252,24 @@ import { Workspace, HybridProviderV3 } from "wative-core";
300
252
  const ws = await Workspace.open(new HybridProviderV3("~/wallets"), password);
301
253
  ```
302
254
 
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.
255
+ `HybridProvider` derives a key per stored secret, so unlocking an account costs
256
+ one derivation per address and gets slower as the account grows.
257
+ `HybridProviderV3` derives once per workspace instead — unlocking a 50-address
258
+ account goes from about 7 seconds to under 200 ms, and stays there however many
259
+ addresses you add.
309
260
 
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.
261
+ It is the Node default where `@node-rs/argon2` is installed, and Node-only: that
262
+ package has no browser build. Construct it yourself only to pick a location, or
263
+ to be sure you get it rather than the fallback.
313
264
 
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.
265
+ Two things to plan for:
321
266
 
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.
267
+ - **An existing workspace does not get faster on its own.** Secrets written
268
+ before the switch keep their own keys, so they keep costing one derivation
269
+ each. `account.resetPassword(old, new)` rewrites them.
270
+ - ⚠️ **Older versions of this library cannot open a V3 workspace**, and there is
271
+ no way back. Once a workspace has been opened with V3, keep using a version
272
+ that supports it.
331
273
 
332
274
  ### Checking which implementation ran
333
275
 
@@ -352,7 +294,7 @@ An `examples/` folder ships with the package — runnable JavaScript files demon
352
294
  Copy them out of `node_modules` first, then run the whole set:
353
295
 
354
296
  ```bash
355
- cp -R node_modules/wative-core/tests wative-examples
297
+ cp -R node_modules/wative-core/examples wative-examples
356
298
  node --test wative-examples/
357
299
  ```
358
300
 
@@ -362,7 +304,7 @@ The copy is required: Node's test runner skips anything under `node_modules`, so
362
304
  node node_modules/wative-core/examples/01-quick-start.test.cjs
363
305
  ```
364
306
 
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.
307
+ 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
308
 
367
309
  ## What you can do
368
310
 
@@ -518,11 +460,10 @@ const svmAddr = hd.wallets[0].addresses.find((a) => a.vm === "svm")!;
518
460
 
519
461
  const sig = evmAddr.signMessage("hello hedgue");
520
462
 
521
- import { Network } from "wative-core";
522
- const tx = await evmAddr.buildTransaction({
463
+ const tx = evmAddr.buildTransaction({
523
464
  to: "0x1234567890123456789012345678901234567890",
524
465
  value: 1_000_000_000_000_000n,
525
- network: Network.Ethereum,
466
+ chainId: 1,
526
467
  });
527
468
  await evmAddr.signTransaction(tx);
528
469
  const tracker = await evmAddr.sendTransaction(tx);
@@ -606,7 +547,11 @@ const evmTx = new EvmTransaction({
606
547
  from: evmAddr.publicKey as EvmAddress,
607
548
  to: "0x1234567890123456789012345678901234567890" as EvmAddress,
608
549
  value: 10_000_000n,
609
- network: Network.Base,
550
+ // The chain is set HERE, by id. `chainId` is required and has no default,
551
+ // and there is no `network` field on a transaction — both are refused
552
+ // rather than guessed, because a signed transaction for the wrong chain is
553
+ // still a valid transaction on that chain.
554
+ chainId: 8453 as ChainId, // Base
610
555
  gasLimit: 21000n,
611
556
  maxFeePerGas: 50_000_000_000n,
612
557
  maxPriorityFeePerGas: 1_000_000_000n,
@@ -617,7 +562,7 @@ const evmTracker = await evmAddr.sendTransaction(evmTx);
617
562
  const receipt = await evmTracker.whenConfirmed(3);
618
563
  ```
619
564
 
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.
565
+ 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
566
 
622
567
  #### Hand off to an external signer / RPC client
623
568
 
@@ -658,9 +603,100 @@ const address = await ws.filter("0xab12345678…cdef", "Address");
658
603
  const asset = await ws.filter("USDC", "Asset");
659
604
  ```
660
605
 
606
+ ## Which storage backend you get
607
+
608
+ `Workspace.open()` picks one for you unless you pass a provider:
609
+
610
+ | environment | default | notes |
611
+ |---|---|---|
612
+ | Node, with `import "wative-core/node"` | `HybridProviderV3`, falling back to `HybridProvider` | encrypted files on disk |
613
+ | Browser | `IdbProvider` | IndexedDB, in the current origin |
614
+ | Node, without the node import | *none* — throws, and the message tells you to add the import | |
615
+
616
+ The optional first argument means a **path** in Node and a **database name** in the browser, so the same call reads sensibly in both:
617
+
618
+ ```ts
619
+ await Workspace.open("my-wallet", password); // path in Node, database name in a browser
620
+ await Workspace.open({ password }); // omit it for the conventional location / "wative"
621
+ ```
622
+
623
+ Two things to know about the Node default:
624
+
625
+ - 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.
626
+ - 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.
627
+
628
+ Pass a provider explicitly whenever you need to configure it — a non-default location, a named database, or acknowledging evictable browser storage:
629
+
630
+ ```ts
631
+ import { Workspace, IdbProvider } from "wative-core";
632
+
633
+ const provider = await IdbProvider.create("my-wallet", { acknowledgeEvictionRisk: true });
634
+ const ws = await Workspace.open({ provider, password });
635
+ ```
636
+
637
+ ## Records
638
+
639
+ 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.
640
+
641
+ A Record arrives **locked** and holds ciphertext until you unlock it:
642
+
643
+ ```ts
644
+ const record = await provider.loadRecord("ACCOUNTS", "alice");
645
+
646
+ record.locked; // true
647
+ record.value; // throws RECORD_LOCKED
648
+
649
+ record.unlock(password); // decrypts in memory
650
+ record.value; // your data
651
+
652
+ record.value.displayName = "Alice Desk";
653
+ await record.save(); // re-encrypts and writes back
654
+ ```
655
+
656
+ | member | behaviour |
657
+ |---|---|
658
+ | `locked` | `true` until `unlock()` succeeds |
659
+ | `value` | throws `RECORD_LOCKED` while locked |
660
+ | `unlock(password)` | `BAD_PASSWORD` if wrong, `DECRYPT_FAILED` if the record cannot be read at all |
661
+ | `save()` | throws if locked, or if the workspace that issued it has since been locked |
662
+
663
+ Two things to plan for:
664
+
665
+ - **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.
666
+ - **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.
667
+
661
668
  ## Custom storage backends
662
669
 
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()`:
670
+ 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:
671
+
672
+ ```ts
673
+ import { Workspace, ContainerProvider, WativeError } from "wative-core";
674
+
675
+ class MyProvider extends ContainerProvider {
676
+ #store = new Map<string, Uint8Array>();
677
+
678
+ protected _exist(path: string) { return this.#store.has(path); }
679
+ protected _read(path: string) {
680
+ const v = this.#store.get(path);
681
+ if (!v) throw new WativeError("RECORD_NOT_FOUND", path);
682
+ return v;
683
+ }
684
+ protected _write(path: string, data: Uint8Array) { this.#store.set(path, data); }
685
+ protected _remove(path: string) { this.#store.delete(path); }
686
+ protected _ensureDir(_path: string) {}
687
+ protected _listItems(prefix: string) { /* names directly under prefix */ return []; }
688
+ }
689
+
690
+ const ws = await Workspace.open({ provider: new MyProvider("mem://desk"), password });
691
+ ```
692
+
693
+ 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.
694
+
695
+ 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).
696
+
697
+ ### Taking over the record layer as well
698
+
699
+ 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
700
 
665
701
  ```ts
666
702
  import { Workspace, Provider, Record, type RecordInit, type RecordType } from "wative-core";