@midnightntwrk/wallet-sdk-shielded 3.0.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.
@@ -0,0 +1,40 @@
1
+ import type * as ledger from '@midnight-ntwrk/ledger-v8';
2
+ import { Effect, Scope, Stream } from 'effect';
3
+ import { type WalletRuntimeError, type Variant, StateChange } from '@midnightntwrk/wallet-sdk-runtime/abstractions';
4
+ import { type SerializationCapability } from './Serialization.js';
5
+ import { type ChangesResult, type EventsSyncUpdate, type SyncCapability, type SyncService } from './Sync.js';
6
+ import { type TransactingCapability, type TokenTransfer, type BalancingResult } from './Transacting.js';
7
+ import { type WalletError } from './WalletError.js';
8
+ import { type CoinsAndBalancesCapability } from './CoinsAndBalances.js';
9
+ import { type KeysCapability } from './Keys.js';
10
+ import { type CoinSelection } from '@midnightntwrk/wallet-sdk-capabilities';
11
+ import { type CoreWallet } from './CoreWallet.js';
12
+ import { type TransactionHistoryService } from './TransactionHistory.js';
13
+ export declare namespace RunningV1Variant {
14
+ type Context<TSerialized, TSyncUpdate, TTransaction, TStartAux> = {
15
+ serializationCapability: SerializationCapability<CoreWallet, null, TSerialized>;
16
+ syncService: SyncService<CoreWallet, TStartAux, TSyncUpdate>;
17
+ syncCapability: SyncCapability<CoreWallet, TSyncUpdate, ChangesResult>;
18
+ transactingCapability: TransactingCapability<ledger.ZswapSecretKeys, CoreWallet, TTransaction>;
19
+ coinsAndBalancesCapability: CoinsAndBalancesCapability<CoreWallet>;
20
+ keysCapability: KeysCapability<CoreWallet>;
21
+ coinSelection: CoinSelection<ledger.QualifiedShieldedCoinInfo>;
22
+ transactionHistoryService: TransactionHistoryService;
23
+ };
24
+ type AnyContext = Context<any, any, any, any>;
25
+ }
26
+ export declare const V1Tag: unique symbol;
27
+ export type DefaultRunningV1 = RunningV1Variant<string, EventsSyncUpdate, ledger.FinalizedTransaction, ledger.ZswapSecretKeys>;
28
+ export declare class RunningV1Variant<TSerialized, TSyncUpdate, TTransaction, TStartAux> implements Variant.RunningVariant<typeof V1Tag, CoreWallet> {
29
+ #private;
30
+ readonly __polyTag__: typeof V1Tag;
31
+ readonly state: Stream.Stream<StateChange.StateChange<CoreWallet>, WalletRuntimeError>;
32
+ constructor(scope: Scope.Scope, context: Variant.VariantContext<CoreWallet>, v1Context: RunningV1Variant.Context<TSerialized, TSyncUpdate, TTransaction, TStartAux>);
33
+ startSyncInBackground(startAux: TStartAux): Effect.Effect<void>;
34
+ startSync(startAux: TStartAux): Stream.Stream<void, WalletError, Scope.Scope>;
35
+ balanceTransaction(secretKeys: ledger.ZswapSecretKeys, tx: ledger.Transaction<ledger.Signaturish, ledger.Proofish, ledger.Bindingish>): Effect.Effect<BalancingResult, WalletError>;
36
+ transferTransaction(secretKeys: ledger.ZswapSecretKeys, outputs: ReadonlyArray<TokenTransfer>): Effect.Effect<ledger.UnprovenTransaction, WalletError>;
37
+ initSwap(secretKeys: ledger.ZswapSecretKeys, desiredInputs: Record<ledger.RawTokenType, bigint>, desiredOutputs: ReadonlyArray<TokenTransfer>): Effect.Effect<ledger.UnprovenTransaction, WalletError>;
38
+ revertTransaction(transaction: ledger.Transaction<ledger.Signaturish, ledger.Proofish, ledger.Bindingish>): Effect.Effect<void, WalletError>;
39
+ serializeState(state: CoreWallet): TSerialized;
40
+ }
@@ -0,0 +1,98 @@
1
+ import { Effect, pipe, Scope, Stream, SubscriptionRef, Schedule, Duration, Sink, Console } from 'effect';
2
+ import { ProtocolVersion } from '@midnightntwrk/wallet-sdk-abstractions';
3
+ import { StateChange, VersionChangeType, } from '@midnightntwrk/wallet-sdk-runtime/abstractions';
4
+ import { EitherOps } from '@midnightntwrk/wallet-sdk-utilities';
5
+ import { OtherWalletError } from './WalletError.js';
6
+ const progress = (state) => {
7
+ const appliedIndex = state.progress?.appliedIndex ?? 0n;
8
+ const highestRelevantWalletIndex = state.progress?.highestRelevantWalletIndex ?? 0n;
9
+ const highestIndex = state.progress?.highestIndex ?? 0n;
10
+ const highestRelevantIndex = state.progress?.highestRelevantIndex ?? 0n;
11
+ const sourceGap = highestIndex - highestRelevantIndex;
12
+ const applyGap = highestRelevantWalletIndex - appliedIndex;
13
+ return [StateChange.ProgressUpdate({ sourceGap, applyGap })];
14
+ };
15
+ const protocolVersionChange = (previous, current) => {
16
+ return previous.protocolVersion != current.protocolVersion
17
+ ? [
18
+ StateChange.VersionChange({
19
+ change: VersionChangeType.Version({
20
+ version: ProtocolVersion.ProtocolVersion(current.protocolVersion),
21
+ }),
22
+ }),
23
+ ]
24
+ : [];
25
+ };
26
+ export const V1Tag = Symbol('V1');
27
+ export class RunningV1Variant {
28
+ __polyTag__ = V1Tag;
29
+ #scope;
30
+ #context;
31
+ #v1Context;
32
+ state;
33
+ constructor(scope, context, v1Context) {
34
+ this.#scope = scope;
35
+ this.#context = context;
36
+ this.#v1Context = v1Context;
37
+ this.state = Stream.fromEffect(context.stateRef.get).pipe(Stream.flatMap((initialState) => context.stateRef.changes.pipe(Stream.mapAccum(initialState, (previous, current) => {
38
+ return [current, [previous, current]];
39
+ }))), Stream.mapConcat(([previous, current]) => {
40
+ // TODO: emit progress only upon actual change
41
+ return [
42
+ StateChange.State({ state: current }),
43
+ ...progress(current),
44
+ ...protocolVersionChange(previous, current),
45
+ ];
46
+ }));
47
+ }
48
+ startSyncInBackground(startAux) {
49
+ return this.startSync(startAux).pipe(Stream.runScoped(Sink.drain), Effect.forkScoped, Effect.provideService(Scope.Scope, this.#scope));
50
+ }
51
+ startSync(startAux) {
52
+ return pipe(SubscriptionRef.get(this.#context.stateRef), Stream.fromEffect, Stream.flatMap((state) => this.#v1Context.syncService.updates(state, startAux)), Stream.mapEffect((update) => SubscriptionRef.modifyEffect(this.#context.stateRef, (state) => Effect.try({
53
+ try: () => {
54
+ const [newState, changesResult] = this.#v1Context.syncCapability.applyUpdate(state, update);
55
+ return [changesResult, newState];
56
+ },
57
+ catch: (err) => new OtherWalletError({
58
+ message: 'Error while applying sync update',
59
+ cause: err,
60
+ }),
61
+ })).pipe(Effect.flatMap(({ changes, protocolVersion }) =>
62
+ // Skip the tx-history fork entirely when there are no changes.
63
+ // Forking unconditionally allocates a fiber per apply call (one
64
+ // for every batch the sync emits, even the all-progress ones),
65
+ // which adds up fast during catch-up.
66
+ changes.length === 0
67
+ ? Effect.void
68
+ : pipe(Effect.forEach(changes, (change) => pipe(this.#v1Context.transactionHistoryService.getTransactionDetails(change.source), Effect.flatMap((metadata) => this.#v1Context.transactionHistoryService.put(change, metadata, protocolVersion)), Effect.catchAllCause((cause) => Console.error('Error processing tx history metadata', cause))), { discard: true, concurrency: 'unbounded' }), Effect.forkScoped)), Effect.provideService(Scope.Scope, this.#scope))), Stream.tapError((error) => Console.error(error)), Stream.retry(pipe(Schedule.exponential(Duration.seconds(1), 2), Schedule.map((delay) => {
69
+ const maxDelay = Duration.minutes(2);
70
+ const jitter = Duration.millis(Math.floor(Math.random() * 1000));
71
+ const delayWithJitter = Duration.toMillis(delay) + Duration.toMillis(jitter);
72
+ return Duration.millis(Math.min(delayWithJitter, Duration.toMillis(maxDelay)));
73
+ }))));
74
+ }
75
+ balanceTransaction(secretKeys, tx) {
76
+ return SubscriptionRef.modifyEffect(this.#context.stateRef, (state) => {
77
+ return pipe(this.#v1Context.transactingCapability.balanceTransaction(secretKeys, state, tx), EitherOps.toEffect);
78
+ });
79
+ }
80
+ transferTransaction(secretKeys, outputs) {
81
+ return SubscriptionRef.modifyEffect(this.#context.stateRef, (state) => {
82
+ return pipe(this.#v1Context.transactingCapability.makeTransfer(secretKeys, state, outputs), EitherOps.toEffect);
83
+ });
84
+ }
85
+ initSwap(secretKeys, desiredInputs, desiredOutputs) {
86
+ return SubscriptionRef.modifyEffect(this.#context.stateRef, (state) => {
87
+ return pipe(this.#v1Context.transactingCapability.initSwap(secretKeys, state, desiredInputs, desiredOutputs), EitherOps.toEffect);
88
+ });
89
+ }
90
+ revertTransaction(transaction) {
91
+ return SubscriptionRef.updateEffect(this.#context.stateRef, (state) => {
92
+ return pipe(this.#v1Context.transactingCapability.revertTransaction(state, transaction), EitherOps.toEffect);
93
+ });
94
+ }
95
+ serializeState(state) {
96
+ return this.#v1Context.serializationCapability.serialize(state);
97
+ }
98
+ }
@@ -0,0 +1,12 @@
1
+ import { Either } from 'effect';
2
+ import { WalletError } from './WalletError.js';
3
+ import { CoreWallet } from './CoreWallet.js';
4
+ import { type NetworkId } from '@midnightntwrk/wallet-sdk-abstractions';
5
+ export type SerializationCapability<TWallet, TAux, TSerialized> = {
6
+ serialize(wallet: TWallet): TSerialized;
7
+ deserialize(aux: TAux, data: TSerialized): Either.Either<TWallet, WalletError>;
8
+ };
9
+ export type DefaultSerializationConfiguration = {
10
+ networkId: NetworkId.NetworkId;
11
+ };
12
+ export declare const makeDefaultV1SerializationCapability: () => SerializationCapability<CoreWallet, null, string>;
@@ -0,0 +1,79 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ import { Effect, ParseResult, Either, pipe, Schema } from 'effect';
14
+ import { WalletError } from './WalletError.js';
15
+ import * as ledger from '@midnight-ntwrk/ledger-v8';
16
+ import { CoreWallet } from './CoreWallet.js';
17
+ const StateSchema = Schema.declare((input) => input instanceof ledger.ZswapLocalState).annotations({
18
+ identifier: 'ledger.ZswapLocalState',
19
+ });
20
+ const Uint8ArraySchema = Schema.declare((input) => input instanceof Uint8Array).annotations({
21
+ identifier: 'Uint8Array',
22
+ });
23
+ const StateFromUInt8Array = () => Schema.asSchema(Schema.transformOrFail(Uint8ArraySchema, StateSchema, {
24
+ encode: (state) => {
25
+ return Effect.try({
26
+ try: () => {
27
+ return state.serialize();
28
+ },
29
+ catch: (err) => {
30
+ return new ParseResult.Unexpected(err, 'Could not serialize local state');
31
+ },
32
+ });
33
+ },
34
+ decode: (bytes) => Effect.try({
35
+ try: () => ledger.ZswapLocalState.deserialize(bytes),
36
+ catch: (err) => {
37
+ return new ParseResult.Unexpected(err, 'Could not deserialize local state');
38
+ },
39
+ }),
40
+ }));
41
+ const HexedState = () => pipe(Schema.Uint8ArrayFromHex, Schema.compose(StateFromUInt8Array()));
42
+ export const makeDefaultV1SerializationCapability = () => {
43
+ const SnapshotSchema = Schema.Struct({
44
+ publicKeys: Schema.Struct({
45
+ coinPublicKey: Schema.String,
46
+ encryptionPublicKey: Schema.String,
47
+ }),
48
+ state: HexedState(),
49
+ protocolVersion: Schema.BigInt,
50
+ offset: Schema.optional(Schema.BigInt),
51
+ networkId: Schema.String,
52
+ coinHashes: Schema.Record({
53
+ key: Schema.String,
54
+ value: Schema.Struct({ nullifier: Schema.String, commitment: Schema.String }),
55
+ }),
56
+ });
57
+ return {
58
+ serialize: (wallet) => {
59
+ const buildSnapshot = (w) => ({
60
+ publicKeys: w.publicKeys,
61
+ state: w.state,
62
+ protocolVersion: w.protocolVersion,
63
+ networkId: w.networkId,
64
+ offset: w.progress?.appliedIndex,
65
+ coinHashes: w.coinHashes,
66
+ });
67
+ return pipe(wallet, buildSnapshot, Schema.encodeSync(SnapshotSchema), JSON.stringify);
68
+ },
69
+ deserialize: (aux, serialized) => {
70
+ return pipe(serialized, Schema.decodeUnknownEither(Schema.parseJson(SnapshotSchema)), Either.mapLeft((err) => WalletError.other(err)), Either.flatMap((snapshot) => CoreWallet.restoreWithCoinHashes(snapshot.publicKeys, snapshot.state, snapshot.coinHashes, {
71
+ appliedIndex: snapshot.offset ?? 0n,
72
+ highestRelevantWalletIndex: 0n,
73
+ highestIndex: 0n,
74
+ highestRelevantIndex: 0n,
75
+ isConnected: false,
76
+ }, snapshot.protocolVersion, snapshot.networkId)));
77
+ },
78
+ };
79
+ };
@@ -0,0 +1,83 @@
1
+ import * as ledger from '@midnight-ntwrk/ledger-v8';
2
+ import { Schema, type Scope, Stream } from 'effect';
3
+ import { CoreWallet } from './CoreWallet.js';
4
+ import { type Simulator, type SimulatorState } from '@midnightntwrk/wallet-sdk-capabilities/simulation';
5
+ import { type WalletError } from './WalletError.js';
6
+ import { type TransactionHistoryService } from './TransactionHistory.js';
7
+ export interface SyncService<TState, TStartAux, TUpdate> {
8
+ updates: (state: TState, auxData: TStartAux) => Stream.Stream<TUpdate, WalletError, Scope.Scope>;
9
+ }
10
+ export type ChangesResult = {
11
+ readonly changes: ledger.ZswapStateChanges[];
12
+ readonly protocolVersion: number;
13
+ };
14
+ export interface SyncCapability<TState, TUpdate, TResult> {
15
+ applyUpdate: (state: TState, update: TUpdate) => [TState, TResult];
16
+ }
17
+ export type IndexerClientConnection = {
18
+ indexerHttpUrl: string;
19
+ indexerWsUrl?: string;
20
+ keepAlive?: number;
21
+ /** Cap on the in-flight event queue between the WebSocket push and the apply loop. Default: 10000. */
22
+ bufferSize?: number;
23
+ /** In-flight count at which the disposed WS subscription is reopened. Default: 100. */
24
+ resumeThreshold?: number;
25
+ };
26
+ export type BatchUpdatesConfig = {
27
+ /**
28
+ * Maximum number of events to collect into a single batch before emitting.
29
+ *
30
+ * @default 10
31
+ */
32
+ readonly size?: number;
33
+ /**
34
+ * Maximum time in milliseconds to wait for a full batch before emitting a partial one. Controls the `groupedWithin`
35
+ * timeout — lower values mean more responsive (but smaller) batches when events arrive slowly.
36
+ *
37
+ * @default 1
38
+ */
39
+ readonly timeout?: number;
40
+ /**
41
+ * Minimum delay in milliseconds injected between consecutive batches. Prevents the sync stream from saturating
42
+ * downstream consumers when many events are available at once. Set to 0 to disable spacing entirely.
43
+ *
44
+ * @default 4
45
+ */
46
+ readonly spacing?: number;
47
+ };
48
+ export type DefaultSyncConfiguration = {
49
+ indexerClientConnection: IndexerClientConnection;
50
+ batchUpdates?: BatchUpdatesConfig;
51
+ };
52
+ export type DefaultSyncContext = {
53
+ transactionHistoryService: TransactionHistoryService;
54
+ };
55
+ export type SecretKeysResource = <A>(cb: (keys: ledger.ZswapSecretKeys) => A) => A;
56
+ export declare const SecretKeysResource: {
57
+ create: (secretKeys: ledger.ZswapSecretKeys) => SecretKeysResource;
58
+ };
59
+ export type WalletSyncUpdate = {
60
+ updates: EventsSyncUpdate[];
61
+ secretKeys: ledger.ZswapSecretKeys;
62
+ };
63
+ export declare const WalletSyncUpdate: {
64
+ create: (updates: EventsSyncUpdate[], secretKeys: ledger.ZswapSecretKeys) => WalletSyncUpdate;
65
+ };
66
+ export declare const EventsSyncUpdate: Schema.TaggedStruct<"EventsSyncUpdate", {
67
+ id: typeof Schema.Number;
68
+ protocolVersion: typeof Schema.Number;
69
+ maxId: typeof Schema.Number;
70
+ event: Schema.declare<ledger.Event, ledger.Event, readonly [], never>;
71
+ }>;
72
+ export type EventsSyncUpdate = Schema.Schema.Type<typeof EventsSyncUpdate>;
73
+ export declare const makeEventsSyncService: (config: DefaultSyncConfiguration) => SyncService<CoreWallet, ledger.ZswapSecretKeys, WalletSyncUpdate>;
74
+ export declare const makeEventsSyncCapability: () => SyncCapability<CoreWallet, WalletSyncUpdate, ChangesResult>;
75
+ export type SimulatorSyncConfiguration = {
76
+ simulator: Simulator;
77
+ };
78
+ export type SimulatorSyncUpdate = {
79
+ update: SimulatorState;
80
+ secretKeys: ledger.ZswapSecretKeys;
81
+ };
82
+ export declare const makeSimulatorSyncService: (config: SimulatorSyncConfiguration) => SyncService<CoreWallet, ledger.ZswapSecretKeys, SimulatorSyncUpdate>;
83
+ export declare const makeSimulatorSyncCapability: () => SyncCapability<CoreWallet, SimulatorSyncUpdate, ChangesResult>;
@@ -0,0 +1,206 @@
1
+ // This file is part of MIDNIGHT-WALLET-SDK.
2
+ // Copyright (C) Midnight Foundation
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Licensed under the Apache License, Version 2.0 (the "License");
5
+ // You may not use this file except in compliance with the License.
6
+ // You may obtain a copy of the License at
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+ import * as ledger from '@midnight-ntwrk/ledger-v8';
14
+ import { Chunk, Duration, Effect, Either, ParseResult, pipe, Schedule, Schema, Stream } from 'effect';
15
+ import { CoreWallet } from './CoreWallet.js';
16
+ import { getLastBlock, getBlockEventsFrom, } from '@midnightntwrk/wallet-sdk-capabilities/simulation';
17
+ import { ZswapEvents } from '@midnightntwrk/wallet-sdk-indexer-client';
18
+ import { ConnectionHelper, WsSubscriptionClient } from '@midnightntwrk/wallet-sdk-indexer-client/effect';
19
+ import { SyncWalletError } from './WalletError.js';
20
+ import { WsURL } from '@midnightntwrk/wallet-sdk-utilities/networking';
21
+ import { EitherOps } from '@midnightntwrk/wallet-sdk-utilities';
22
+ const Uint8ArraySchema = Schema.declare((input) => input instanceof Uint8Array).annotations({
23
+ identifier: 'Uint8Array',
24
+ });
25
+ export const SecretKeysResource = {
26
+ create: (secretKeys) => {
27
+ return (cb) => {
28
+ const result = cb(secretKeys);
29
+ secretKeys.clear();
30
+ return result;
31
+ };
32
+ },
33
+ };
34
+ export const WalletSyncUpdate = {
35
+ create: (updates, secretKeys) => {
36
+ return {
37
+ updates,
38
+ secretKeys,
39
+ };
40
+ },
41
+ };
42
+ const LedgerEventSchema = Schema.declare((input) => input instanceof ledger.Event).annotations({
43
+ identifier: 'ledger.Event',
44
+ });
45
+ const LedgerEventFromUint8Array = Schema.transformOrFail(Uint8ArraySchema, LedgerEventSchema, {
46
+ encode: (event) => Effect.try({
47
+ try: () => event.serialize(),
48
+ catch: (error) => new ParseResult.Unexpected(error, 'Could not serialize ledger event'),
49
+ }),
50
+ decode: (bytes) => Effect.try({
51
+ try: () => ledger.Event.deserialize(bytes),
52
+ catch: (error) => new ParseResult.Unexpected(error, 'Could not deserialize ledger event'),
53
+ }),
54
+ });
55
+ const HexedLedgerEvent = pipe(Schema.Uint8ArrayFromHex, Schema.compose(LedgerEventFromUint8Array));
56
+ const EventsSyncUpdatePayload = Schema.Struct({
57
+ id: Schema.Number,
58
+ raw: Schema.String,
59
+ protocolVersion: Schema.Number,
60
+ maxId: Schema.Number,
61
+ });
62
+ export const EventsSyncUpdate = Schema.TaggedStruct('EventsSyncUpdate', {
63
+ id: Schema.Number,
64
+ protocolVersion: Schema.Number,
65
+ maxId: Schema.Number,
66
+ event: LedgerEventSchema,
67
+ });
68
+ const EventsSyncUpdateFromPayload = Schema.transformOrFail(EventsSyncUpdatePayload, EventsSyncUpdate, {
69
+ decode: (input) => pipe(Schema.decodeUnknownEither(HexedLedgerEvent)(input.raw), Either.map((event) => ({
70
+ _tag: 'EventsSyncUpdate',
71
+ id: input.id,
72
+ protocolVersion: input.protocolVersion,
73
+ maxId: input.maxId,
74
+ event,
75
+ })), Either.mapLeft((error) => new ParseResult.Unexpected(error, 'Failed to decode ledger event payload')), EitherOps.toEffect),
76
+ encode: (output) => pipe(Schema.encodeEither(HexedLedgerEvent)(output.event), Either.map((raw) => ({
77
+ id: output.id,
78
+ raw,
79
+ protocolVersion: output.protocolVersion,
80
+ maxId: output.maxId,
81
+ })), Either.mapLeft((error) => new ParseResult.Unexpected(error, 'Failed to encode ledger event payload')), EitherOps.toEffect),
82
+ });
83
+ export const makeEventsSyncService = (config) => {
84
+ return {
85
+ updates: (state, secretKeys) => {
86
+ const { indexerClientConnection } = config;
87
+ const webSocketUrlResult = ConnectionHelper.createWebSocketUrl(indexerClientConnection.indexerHttpUrl, indexerClientConnection.indexerWsUrl);
88
+ if (Either.isLeft(webSocketUrlResult)) {
89
+ return Stream.fail(new SyncWalletError(new Error(`Could not derive WebSocket URL from indexer HTTP URL: ${webSocketUrlResult.left.message}`)));
90
+ }
91
+ const indexerWsUrlResult = WsURL.make(webSocketUrlResult.right);
92
+ if (Either.isLeft(indexerWsUrlResult)) {
93
+ return Stream.fail(new SyncWalletError(new Error(`Invalid indexer WS URL: ${indexerWsUrlResult.left.message}`)));
94
+ }
95
+ const indexerWsUrl = indexerWsUrlResult.right;
96
+ const appliedIndex = state.progress?.appliedIndex ?? 0n;
97
+ // The boundary is load-bearing, not waste: this subscription emits only events (no tip/progress
98
+ // sentinel), and `isConnected`/the tip (`maxId`) are set only when an event is received. So the
99
+ // cursor must stay `<= appliedIndex` — never `appliedIndex + 1`. Requesting one event later would
100
+ // deliver nothing to a wallet already at the tip, so `applyUpdate` would never run and sync would
101
+ // hang.
102
+ //
103
+ // A fresh wallet has `appliedIndex === 0n` (the "nothing applied yet" sentinel), so `resumeFrom`
104
+ // is `-1n` and the `variables` mapping below opens the subscription with no `id` — the indexer
105
+ // streams from the very start. A restored wallet has `appliedIndex >= 1`, so `resumeFrom` is
106
+ // `appliedIndex - 1` and the inclusive cursor re-delivers the boundary event.
107
+ const resumeFrom = appliedIndex - 1n;
108
+ const batchSize = config.batchUpdates?.size ?? 10;
109
+ const batchTimeout = Duration.millis(config.batchUpdates?.timeout ?? 1);
110
+ const batchSpacing = config.batchUpdates?.spacing ?? 4;
111
+ const bufferSize = config.indexerClientConnection.bufferSize ?? 10000;
112
+ const resumeThreshold = config.indexerClientConnection.resumeThreshold ?? 100;
113
+ const eventsStream = pipe(
114
+ // Backpressure caps the in-flight queue between the WS push and the
115
+ // apply loop. Without it the JS heap grows linearly with catch-up
116
+ // depth, since `Stream.asyncPush({ bufferSize: 'unbounded' })`
117
+ // buffers every event the indexer pushes regardless of apply rate.
118
+ ZswapEvents.runWithBackpressure({
119
+ bufferSize,
120
+ resumeThreshold,
121
+ from: resumeFrom,
122
+ // `resumeFrom < 0n` means a fresh wallet: send no `id` so the indexer streams from the very
123
+ // start, rather than relying on `id: 0` sorting below the first real event id.
124
+ variables: (cursor) => ({ id: cursor < 0n ? null : Number(cursor) }),
125
+ key: (r) => BigInt(r.zswapLedgerEvents.id),
126
+ }), Stream.provideLayer(WsSubscriptionClient.layer({ url: indexerWsUrl, keepAlive: config.indexerClientConnection.keepAlive })), Stream.mapError((error) => new SyncWalletError(error)), Stream.mapEffect((subscription) => pipe(subscription.zswapLedgerEvents, Schema.decodeUnknownEither(EventsSyncUpdateFromPayload), Either.mapLeft((err) => new SyncWalletError(err)), EitherOps.toEffect)), Stream.groupedWithin(batchSize, batchTimeout), Stream.map(Chunk.toArray), Stream.map((data) => WalletSyncUpdate.create(data, secretKeys)));
127
+ return batchSpacing > 0
128
+ ? Stream.schedule(eventsStream, Schedule.spaced(Duration.millis(batchSpacing)))
129
+ : eventsStream;
130
+ },
131
+ };
132
+ };
133
+ export const makeEventsSyncCapability = () => {
134
+ return {
135
+ applyUpdate: (state, wrappedUpdate) => {
136
+ if (wrappedUpdate.updates.length === 0) {
137
+ return [state, { changes: [], protocolVersion: Number(state.protocolVersion) }];
138
+ }
139
+ // The subscription resumes at the last-applied index and its cursor is inclusive, so the
140
+ // boundary event (id === appliedIndex) is re-delivered. Replaying it would re-insert
141
+ // commitments already in the zswap state ("non-linear insertion" error), so only events
142
+ // strictly after the applied index are replayed. The boundary is still used (below) to
143
+ // refresh the tip and mark the wallet connected — that is how an already-caught-up wallet
144
+ // learns it is synced when no new events exist.
145
+ const appliedIndex = state.progress?.appliedIndex ?? 0n;
146
+ const freshUpdates = wrappedUpdate.updates.filter((u) => BigInt(u.id) > appliedIndex);
147
+ const lastUpdate = wrappedUpdate.updates.at(-1);
148
+ const highestRelevantWalletIndex = BigInt(lastUpdate.maxId);
149
+ const [newState, newChanges] = freshUpdates.length === 0
150
+ ? [state, []]
151
+ : CoreWallet.replayEventsWithChanges(state, wrappedUpdate.secretKeys, freshUpdates.map((u) => u.event));
152
+ const updatedState = CoreWallet.updateProgress(newState, {
153
+ highestRelevantWalletIndex,
154
+ appliedIndex: freshUpdates.length === 0 ? appliedIndex : BigInt(freshUpdates.at(-1).id),
155
+ isConnected: true,
156
+ });
157
+ return [updatedState, { changes: newChanges, protocolVersion: lastUpdate.protocolVersion }];
158
+ },
159
+ };
160
+ };
161
+ export const makeSimulatorSyncService = (config) => {
162
+ return {
163
+ updates: (_state, secretKeys) => {
164
+ // Get the initial state immediately to ensure we process the genesis block.
165
+ // Then subscribe to state$ for subsequent changes, but deduplicate by block number
166
+ // to avoid processing the same block twice.
167
+ let lastSeenBlockNumber;
168
+ return pipe(Stream.fromEffect(config.simulator.getLatestState()), Stream.concat(config.simulator.state$), Stream.filter((state) => {
169
+ const lastBlock = getLastBlock(state);
170
+ if (lastBlock === undefined) {
171
+ return false; // Skip blank state
172
+ }
173
+ const blockNumber = lastBlock.number;
174
+ // Skip if we've already seen this block (deduplication)
175
+ if (lastSeenBlockNumber !== undefined && blockNumber <= lastSeenBlockNumber) {
176
+ return false;
177
+ }
178
+ lastSeenBlockNumber = blockNumber;
179
+ return true;
180
+ }), Stream.map((state) => ({ update: state, secretKeys })));
181
+ },
182
+ };
183
+ };
184
+ export const makeSimulatorSyncCapability = () => {
185
+ return {
186
+ applyUpdate: (state, update) => {
187
+ const { update: simulatorState, secretKeys } = update;
188
+ const lastBlock = getLastBlock(simulatorState);
189
+ if (lastBlock === undefined) {
190
+ return [state, { changes: [], protocolVersion: Number(state.protocolVersion) }];
191
+ }
192
+ // Get all events from blocks starting at appliedIndex (the next block to process).
193
+ // appliedIndex semantics: the first block number we haven't processed yet.
194
+ // Initial: appliedIndex = 0 (haven't processed any blocks)
195
+ // After processing block N: appliedIndex = N + 1 (next block to process)
196
+ const events = [...getBlockEventsFrom(simulatorState, state.progress.appliedIndex)];
197
+ const [newState, newChanges] = CoreWallet.replayEventsWithChanges(state, secretKeys, events);
198
+ return [
199
+ CoreWallet.updateProgress(newState, {
200
+ appliedIndex: lastBlock.number + 1n,
201
+ }),
202
+ { changes: newChanges, protocolVersion: Number(state.protocolVersion) },
203
+ ];
204
+ },
205
+ };
206
+ };
@@ -0,0 +1,50 @@
1
+ import * as ledger from '@midnight-ntwrk/ledger-v8';
2
+ import { type NetworkId } from '@midnightntwrk/wallet-sdk-abstractions';
3
+ import { Array as Arr, Either } from 'effect';
4
+ import { CoreWallet } from './CoreWallet.js';
5
+ import { type WalletError } from './WalletError.js';
6
+ import { type CoinSelection } from '@midnightntwrk/wallet-sdk-capabilities';
7
+ import { type ShieldedAddress } from '@midnightntwrk/wallet-sdk-address-format';
8
+ import { TransactionImbalances } from './TransactionImbalances.js';
9
+ import { TransactionOps } from './TransactionOps.js';
10
+ import { type CoinsAndBalancesCapability } from './CoinsAndBalances.js';
11
+ import { type KeysCapability } from './Keys.js';
12
+ export interface TokenTransfer {
13
+ readonly amount: bigint;
14
+ readonly type: ledger.RawTokenType;
15
+ readonly receiverAddress: ShieldedAddress;
16
+ }
17
+ export type BalancingResult = ledger.UnprovenTransaction | undefined;
18
+ export interface TransactingCapability<TSecrets, TState, _TTransaction> {
19
+ balanceTransaction(secrets: TSecrets, state: TState, tx: ledger.Transaction<ledger.Signaturish, ledger.Proofish, ledger.Bindingish>): Either.Either<[BalancingResult, TState], WalletError>;
20
+ makeTransfer(secrets: TSecrets, state: TState, outputs: ReadonlyArray<TokenTransfer>): Either.Either<[ledger.UnprovenTransaction, TState], WalletError>;
21
+ initSwap(secrets: TSecrets, state: TState, desiredInputs: Record<ledger.RawTokenType, bigint>, desiredOutputs: ReadonlyArray<TokenTransfer>): Either.Either<[ledger.UnprovenTransaction, TState], WalletError>;
22
+ revertTransaction(state: TState, transaction: ledger.Transaction<ledger.Signaturish, ledger.Proofish, ledger.Bindingish>): Either.Either<TState, WalletError>;
23
+ }
24
+ export type DefaultTransactingConfiguration = {
25
+ networkId: NetworkId.NetworkId;
26
+ };
27
+ export type DefaultTransactingContext = {
28
+ coinSelection: CoinSelection<ledger.QualifiedShieldedCoinInfo>;
29
+ coinsAndBalancesCapability: CoinsAndBalancesCapability<CoreWallet>;
30
+ keysCapability: KeysCapability<CoreWallet>;
31
+ };
32
+ export declare const makeDefaultTransactingCapability: (config: DefaultTransactingConfiguration, getContext: () => DefaultTransactingContext) => TransactingCapability<ledger.ZswapSecretKeys, CoreWallet, ledger.FinalizedTransaction>;
33
+ export declare const makeSimulatorTransactingCapability: (config: DefaultTransactingConfiguration, getContext: () => DefaultTransactingContext) => TransactingCapability<ledger.ZswapSecretKeys, CoreWallet, ledger.ProofErasedTransaction>;
34
+ export declare class TransactingCapabilityImplementation<TTransaction extends ledger.Transaction<ledger.Signaturish, ledger.Proofish, ledger.Bindingish>> implements TransactingCapability<ledger.ZswapSecretKeys, CoreWallet, TTransaction> {
35
+ #private;
36
+ readonly networkId: NetworkId.NetworkId;
37
+ readonly getCoinSelection: () => CoinSelection<ledger.QualifiedShieldedCoinInfo>;
38
+ readonly txOps: TransactionOps<TTransaction>;
39
+ readonly getCoins: () => CoinsAndBalancesCapability<CoreWallet>;
40
+ readonly getKeys: () => KeysCapability<CoreWallet>;
41
+ constructor(networkId: NetworkId.NetworkId, getCoinSelection: () => CoinSelection<ledger.QualifiedShieldedCoinInfo>, getCoins: () => CoinsAndBalancesCapability<CoreWallet>, getKeys: () => KeysCapability<CoreWallet>, txOps: TransactionOps<TTransaction>);
42
+ balanceTransaction(secretKeys: ledger.ZswapSecretKeys, state: CoreWallet, tx: TTransaction): Either.Either<[BalancingResult, CoreWallet], WalletError>;
43
+ makeTransfer(secretKeys: ledger.ZswapSecretKeys, state: CoreWallet, transfers: Arr.NonEmptyReadonlyArray<TokenTransfer>): Either.Either<[ledger.UnprovenTransaction, CoreWallet], WalletError>;
44
+ initSwap(secretKeys: ledger.ZswapSecretKeys, state: CoreWallet, desiredInputs: Record<ledger.RawTokenType, bigint>, desiredOutputs: ReadonlyArray<TokenTransfer>): Either.Either<[ledger.UnprovenTransaction, CoreWallet], WalletError>;
45
+ revertTransaction(state: CoreWallet, transaction: TTransaction | ledger.UnprovenTransaction): Either.Either<CoreWallet, WalletError>;
46
+ balanceFallibleSection(secretKeys: ledger.ZswapSecretKeys, state: CoreWallet, imbalances: TransactionImbalances, coinSelection: CoinSelection<ledger.QualifiedShieldedCoinInfo>): Either.Either<{
47
+ fallibleOffers: Map<number, ledger.ZswapOffer<ledger.PreProof>>;
48
+ newState: CoreWallet;
49
+ }, WalletError>;
50
+ }