Skip to content

Commit 6b2f432

Browse files
committed
fix(deployer): unblock preprod wallet sync
preprod's dust history is a ~1M-event global stream that every client replays on sync. Deploying any contract there failed before a tx was ever built: the dust wallet OOMed mid-replay, and once that was worked around the sync gate never completed. Both trace to the pre-fix wallet-sdk-dust-wallet@4.0.0 pin (midnightntwrk/midnight-wallet#425). * wallet/handler: set batchUpdates = { size: 5000, timeout: 1, spacing: 4 } on the shared wallet config so the fresh-sync and the cache-restore paths (shielded + dust alike) stream the replay in larger chunks instead of exhausting the V8 heap at the SDK default batch size of 10. * deployer: replace the strict FacadeState.isSynced tip gate with isCompleteWithin(50) on each sub-wallet. On a live chain the dust stream advances continuously, so strict completion never fires and the gate timed out on a fully usable wallet. The gate still waits on all three sub-wallets to avoid the custom-error-170 regression. * cli + README: document the NODE_OPTIONS max-old-space-size bump still useful for a first preprod sync, plus the tolerant tip gate. Verified on preprod: ShieldedFungibleToken dry-run synced to tip in ~1m12s from a warm cache (dust ~1.08M events, no OOM) and the gate completed even though dust never reached strict completion. Closes #115
1 parent 15619e6 commit 6b2f432

6 files changed

Lines changed: 269 additions & 20 deletions

File tree

packages/cli/src/runDeploy.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,21 @@ function showUsage(): void {
299299
console.log(
300300
chalk.yellow(' compact-deploy Token --network preprod --dry-run --json'),
301301
);
302+
console.log(
303+
chalk.yellow(
304+
'\nNote: a first sync on a long-history network (e.g. preprod) can exceed',
305+
),
306+
);
307+
console.log(
308+
chalk.yellow(
309+
" Node's default heap. On 'JavaScript heap out of memory', raise it:",
310+
),
311+
);
312+
console.log(
313+
chalk.yellow(
314+
' NODE_OPTIONS=--max-old-space-size=8192 compact-deploy <Contract> …',
315+
),
316+
);
302317
}
303318

