@quaivault/sdk 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QuaiVault
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,387 @@
1
+ # @quaivault/sdk
2
+
3
+ TypeScript SDK for [QuaiVault](https://quaivault.org) multisig vaults on Quai Network.
4
+
5
+ Reads go through the indexer when it is fresh and the chain when it is not; writes always
6
+ re-validate on chain before signing. Works in Node and the browser, with any signer.
7
+
8
+ ```bash
9
+ npm install @quaivault/sdk
10
+ ```
11
+
12
+ **Documentation:** [quaivault.org/docs/sdk](https://quaivault.org/docs/sdk) —
13
+ [guides](https://quaivault.org/docs/sdk-guides) ·
14
+ [API reference](https://quaivault.org/docs/sdk-reference)
15
+
16
+ ## Quick start
17
+
18
+ ```ts
19
+ import { connect } from '@quaivault/sdk';
20
+
21
+ // Read-only. No wallet, no key.
22
+ const qv = connect({ network: 'mainnet' });
23
+
24
+ const vault = qv.vault('0x005f2629A632962f4944d23686efDa5c160d535b');
25
+ const info = await vault.info();
26
+ console.log(`${info.threshold}-of-${info.owners.length}, balance ${info.balance}`);
27
+
28
+ for (const tx of await vault.pendingTransactions()) {
29
+ console.log(tx.status, `${tx.approvalCount}/${tx.threshold}`, tx.summary);
30
+ }
31
+ ```
32
+
33
+ Add a signer to write:
34
+
35
+ ```ts
36
+ const qv = connect({ network: 'mainnet', privateKey: process.env.QUAIVAULT_PRIVATE_KEY });
37
+
38
+ const { txHash } = await qv.vault(address).propose.transfer({
39
+ to: '0x00…',
40
+ amount: 1_000000000000000000n, // 1 QUAI
41
+ });
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ Everything is configurable by environment variable, explicit option, or network preset.
47
+ **Explicit options win, then the environment, then the preset.** Nothing is read at import
48
+ time, so importing the SDK never touches `process.env`.
49
+
50
+ See [`.env.example`](./.env.example) for the full list. The essentials:
51
+
52
+ | Variable | Purpose |
53
+ |---|---|
54
+ | `QUAIVAULT_NETWORK` | `mainnet` or `testnet` |
55
+ | `QUAIVAULT_RPC_URL` | Quai RPC base URL (shard path appended automatically) |
56
+ | `QUAIVAULT_PRIVATE_KEY` | Signing key — required only for writes |
57
+ | `QUAIVAULT_INDEXER_ANON_KEY` | Override the shipped read-only indexer key |
58
+ | `QUAIVAULT_CONSISTENCY` | `auto` (default), `indexed`, or `chain` |
59
+
60
+ Networks:
61
+
62
+ | Preset | Chain ID | RPC | Indexer schema |
63
+ |---|---|---|---|
64
+ | `mainnet` | 9 | `https://rpc.quai.network` | `mainnet` |
65
+ | `testnet` (Orchard) | 15000 | `https://orchard.rpc.quai.network` | `testnet` |
66
+
67
+ Indexer credentials ship with the SDK, so discovery, history and realtime work with no
68
+ configuration. The publishable key is a public credential — the indexer's RLS grants `anon`
69
+ `SELECT` and nothing else, with every write reserved for `service_role`. Override it only
70
+ to point at a self-hosted indexer or if it is rotated ahead of a release.
71
+
72
+ ## Three things worth knowing
73
+
74
+ ### 1. A successful receipt does not mean the transaction executed
75
+
76
+ QuaiVault has three paths where the Quai transaction succeeds but the vault transaction did
77
+ not run. `execute()` returns a discriminated result rather than a receipt:
78
+
79
+ ```ts
80
+ const result = await vault.execute(txHash);
81
+
82
+ switch (result.outcome) {
83
+ case 'executed': break; // the target call succeeded
84
+ case 'failed': result.decodedRevert; // target reverted; TERMINAL
85
+ case 'timelock_started': result.executableAfter; // clock started, retry later
86
+ case 'approved_only': result.approvalsNeeded; // needs more signatures
87
+ }
88
+ console.log(result.message); // plain-language explanation
89
+ ```
90
+
91
+ - **`failed`** — the vault marks the transaction executed *permanently* and emits
92
+ `TransactionFailed` instead of reverting. It cannot be retried; propose a replacement.
93
+ - **`timelock_started`** — a timelocked transaction that reached quorum without
94
+ `approvedAt` being set (e.g. the threshold was lowered mid-flight) has its clock started
95
+ by the first `execute` call, which then returns without executing.
96
+ - **`approved_only`** — `approveAndExecute` returned `false`, which a receipt cannot show.
97
+
98
+ ### 2. Deploying requires mining a CREATE2 salt
99
+
100
+ Quai addresses are shard-scoped, so a vault must land on the deployer's shard or it is
101
+ unreachable. `create()` mines a salt automatically:
102
+
103
+ ```ts
104
+ const { address, salt, predictionMatched } = await qv.factory.create(
105
+ { owners: [a, b, c], threshold: 2, minExecutionDelay: 86_400 },
106
+ { onProgress: (p) => console.log(p.step, p.message) },
107
+ );
108
+ ```
109
+
110
+ The predicted address is a pure function of `(factory, implementation, deployer, salt,
111
+ create params)` — including `initialModules` and `initialDelegatecallTargets`. Mining with
112
+ different params than you deploy with predicts the wrong address, so pass the real ones.
113
+
114
+ To mine ahead of time and deploy later, keep the salt and reuse it with identical params:
115
+
116
+ ```ts
117
+ const mined = await qv.factory.mineSalt(params);
118
+ await qv.factory.create({ ...params, salt: mined.salt });
119
+ ```
120
+
121
+ ### 3. Indexed approval counts can over-report
122
+
123
+ The contract invalidates an owner's in-flight approvals when that owner is removed
124
+ (approval epochs). The indexer marks a confirmation inactive only on an explicit
125
+ `ApprovalRevoked`, so its stored `confirmation_count` is too high after any owner removal.
126
+
127
+ The SDK intersects confirmations with the current owner set to reproduce the contract's
128
+ count, and every write path re-reads the chain before signing. A stale count never gates a
129
+ signature.
130
+
131
+ ## Knowing what you can do
132
+
133
+ `affordances()` answers "what may this address legally do to this transaction right now,
134
+ and if not now, when" — derived from the contract's actual rules, so you can plan instead
135
+ of discovering constraints through reverts.
136
+
137
+ ```ts
138
+ for (const a of await vault.affordances(txHash, caller)) {
139
+ console.log(a.allowed ? 'YES' : 'no', a.action, a.reason, a.availableAt);
140
+ }
141
+ ```
142
+
143
+ It encodes the subtle ones: proposer-cancel is permanently blocked once quorum is reached
144
+ (even if approvals are later revoked), self-calls bypass the timelock, and `expire` is
145
+ permissionless once past the deadline.
146
+
147
+ `describe(txHash)` renders the same information as a compact human-readable block.
148
+
149
+ ## API
150
+
151
+ ### Client
152
+
153
+ ```ts
154
+ connect(options?): QuaiVaultClient
155
+ qv.vault(address): Vault
156
+ qv.vaults.forOwner(address) / forGuardian(address) / exists(address)
157
+ qv.factory.create(params, options?) / mineSalt(params) / predictAddress(deployer, salt, params)
158
+ qv.factory.implementation() / verify() / vaultCount() / vaultAt(i) / register(vault)
159
+ qv.indexerHealth()
160
+ ```
161
+
162
+ ### Vault reads
163
+
164
+ ```ts
165
+ vault.info() / owners() / threshold() / balance() / modules() / delegatecallTargets()
166
+ vault.isOwner(a) / isModuleEnabled(m) / isDelegatecallAllowed(t) / isValidSignature(hash)
167
+ vault.transaction(txHash) / pendingTransactions(page?) / transactionHistory(page?)
168
+ vault.transactionHash(to, value, data, nonce?)
169
+ vault.affordances(txHash, caller?) / describe(txHash, caller?)
170
+ vault.balances(opts?) / deposits(page?) / tokenTransfers(page?) / signedMessages()
171
+ vault.waitForExecutable(txHash, opts?)
172
+ vault.watch(handler, opts?) // Supabase Realtime
173
+ ```
174
+
175
+ ### Social recovery
176
+
177
+ ```ts
178
+ vault.recovery.config() / isGuardian(a) / isEnabled() / hasPending()
179
+ vault.recovery.get(hash) / pending() / history(page?) / approvals(hash)
180
+ vault.recovery.predictHash(newOwners, newThreshold)
181
+ vault.recovery.initiate({ newOwners, newThreshold }) // guardians only
182
+ vault.recovery.approve(h) / revokeApproval(h) / execute(h) / cancel(h) / expire(h)
183
+ vault.recovery.affordances(hash, caller?)
184
+ ```
185
+
186
+ Configuring guardians goes through the owner multisig
187
+ (`vault.propose.setupRecovery({ guardians, threshold, recoveryPeriodSeconds })`); everything
188
+ after that is guardian-driven. `cancel` is callable by any current vault owner — the owners'
189
+ defence against a hostile or mistaken guardian action.
190
+
191
+ Recovery status is derived from timestamps rather than read from a stored flag. Nothing
192
+ transitions a recovery to expired on its own: `expireRecovery` is a permissionless cleanup
193
+ call somebody has to make, so an un-cleaned recovery still reads as `pending` in the
194
+ indexer long after its deadline. `execute()` also pre-checks the transient owner count,
195
+ because new owners are added before old ones are removed and a fully disjoint replacement
196
+ can momentarily exceed the 20-owner cap.
197
+
198
+ ### Vault writes
199
+
200
+ ```ts
201
+ vault.propose.call / transfer / erc20Transfer / erc721Transfer / erc1155Transfer / batch
202
+ vault.propose.addOwner / removeOwner / changeThreshold / setMinExecutionDelay
203
+ vault.propose.enableModule / disableModule
204
+ vault.propose.addDelegatecallTarget / removeDelegatecallTarget
205
+ vault.propose.cancelByConsensus / signMessage / approveHashForEip1271 / setupRecovery
206
+
207
+ vault.approve(h) / approveAndExecute(h) / execute(h) / revokeApproval(h) / cancel(h) / expire(h)
208
+ ```
209
+
210
+ `waitForExecutable` polls the chain until a transaction is `ready`, and fails fast rather
211
+ than spinning when waiting cannot help — below quorum, or already terminal.
212
+
213
+ Every `propose.*` accepts `{ expiration, executionDelay, dryRun }`. With `dryRun: true` you
214
+ get the encoded calldata, a gas estimate, and any predicted revert — without signing.
215
+
216
+ Preconditions are checked client-side against the contract's real rules, so failures name
217
+ the fix. Removing an owner below the threshold, batching without MultiSendCallOnly
218
+ whitelisted, or executing a `disableModule` proposal whose module list has since changed all
219
+ raise typed errors rather than reverting on chain.
220
+
221
+ ### Errors
222
+
223
+ All errors extend `QuaiVaultError` with a stable `code`, a `remediation` string, and
224
+ `toJSON()`. Reverts are decoded against every QuaiVault ABI — 73 custom error selectors —
225
+ and rendered with guidance:
226
+
227
+ ```
228
+ NotEnoughApprovals — The approval threshold has not been reached yet.
229
+ DelegateCallNotAllowed(0x00…) — DelegateCall targets must be explicitly whitelisted.
230
+ Propose addDelegatecallTarget for this address first.
231
+ ```
232
+
233
+ ### Balances
234
+
235
+ ```ts
236
+ const { native, tokens } = await vault.balances();
237
+ ```
238
+
239
+ Token discovery is indexer-driven — the chain cannot answer "which contracts has this
240
+ address touched" without scanning every log. Amounts are then verified on chain by default,
241
+ because replaying transfers misses anything the indexer did not observe. Pass
242
+ `{ verify: false }` to skip the reads.
243
+
244
+ ### Waiting for the indexer
245
+
246
+ A transaction confirmed at block N is not queryable until the indexer reaches N. Proposing
247
+ and then immediately listing will silently miss it:
248
+
249
+ ```ts
250
+ const { txHash, chainTxHash } = await vault.propose.transfer({ to, amount });
251
+
252
+ await vault.waitForIndexer(); // or waitForIndexer(blockNumber)
253
+ const pending = await vault.pendingTransactions(); // now includes it
254
+ ```
255
+
256
+ Returns `{ reached, lastIndexedBlock }` rather than throwing on timeout — a lagging indexer
257
+ is an expected operating condition, and the caller may prefer to fall back to a chain read.
258
+ With no argument it targets the current head of the vault's own Quai zone, derived from its
259
+ address (there is no single chain head on a sharded network).
260
+
261
+ ### Realtime
262
+
263
+ ```ts
264
+ const sub = vault.watch((e) => console.log(e.topic, e.type), {
265
+ topics: ['transactions', 'confirmations', 'owners'],
266
+ });
267
+ await sub.unsubscribe();
268
+ ```
269
+
270
+ All topics share one channel per vault. Events fire after the *indexer* processes a block,
271
+ so treat them as a signal to re-read rather than as state themselves.
272
+
273
+ ### Standalone utilities
274
+
275
+ Usable without a client:
276
+
277
+ ```ts
278
+ import {
279
+ predictVaultAddress, mineSalt, shardPrefixOf, // CREATE2
280
+ selfCall, tokenCalls, encodeMultiSend, // encoding
281
+ decodeCall, decodeMultiSendPayload, // decoding
282
+ classifyExecution, deriveStatus, deriveRecoveryStatus, computeAffordances,
283
+ decodeRevert, knownErrorSelectors,
284
+ } from '@quaivault/sdk';
285
+
286
+ import { QuaiVaultAbi, QuaiVaultFactoryAbi } from '@quaivault/sdk/abi';
287
+ ```
288
+
289
+ ## Quai addresses only
290
+
291
+ Quai has two ledgers. **Qi is a UTXO ledger with no contract execution**, so a Qi address
292
+ can never sign a vault transaction, approve a recovery, or receive value that stays on the
293
+ Quai side. The contracts do not check for this — `_addOwner` rejects only the zero address,
294
+ the vault itself, and the module sentinel — so a Qi owner is dead weight against the
295
+ threshold, and enough of them brick the vault permanently.
296
+
297
+ Two independent properties are encoded in an address, and both must hold:
298
+
299
+ | Property | Where | Valid |
300
+ |---|---|---|
301
+ | Zone | first byte | `0x00`–`0x02`, `0x10`–`0x12`, `0x20`–`0x22` |
302
+ | Ledger | 9th bit (high bit of the second byte) | clear = Quai, set = Qi |
303
+
304
+ They are orthogonal: `0x0081…` is in a real zone but is Qi, and `0x7E11…` is Quai-ledger
305
+ but in no zone. The SDK rejects both wherever an address is committed to a role or receives
306
+ value — owners, guardians, recovery owners, modules, delegatecall targets, transfer
307
+ recipients, the signing key, and the vault address itself.
308
+
309
+ Removals stay permissive on purpose: a vault that admitted a bad address before this check
310
+ existed must still be able to remove it.
311
+
312
+ ```ts
313
+ import { assertQuaiAddress, isUsableQuaiAddress, inspectAddress } from '@quaivault/sdk';
314
+
315
+ isUsableQuaiAddress('0x0081…'); // false — Qi ledger
316
+ inspectAddress('0x0081…'); // { valid: false, zone: '0x00', ledger: 'qi', reason: … }
317
+ ```
318
+
319
+ ## Resilience
320
+
321
+ Reads retry transient RPC and indexer failures with exponential backoff and full jitter:
322
+
323
+ ```ts
324
+ const qv = connect({
325
+ network: 'mainnet',
326
+ retry: { maxAttempts: 4, baseDelayMs: 250, onRetry: ({ attempt, error }) => log(attempt, error) },
327
+ });
328
+ ```
329
+
330
+ **Writes are never retried.** A resubmit that looks like a timeout to the client may already
331
+ be in the mempool, so retrying risks a double broadcast. Only reads go through the policy.
332
+
333
+ The classifier errs toward *not* retrying: reverts (`CALL_EXCEPTION`), user rejections,
334
+ nonce and funds errors are permanent by definition, and anything unrecognised is treated as
335
+ permanent too — so a genuine bug surfaces immediately instead of hiding behind three slow
336
+ attempts. Rate limits, 5xx and raw transport failures are retried.
337
+
338
+ ## Development
339
+
340
+ ```bash
341
+ npm run sync-abis # regenerate src/abi from quaivault-contracts artifacts
342
+ npm run sync-abis -- --check # CI drift check
343
+ npm run typecheck
344
+ npm test
345
+ npm run build
346
+ ```
347
+
348
+ ABIs are generated from `quaivault-contracts/artifacts`. The SDK is the single distribution
349
+ point — do not hand-edit `src/abi/*.json`.
350
+
351
+ ## Publishing
352
+
353
+ The package is `@quaivault/sdk` — scoped, so `publishConfig.access` is set to `public`
354
+ (scoped packages default to restricted and would otherwise fail to publish).
355
+
356
+ ```bash
357
+ npm run sync-abis -- --check # fail if ABIs drifted from quaivault-contracts
358
+ npm run typecheck
359
+ npm test
360
+ npm run build
361
+ npm pack # inspect the tarball before publishing
362
+ npm publish
363
+ ```
364
+
365
+ `prepublishOnly` runs the first four automatically, so `npm publish` cannot ship a build
366
+ that fails a check or a stale ABI.
367
+
368
+ Releases go out through **npm trusted publishing**: push a `v*` tag and the release
369
+ workflow exchanges a GitHub OIDC token for a short-lived, workflow-scoped npm credential.
370
+ There is no `NPM_TOKEN` to leak or rotate, and provenance attestations are generated
371
+ automatically because the repository is public.
372
+
373
+ The trusted publisher is configured in the package's settings on npmjs.com and names
374
+ `release.yml` exactly. npm does not validate that configuration when it is saved, so a
375
+ mismatch in the org, repo or workflow filename only surfaces as a failed publish.
376
+
377
+ `npm publish` from a workstation still works and is what bootstrapped `0.1.0` — a package
378
+ must exist before a trusted publisher can be attached to it.
379
+
380
+ **Version pinning.** `quais` is tracked with `~1.0.0-alpha.53` rather than `^`. It is still
381
+ pre-1.0, so a caret range would allow 1.x minors that may change the API before release;
382
+ the tilde keeps consumers on the alpha line this SDK is verified against (alpha.53 and
383
+ alpha.55).
384
+
385
+ ## License
386
+
387
+ MIT