Skip to content

Commit e909ad3

Browse files
authored
Merge branch 'main' into feature/historical-sync
2 parents 32678c9 + 3e9c3ac commit e909ad3

214 files changed

Lines changed: 10892 additions & 383 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,211 @@ await client.submitApplication({
6060
- `getGrantApplication`
6161
- `getMilestones`
6262

63+
## 🔐 Crypto (secp256k1)
64+
65+
`whitechain-sdk/crypto` exposes low-level secp256k1 signing, verification, and public-key recovery, backed by a WebAssembly implementation (`tiny-secp256k1`) with an automatic, transparent fallback to a pure-JavaScript implementation (`@noble/curves`) in environments where WASM can't be loaded. The active backend is chosen lazily on first use and cached for the life of the process.
66+
67+
```ts
68+
import { sign, verify, getPublicKey } from 'whitechain-sdk/crypto'
69+
70+
const publicKey = await getPublicKey(privateKey)
71+
const signature = await sign(messageHash, privateKey) // { r, s, recovery }
72+
const isValid = await verify(messageHash, signature, publicKey)
73+
```
74+
75+
Both backends produce identical, low-S-normalized, RFC6979-deterministic output for the same input — the WASM path is purely a performance optimization, never a behavioral change.
76+
77+
## 🔒 Offline Transaction Signing (Cold Storage)
78+
79+
`whitechain-sdk/security` exposes `OfflineSigner`, a dedicated signer for **air-gapped** environments — machines with no network connection at all, such as a cold-storage laptop or hardware-adjacent signing device. It constructs and signs legacy and EIP-1559 transactions using only values you supply directly.
80+
81+
**Zero network dependencies, by construction:** `OfflineSigner` does not accept a provider, transport, public client, wallet client, RPC URL, chain RPC configuration, or SDK context of any kind — there is no parameter slot for one. It never calls `fetch`, `XMLHttpRequest`, `WebSocket`, an HTTP library, a viem client action, or a network-discovery function, and it never calls `getNetwork`, `getChainId`, `getTransactionCount`, `estimateGas`, `getGasPrice`, `estimateFeesPerGas`, `prepareTransactionRequest`, or any other helper that could silently fill in a missing field via RPC. Every field must come from you; a missing or invalid one fails immediately and locally with a `ValidationError`, never a network error.
82+
83+
### The three-stage air-gapped workflow
84+
85+
**1. Online preparation** *(internet-connected machine)*
86+
- Fetch the account's next nonce, the current chain ID, an appropriate gas limit, and current fee data (`maxFeePerGas`/`maxPriorityFeePerGas`, or `gasPrice` for a legacy transaction).
87+
- Assemble these into a plain, unsigned transaction object.
88+
- Transfer **only this unsigned transaction data** to the offline environment (QR code, USB drive, manual entry) — never the private key in the other direction.
89+
90+
**2. Offline signing** *(air-gapped machine, no network connection)*
91+
- Import or otherwise securely provide the private key to this machine.
92+
- Construct an `OfflineSigner` with it.
93+
- Call `signTransaction(...)`, passing only the manually supplied values from stage 1.
94+
- Export the resulting raw signed transaction hex.
95+
- The private key never leaves this machine — it is never transmitted, logged, or included in any error message.
96+
97+
**3. Online broadcast** *(internet-connected machine)*
98+
- Transfer only the signed raw transaction hex back — never the private key.
99+
- Broadcast it via `eth_sendRawTransaction` (or `publicClient.sendRawTransaction({ serializedTransaction })`) on any online node.
100+
- A successfully *signed* transaction can still fail at this stage: if the nonce, fee levels, account balance, or other chain state changed between stage 1 and stage 3 (for example, another transaction from the same account was mined in between), the node may reject it. Re-run stage 1 with fresh values if that happens.
101+
102+
```ts
103+
import { OfflineSigner } from 'whitechain-sdk/security'
104+
105+
// Stage 2 — air-gapped machine. `privateKey` never leaves this process.
106+
const signer = new OfflineSigner(privateKey) // 0x-prefixed hex or Uint8Array
107+
108+
const signed = await signer.signTransaction({
109+
type: 'eip1559',
110+
chainId: 1,
111+
nonce: 12, // from stage 1, supplied by you
112+
to: '0xRecipient...',
113+
value: 1_000000000_000000000n, // 1 ETH, in wei
114+
gas: 21_000n, // from stage 1, supplied by you
115+
maxFeePerGas: 30_000000000n, // from stage 1, supplied by you
116+
maxPriorityFeePerGas: 2_000000000n, // required — no RPC to estimate a default from
117+
})
118+
119+
// signed.raw is the payload for stage 3 — hand it to an online node:
120+
// await publicClient.sendRawTransaction({ serializedTransaction: signed.raw })
121+
console.log(signed.raw) // 0x02... (ready to broadcast)
122+
console.log(signed.hash) // keccak256(signed.raw) — matches eth_sendRawTransaction's return value
123+
console.log(signed.from) // the address that produced the signature
124+
```
125+
126+
Legacy (pre-EIP-1559) transactions are supported the same way, priced with `gasPrice` instead of `maxFeePerGas`/`maxPriorityFeePerGas`:
127+
128+
```ts
129+
const signed = await signer.signTransaction({
130+
chainId: 1,
131+
nonce: 12,
132+
to: '0xRecipient...',
133+
value: 0n,
134+
gas: 21_000n,
135+
gasPrice: 20_000000000n,
136+
})
137+
```
138+
139+
Contract creation is supported by omitting `to` (or passing `null`) alongside deployment `data` — this is only ever intentional, since a normal transfer or call always specifies `to`.
140+
141+
All fields are validated locally and strictly: private key format/length, `chainId`/`nonce` as safe integers, a positive `gas` limit, non-negative fee/value fields, `maxFeePerGas >= maxPriorityFeePerGas`, address format/checksum, and well-formed hex call data. Every failure throws `ValidationError` synchronously or via a rejected promise — never a network error, since no network call is ever made.
142+
143+
### ⚠️ Security warning
144+
145+
`OfflineSigner` guarantees that the **signing step itself** performs no network I/O — that guarantee is enforced in code and covered by tests. It **cannot** guarantee the security of anything around that step: the operating system on the air-gapped machine, the removable media used to move data across the gap, how or where the private key is generated and stored, or the physical transfer process. Those remain entirely your responsibility. Treat the offline machine as if it will eventually be compromised, and design your key-management practices accordingly.
146+
147+
## 🔌 Plugin System
148+
149+
The SDK ships a first-class plugin architecture so community developers can extend the `WhitechainSDK` instance with custom namespaces — NFT marketplace helpers, lending calculators, analytics modules — without forking the core SDK or adding bloat to the core bundle.
150+
151+
### Core concepts
152+
153+
| Concept | Description |
154+
|---|---|
155+
| `WhitechainSDK` | The extensible host class. Accepts plugins at construction time or dynamically via `.use()`. |
156+
| `ISDKPlugin` | Interface every plugin must implement: `name`, `version`, and `onInitialize(ctx)`. |
157+
| `SDKContext` | Read-only view of SDK internals (`publicClient`, `walletClient`, `network`, `logger`) passed to `onInitialize`. |
158+
| `WhitechainSDKPlugins` | Open interface for TypeScript declaration merging — augment it to add IDE autocomplete for your plugin's namespace. |
159+
160+
### Quick start
161+
162+
```ts
163+
import { WhitechainSDK, type ISDKPlugin, type SDKContext } from 'whitechain-sdk'
164+
import { networks } from 'whitechain-sdk'
165+
166+
// 1. Define a plugin
167+
const marketplacePlugin: ISDKPlugin = {
168+
name: 'marketplace',
169+
version: '1.0.0',
170+
onInitialize(ctx: SDKContext) {
171+
return {
172+
async buyNFT(tokenId: bigint) {
173+
ctx.logger.info(`Purchasing NFT #${tokenId}`)
174+
// use ctx.publicClient / ctx.walletClient to call contracts
175+
},
176+
}
177+
},
178+
}
179+
180+
// 2. Type-augment the SDK (in a .d.ts file or at the top of your plugin package)
181+
declare module 'whitechain-sdk' {
182+
interface WhitechainSDKPlugins {
183+
marketplace: { buyNFT(tokenId: bigint): Promise<void> }
184+
}
185+
}
186+
187+
// 3. Create the SDK — plugins are awaited before the factory resolves
188+
const sdk = await WhitechainSDK.create(
189+
{ network: networks.whitechainMainnet },
190+
[marketplacePlugin],
191+
)
192+
193+
// 4. Call the plugin — fully typed, IDE autocomplete included
194+
await sdk.marketplace.buyNFT(42n)
195+
```
196+
197+
### Passing plugins at construction vs. dynamically
198+
199+
```ts
200+
// At construction time (recommended)
201+
const sdk = await WhitechainSDK.create(config, [pluginA, pluginB])
202+
203+
// Dynamically after construction
204+
await sdk.use(pluginC)
205+
206+
// Chainable
207+
await sdk.use(pluginD).then(s => s.use(pluginE))
208+
```
209+
210+
### Accessing SDK internals from a plugin
211+
212+
`onInitialize` receives a frozen `SDKContext` object — the only surface plugins should interact with:
213+
214+
```ts
215+
const myPlugin: ISDKPlugin = {
216+
name: 'myPlugin',
217+
version: '0.1.0',
218+
onInitialize({ publicClient, walletClient, network, logger }) {
219+
logger.info(`Plugin loaded on chain ${network?.chainId}`)
220+
return {
221+
getBalance: (addr: `0x${string}`) =>
222+
publicClient.getBalance({ address: addr }),
223+
}
224+
},
225+
}
226+
```
227+
228+
| Field | Type | Notes |
229+
|---|---|---|
230+
| `publicClient` | `PublicClient` | Always present. Use for reads and `eth_call`. |
231+
| `walletClient` | `WalletClient \| undefined` | Present only when an `account` was provided to the SDK. |
232+
| `network` | `NetworkProfile \| undefined` | Chain name, RPC URL, explorer URL, etc. |
233+
| `logger` | `SDKLogger` | Structured logger — `info`, `warn`, `error`, `debug`. |
234+
235+
### Async initialization
236+
237+
Plugins may perform async work (fetch on-chain config, resolve ENS, etc.) in `onInitialize`. Use `WhitechainSDK.create()` to ensure all hooks are fully settled before the instance is returned:
238+
239+
```ts
240+
const heavyPlugin: ISDKPlugin = {
241+
name: 'heavy',
242+
version: '1.0.0',
243+
async onInitialize(ctx) {
244+
const config = await ctx.publicClient.readContract({ /* ... */ })
245+
return { config }
246+
},
247+
}
248+
249+
const sdk = await WhitechainSDK.create(config, [heavyPlugin])
250+
// sdk.heavy.config is ready here — no race conditions
251+
```
252+
253+
### Inspecting loaded plugins
254+
255+
```ts
256+
console.log(sdk.getPlugins())
257+
// [ { name: 'marketplace', version: '1.0.0' }, ... ]
258+
```
259+
260+
### Plugin authoring guide
261+
262+
1. Export an object (or class instance) that satisfies `ISDKPlugin`.
263+
2. Choose a unique `name` — it becomes the property key on the SDK instance. Avoid collisions with `publicClient`, `walletClient`, `network`, `logger`, `use`, and `getPlugins`.
264+
3. Augment `WhitechainSDKPlugins` in your package's `index.d.ts` so consumers get full IDE support.
265+
4. Keep the plugin self-contained. Do not import SDK internals directly — only use what `SDKContext` exposes.
266+
5. The core bundle is **not affected** by external plugins; plugins are loaded lazily at runtime and contribute zero bytes to the base bundle.
267+
63268
## 🏗️ Design Philosophy
64269

65270
**Omitted By Design** to keep the SDK fast and secure:
@@ -81,6 +286,76 @@ Available scripts for local development:
81286
- `npm run build` – Typecheck and emit ESM to `dist/`
82287
- `npm run typecheck` – Typecheck only
83288
- `npm run test` – Run unit tests via Vitest
289+
- `npm run bench` – Benchmark the WASM vs JS secp256k1 signer (requires `npm run build` first)
290+
291+
### 🧪 Foundry Invariant & Stateful Fuzzing Suite
292+
293+
We utilize [Foundry](https://book.getfoundry.sh/) to perform deep stateful invariant testing on core AMM and Vault smart contracts. The fuzzing suite bombards contracts with random input sequences to ensure critical economic invariants hold true unconditionally across state space transitions.
294+
295+
To run the invariant test suite:
296+
297+
```bash
298+
forge test --match-path "test/invariants/*"
299+
```
300+
301+
#### Key Invariants Tested
302+
303+
- **Constant Product Formula (`x * y >= k`)**: Proves that AMM swaps (including 0.3% fee accrual) never decrease total pool constant $k$.
304+
- **Vault Asset Solvency (`totalShares <= totalAssets`)**: Ensures share minting never exceeds underlying asset reserves.
305+
- **User Balance Boundary (`userBalance <= totalSupply`)**: Verifies no single user's LP or Vault share balance exceeds overall contract total supply.
306+
- **Graceful Revert Handling**: Ensures zero-amount swap attempts and invalid inputs revert gracefully without breaking stateful fuzz runs.
307+
308+
#### Configuration (`foundry.toml`)
309+
310+
- **Runs**: 10,000 random input sequences per invariant.
311+
- **Depth**: 500 call transitions per run.
312+
- **Revert Policy**: `fail_on_revert = false` (handled via stateful `Handler.sol`).
313+
314+
### 🧪 Foundry Invariant & Stateful Fuzzing Suite
315+
316+
We utilize [Foundry](https://book.getfoundry.sh/) to perform deep stateful invariant testing on core AMM and Vault smart contracts. The fuzzing suite bombards contracts with random input sequences to ensure critical economic invariants hold true unconditionally across state space transitions.
317+
318+
To run the invariant test suite:
319+
320+
```bash
321+
forge test --match-path "test/invariants/*"
322+
```
323+
324+
#### Key Invariants Tested
325+
326+
- **Constant Product Formula (`x * y >= k`)**: Proves that AMM swaps (including 0.3% fee accrual) never decrease total pool constant $k$.
327+
- **Vault Asset Solvency (`totalShares <= totalAssets`)**: Ensures share minting never exceeds underlying asset reserves.
328+
- **User Balance Boundary (`userBalance <= totalSupply`)**: Verifies no single user's LP or Vault share balance exceeds overall contract total supply.
329+
- **Graceful Revert Handling**: Ensures zero-amount swap attempts and invalid inputs revert gracefully without breaking stateful fuzz runs.
330+
331+
#### Configuration (`foundry.toml`)
332+
333+
- **Runs**: 10,000 random input sequences per invariant.
334+
- **Depth**: 500 call transitions per run.
335+
- **Revert Policy**: `fail_on_revert = false` (handled via stateful `Handler.sol`).
336+
337+
### 🧪 Foundry Invariant & Stateful Fuzzing Suite
338+
339+
We utilize [Foundry](https://book.getfoundry.sh/) to perform deep stateful invariant testing on core AMM and Vault smart contracts. The fuzzing suite bombards contracts with random input sequences to ensure critical economic invariants hold true unconditionally across state space transitions.
340+
341+
To run the invariant test suite:
342+
343+
```bash
344+
forge test --match-path "test/invariants/*"
345+
```
346+
347+
#### Key Invariants Tested
348+
349+
- **Constant Product Formula (`x * y >= k`)**: Proves that AMM swaps (including 0.3% fee accrual) never decrease total pool constant $k$.
350+
- **Vault Asset Solvency (`totalShares <= totalAssets`)**: Ensures share minting never exceeds underlying asset reserves.
351+
- **User Balance Boundary (`userBalance <= totalSupply`)**: Verifies no single user's LP or Vault share balance exceeds overall contract total supply.
352+
- **Graceful Revert Handling**: Ensures zero-amount swap attempts and invalid inputs revert gracefully without breaking stateful fuzz runs.
353+
354+
#### Configuration (`foundry.toml`)
355+
356+
- **Runs**: 10,000 random input sequences per invariant.
357+
- **Depth**: 500 call transitions per run.
358+
- **Revert Policy**: `fail_on_revert = false` (handled via stateful `Handler.sol`).
84359

85360
## 🤝 Contributing
86361

bench/signer.bench.mjs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Node.js benchmark: JS (@noble/curves) signer vs WASM (tiny-secp256k1) signer.
2+
//
3+
// Run with `npm run bench` after `npm run build` (or a partial ESM build --
4+
// see README). Imports the compiled output directly, mirroring how
5+
// tests/tree-shaking.test.ts already consumes dist/esm.
6+
import { jsBackend } from '../dist/esm/crypto/backends/js.js'
7+
import { createWasmBackend } from '../dist/esm/crypto/backends/wasm.js'
8+
9+
const WARMUP_ITERATIONS = 2_000
10+
const ROUNDS = 7
11+
const ITERATIONS_PER_ROUND = 20_000
12+
const REQUIRED_SPEEDUP = 5
13+
14+
const PRIVATE_KEY = Uint8Array.from({ length: 32 }, (_, i) => (i === 31 ? 1 : 0))
15+
const HASH = Uint8Array.from({ length: 32 }, (_, i) => (i * 7 + 1) % 256)
16+
17+
function median(values) {
18+
const sorted = [...values].sort((a, b) => a - b)
19+
const mid = Math.floor(sorted.length / 2)
20+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
21+
}
22+
23+
function timeRound(fn, iterations) {
24+
const start = process.hrtime.bigint()
25+
for (let i = 0; i < iterations; i++) fn()
26+
const end = process.hrtime.bigint()
27+
return Number(end - start) / 1e6 // milliseconds
28+
}
29+
30+
function benchmarkSign(backend) {
31+
const sign = () => backend.sign(HASH, PRIVATE_KEY)
32+
33+
// Warm up: excluded from steady-state timing. For the WASM backend this
34+
// also absorbs one-time module/JIT costs unrelated to per-call throughput.
35+
for (let i = 0; i < WARMUP_ITERATIONS; i++) sign()
36+
37+
const roundMs = []
38+
for (let r = 0; r < ROUNDS; r++) {
39+
roundMs.push(timeRound(sign, ITERATIONS_PER_ROUND))
40+
}
41+
42+
const medianMs = median(roundMs)
43+
const opsPerSec = ITERATIONS_PER_ROUND / (medianMs / 1000)
44+
return { roundMs, medianMs, opsPerSec }
45+
}
46+
47+
async function main() {
48+
console.log(`Node ${process.version}, ${ROUNDS} rounds x ${ITERATIONS_PER_ROUND} sign() calls, ${WARMUP_ITERATIONS} warmup calls each\n`)
49+
50+
// Load the WASM backend once, up front, and exclude that cost from
51+
// steady-state timing -- this benchmark measures per-call throughput,
52+
// not cold-start latency.
53+
const wasmModule = await import('tiny-secp256k1')
54+
const wasmBackend = createWasmBackend(wasmModule)
55+
56+
const js = benchmarkSign(jsBackend)
57+
const wasm = benchmarkSign(wasmBackend)
58+
59+
const speedup = js.medianMs / wasm.medianMs
60+
61+
console.log('JS (@noble/curves): median %s ms, %s ops/sec', js.medianMs.toFixed(2), Math.round(js.opsPerSec).toLocaleString())
62+
console.log('WASM (tiny-secp256k1): median %s ms, %s ops/sec', wasm.medianMs.toFixed(2), Math.round(wasm.opsPerSec).toLocaleString())
63+
console.log(`\nSpeedup (JS median / WASM median): ${speedup.toFixed(2)}x`)
64+
65+
if (speedup >= REQUIRED_SPEEDUP) {
66+
console.log(`MEETS the ${REQUIRED_SPEEDUP}x requirement (${speedup.toFixed(2)}x >= ${REQUIRED_SPEEDUP}x).`)
67+
} else {
68+
console.log(`DOES NOT MEET the ${REQUIRED_SPEEDUP}x requirement (${speedup.toFixed(2)}x < ${REQUIRED_SPEEDUP}x).`)
69+
}
70+
}
71+
72+
main()

0 commit comments

Comments
 (0)