304319
function packageVersion(): string {

packages/deployer/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Exit codes: `0` ok · `2` config error · `3` wallet error · `4` provider unrea
4545
- **First sync is slow** (~3 min on preview, 30–60 min on preprod from genesis). Cache makes reruns near-instant.
4646
- **Bump sync timeout**: `--sync-timeout 3600` (default 10 min).
4747
- **Bump Node heap** for long-history chains: `NODE_OPTIONS="--max-old-space-size=8192"`.
48+
- **Tip gate is tolerant**: sync completes once every sub-wallet is within 50 events of the tip, not at an exact gap of 0. On a live network the global dust stream advances continuously, so an exact-match gate would never fire.
4849
- **Seed source**: `--seed-file`, `MN_DEPLOYER_SEED`, or `[wallet].keystore`. The `wallet = { source = "local" }` shorthand is dev-preset only.
4950

5051
## Wallet cache
@@ -153,7 +154,7 @@ signing_key_file = "./deploy/Vault.signingkey"
153154

154155
4. **Dust fee overhead default breaks faucet wallets.** testkit-js default `additionalFeeOverhead` is `5e20` vs a faucet wallet's `~3e15` dust → `Insufficient Funds: could not balance dust`. Deployer overrides to `5e14` for non-mainnet. Library users constructing their own provider must mirror this.
155156

156-
5. **Long-history dust sync exhausts default Node heap.** Use `NODE_OPTIONS="--max-old-space-size=16384"` for the first sync. Cache fixes subsequent runs.
157+
5. **Long-history dust sync exhausts default Node heap.** The deployer now raises the dust/shielded sync batch size (`batchUpdates = { size: 5000, … }`) so the replay no longer OOMs mid-stream on `wallet-sdk-dust-wallet@4.0.0` ([midnightntwrk/midnight-wallet#425](https://github.com/midnightntwrk/midnight-wallet/issues/425)). The restored dust tree plus shielded trial-decryption can still spike past V8's ~2 GB default old-space on a first preprod sync, so set `NODE_OPTIONS="--max-old-space-size=8192"` for that run. Cache fixes subsequent runs.
157158

158159
## Programmatic API
159160

packages/deployer/src/deployer.test.ts

Lines changed: 159 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,27 @@ function fakeProvider(coinKey = '0xCOIN'): FakeProvider {
129129
isSynced: true,
130130
shielded: {
131131
balances: anyKeyHasBalance,
132-
state: { progress: { isStrictlyComplete: () => true } },
132+
state: {
133+
progress: {
134+
isStrictlyComplete: () => true,
135+
isCompleteWithin: () => true,
136+
},
137+
},
133138
},
134139
unshielded: {
135140
balances: anyKeyHasBalance,
136-
progress: { isStrictlyComplete: () => true },
141+
progress: {
142+
isStrictlyComplete: () => true,
143+
isCompleteWithin: () => true,
144+
},
137145
},
138146
dust: {
139-
state: { progress: { isStrictlyComplete: () => true } },
147+
state: {
148+
progress: {
149+
isStrictlyComplete: () => true,
150+
isCompleteWithin: () => true,
151+
},
152+
},
140153
balance: () => 1n,
141154
},
142155
};
@@ -213,14 +226,27 @@ function syncedState(
213226
isSynced: true,
214227
shielded: {
215228
balances: shielded,
216-
state: { progress: { isStrictlyComplete: () => true } },
229+
state: {
230+
progress: {
231+
isStrictlyComplete: () => true,
232+
isCompleteWithin: () => true,
233+
},
234+
},
217235
},
218236
unshielded: {
219237
balances: unshielded,
220-
progress: { isStrictlyComplete: () => true },
238+
progress: {
239+
isStrictlyComplete: () => true,
240+
isCompleteWithin: () => true,
241+
},
221242
},
222243
dust: {
223-
state: { progress: { isStrictlyComplete: () => true } },
244+
state: {
245+
progress: {
246+
isStrictlyComplete: () => true,
247+
isCompleteWithin: () => true,
248+
},
249+
},
224250
balance: () => 0n,
225251
},
226252
};
@@ -414,7 +440,7 @@ describe('Deployer', () => {
414440
});
415441

416442
describe('syncAndVerifyFunds (owned-wallet branch)', () => {
417-
it('should reject with a timeout error when the wallet never reports isSynced', async () => {
443+
it('should reject with a timeout error when the wallet never reaches chain tip', async () => {
418444
const built = fakeOwnedFromProvider(
419445
fakeProviderWithState(Rx.NEVER, '0xSTUCK'),
420446
);
@@ -430,6 +456,110 @@ describe('Deployer', () => {
430456
).rejects.toThrow(/Wallet sync timeout after 50ms/);
431457
});
432458

459+
it('should complete sync when sub-wallets are within the gap but not strictly complete', async () => {
460+
// Live-chain shape (issue #115): the global dust stream keeps
461+
// advancing, so dust is never strictly complete (gap 0) but settles
462+
// within the tolerated gap. The old strict `isSynced` gate would hang
463+
// here forever; the tolerant `isCompleteWithin` gate must proceed.
464+
const anyBal = new Proxy({} as Record<string, bigint>, {
465+
get: () => 1n,
466+
});
467+
const liveTipState = {
468+
isSynced: false,
469+
shielded: {
470+
balances: anyBal,
471+
state: {
472+
progress: {
473+
isStrictlyComplete: () => true,
474+
isCompleteWithin: () => true,
475+
},
476+
},
477+
},
478+
unshielded: {
479+
balances: anyBal,
480+
progress: {
481+
isStrictlyComplete: () => true,
482+
isCompleteWithin: () => true,
483+
},
484+
},
485+
dust: {
486+
state: {
487+
progress: {
488+
isStrictlyComplete: () => false,
489+
isCompleteWithin: () => true,
490+
},
491+
},
492+
balance: () => 1n,
493+
},
494+
};
495+
const built = fakeOwnedFromProvider(
496+
fakeProviderWithState(Rx.of(liveTipState as unknown), '0xLIVE-TIP'),
497+
);
498+
vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned);
499+
await using d = await Deployer.prepare({
500+
contract: 'Counter',
501+
network: 'local',
502+
configPath: fx.configPath,
503+
logger: silentLogger,
504+
syncTimeoutMs: 1000,
505+
});
506+
expect(d.deployer).toBe('0xLIVE-TIP');
507+
});
508+
509+
it('should NOT complete sync while any sub-wallet is outside the gap', async () => {
510+
// Dust still outside the tolerated gap: the gate must keep waiting and
511+
// ultimately time out rather than deploy against a half-synced wallet.
512+
const anyBal = new Proxy({} as Record<string, bigint>, {
513+
get: () => 1n,
514+
});
515+
const laggingState = {
516+
isSynced: false,
517+
shielded: {
518+
balances: anyBal,
519+
state: {
520+
progress: {
521+
isStrictlyComplete: () => true,
522+
isCompleteWithin: () => true,
523+
},
524+
},
525+
},
526+
unshielded: {
527+
balances: anyBal,
528+
progress: {
529+
isStrictlyComplete: () => true,
530+
isCompleteWithin: () => true,
531+
},
532+
},
533+
dust: {
534+
state: {
535+
progress: {
536+
isStrictlyComplete: () => false,
537+
isCompleteWithin: () => false,
538+
},
539+
},
540+
balance: () => 1n,
541+
},
542+
};
543+
// Emit the lagging state, then hang: the gate filters it out and must
544+
// keep waiting (not complete the sequence) so the timeout can fire.
545+
const built = fakeOwnedFromProvider(
546+
fakeProviderWithState(
547+
Rx.concat(Rx.of(laggingState as unknown), Rx.NEVER),
548+
'0xLAGGING',
549+
),
550+
);
551+
vi.mocked(WalletHandler.build).mockResolvedValueOnce(built.owned);
552+
await expect(
553+
Deployer.prepare({
554+
contract: 'Counter',
555+
network: 'local',
556+
configPath: fx.configPath,
557+
logger: silentLogger,
558+
syncTimeoutMs: 50,
559+
}),
560+
).rejects.toThrow(/Wallet sync timeout after 50ms/);
561+
});
562+
433563
it('should throw UnfundedWalletError when shielded and unshielded balances are both empty', async () => {
434564
const built = fakeOwnedFromProvider(
435565
fakeProviderWithState(Rx.of(syncedState({}, {})), '0xEMPTY'),
@@ -684,17 +814,18 @@ describe('Deployer', () => {
684814

685815
describe('describeProgress branches', () => {
686816
it('should render the progress percentage when highest > 0', async () => {
687-
// Mid-sync state (NOT yet `isSynced`) that drives the progress
817+
// Mid-sync state (still short of the tip) that drives the progress
688818
// subscription's "else" branch (highest > 0). Then a follow-up
689-
// synced state lets `firstValueFrom(filter(isSynced))` resolve so
690-
// the prepare call terminates.
819+
// tip-reached state lets the `isCompleteWithin` gate resolve so the
820+
// prepare call terminates.
691821
const midState = {
692822
isSynced: false,
693823
shielded: {
694824
balances: {} as Record<string, bigint>,
695825
state: {
696826
progress: {
697827
isStrictlyComplete: () => false,
828+
isCompleteWithin: () => false,
698829
appliedIndex: 10n,
699830
highestIndex: 100n,
700831
isConnected: true,
@@ -705,6 +836,7 @@ describe('Deployer', () => {
705836
balances: {} as Record<string, bigint>,
706837
progress: {
707838
isStrictlyComplete: () => false,
839+
isCompleteWithin: () => false,
708840
appliedId: 5n,
709841
highestTransactionId: 50n,
710842
isConnected: true,
@@ -714,6 +846,7 @@ describe('Deployer', () => {
714846
state: {
715847
progress: {
716848
isStrictlyComplete: () => false,
849+
isCompleteWithin: () => false,
717850
appliedIndex: 1n,
718851
highestIndex: 10n,
719852
isConnected: true,
@@ -729,14 +862,27 @@ describe('Deployer', () => {
729862
isSynced: true,
730863
shielded: {
731864
balances: anyKeyHasBalance,
732-
state: { progress: { isStrictlyComplete: () => true } },
865+
state: {
866+
progress: {
867+
isStrictlyComplete: () => true,
868+
isCompleteWithin: () => true,
869+
},
870+
},
733871
},
734872
unshielded: {
735873
balances: anyKeyHasBalance,
736-
progress: { isStrictlyComplete: () => true },
874+
progress: {
875+
isStrictlyComplete: () => true,
876+
isCompleteWithin: () => true,
877+
},
737878
},
738879
dust: {
739-
state: { progress: { isStrictlyComplete: () => true } },
880+
state: {
881+
progress: {
882+
isStrictlyComplete: () => true,
883+
isCompleteWithin: () => true,
884+
},
885+
},
740886
balance: () => 1n,
741887
},
742888
};

packages/deployer/src/deployer.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ import {
2929
*/
3030
const DEFAULT_SYNC_TIMEOUT_MS = 10 * 60 * 1000;
3131

32+
/**
33+
* Tolerated sync gap, in events, for the chain-tip gate.
34+
* `FacadeState.isSynced` requires every sub-wallet to be *strictly*
35+
* complete (gap 0). On a live network the global dust stream advances
36+
* continuously, so the dust wallet sits a few events behind the tip
37+
* indefinitely and strict `isSynced` never flips. The gate would then time
38+
* out on a wallet that is in fact fully usable. `isCompleteWithin(50)` is the
39+
* SDK default (and what the unshielded wallet uses internally): it treats
40+
* "within 50 events of tip" as synced, which a live wallet reaches and
41+
* holds. See OpenZeppelin/compact-tools#115.
42+
*/
43+
const SYNC_MAX_GAP = 50n;
44+
3245
import { ConstructorArgs } from './loaders/args.ts';
3346
import { Artifact } from './loaders/artifact.ts';
3447
import { InitialPrivateState } from './loaders/init-state.ts';
@@ -469,11 +482,15 @@ function describeProgress(p: { isStrictlyComplete: () => boolean }): string {
469482
}
470483

471484
/**
472-
* Drive the wallet to chain tip and assert spendable funds. Uses
473-
* `state.isSynced` (strict-complete on all three sub-wallets) as the
474-
* gate. Looser gates regressed on local with `Invalid Transaction
475-
* (custom error 170)`. Throttles progress logs to once per 30 s.
476-
* Throws {@link UnfundedWalletError} on empty wallet.
485+
* Drive the wallet to chain tip and assert spendable funds. Gates on each
486+
* sub-wallet being within {@link SYNC_MAX_GAP} events of the tip rather
487+
* than strictly complete: on a live network the global dust stream never
488+
* settles to gap 0, so strict `FacadeState.isSynced` never fires and the
489+
* gate times out on a perfectly usable wallet (#115). The gate still waits
490+
* on all three sub-wallets; dropping the dust/shielded wait entirely
491+
* regressed on local with `Invalid Transaction (custom error 170)`.
492+
* Throttles progress logs to once per 30 s. Throws
493+
* {@link UnfundedWalletError} on empty wallet.
477494
*/
478495
async function syncAndVerifyFunds(args: {
479496
wallet: MidnightWalletProvider;
@@ -577,7 +594,15 @@ async function syncAndVerifyFunds(args: {
577594
try {
578595
synced = await Rx.firstValueFrom(
579596
state$.pipe(
580-
Rx.filter((s: FacadeState) => s.isSynced),
597+
// Tolerant tip gate: all three sub-wallets within SYNC_MAX_GAP
598+
// events of the tip. Strict `s.isSynced` never fires on a live
599+
// chain because the global dust stream keeps advancing (#115).
600+
Rx.filter(
601+
(s: FacadeState) =>
602+
s.shielded.state.progress.isCompleteWithin(SYNC_MAX_GAP) &&
603+
s.dust.state.progress.isCompleteWithin(SYNC_MAX_GAP) &&
604+
s.unshielded.progress.isCompleteWithin(SYNC_MAX_GAP),
605+
),
581606
Rx.timeout({
582607
each: timeoutMs,
583608
with: () =>

packages/deployer/src/wallet/handler.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,32 @@ describe('WalletHandler', () => {
210210
});
211211
});
212212

213+
describe('sync batching', () => {
214+
it('should set a large batchUpdates size on the shared config for both sub-wallets', async () => {
215+
wireTestkitChain(fakeProvider());
216+
await WalletHandler.build(logger, fakeEnv('preprod'), {
217+
kind: 'hex',
218+
value: '00',
219+
});
220+
// The shared config is mutated in place and threaded into every
221+
// sub-wallet factory, so asserting on the dust + shielded calls proves
222+
// the OOM workaround (issue #115) covers both. Default SDK batch size
223+
// is 10, which OOMs replaying preprod's ~1M-event dust stream.
224+
const withBatch = expect.objectContaining({
225+
batchUpdates: { size: 5000, timeout: 1, spacing: 4 },
226+
});
227+
expect(WalletFactory.createDustWallet).toHaveBeenCalledWith(
228+
withBatch,
229+
expect.any(Uint8Array),
230+
expect.anything(),
231+
);
232+
expect(WalletFactory.createShieldedWallet).toHaveBeenCalledWith(
233+
withBatch,
234+
expect.any(Uint8Array),
235+
);
236+
});
237+
});
238+
213239
describe('provider wiring', () => {
214240
it('should expose the wallet built by MidnightWalletProvider.withWallet via .provider', async () => {
215241
const provider = fakeProvider();

0 commit comments

Comments
 (0)