@midnightntwrk/wallet-sdk-shielded 4.0.0-rc.0 → 4.0.0-rc.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.
package/dist/Restore.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { ProtocolVersion } from '@midnightntwrk/wallet-sdk-abstractions';
2
- import { Either, Option } from 'effect';
1
+ import { ProtocolVersion, type SnapshotFormat } from '@midnightntwrk/wallet-sdk-abstractions';
2
+ import { type Either, Option } from 'effect';
3
3
  declare const UnsupportedSnapshotVersionError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
4
4
  readonly _tag: "@midnightntwrk/wallet-sdk-shielded/Restore/UnsupportedSnapshotVersionError";
5
5
  } & Readonly<A>;
@@ -17,24 +17,41 @@ export declare class UnsupportedSnapshotVersionError extends UnsupportedSnapshot
17
17
  }> {
18
18
  }
19
19
  /**
20
- * Reads the protocol version a serialized wallet snapshot declares.
20
+ * Reads the protocol version a serialized shielded wallet snapshot declares.
21
21
  *
22
22
  * @param serialized The serialized wallet state.
23
23
  * @returns The declared version, or `Option.none()` when the snapshot declares none or cannot be read at all.
24
24
  */
25
25
  export declare const peekProtocolVersion: (serialized: string) => Option.Option<ProtocolVersion.ProtocolVersion>;
26
26
  /**
27
- * Chooses the variant that should read a serialized wallet snapshot.
27
+ * Reads which variant wrote a serialized shielded wallet snapshot, when it says.
28
28
  *
29
29
  * @remarks
30
- * A snapshot that declares no version predates snapshots declaring one, and can only have been written by the variant
31
- * that shipped before the question arose — the head variant. The same fallback covers an envelope this function
32
- * cannot read at all: refusing it here would replace the deserializer's precise error with a vaguer one.
30
+ * Shielded snapshots carry nothing in their shape that says which variant wrote them, so only the name counts; a
31
+ * snapshot written before snapshots named their writer reads as naming none.
32
+ * @param serialized The serialized wallet state.
33
+ * @returns The writer, or `Option.none()` when the snapshot predates the field, names a writer this build does not
34
+ * know, or cannot be read at all.
35
+ */
36
+ export declare const peekWriter: (serialized: string) => Option.Option<SnapshotFormat.SnapshotWriter>;
37
+ /**
38
+ * Chooses the variant that should read a serialized shielded wallet snapshot.
39
+ *
40
+ * @remarks
41
+ * The rule is {@link SnapshotRouting.routeSnapshot}'s: the variant that wrote the snapshot when it says which and that
42
+ * variant is registered, otherwise the variant that owns the declared version, otherwise the head variant. The
43
+ * envelope is read once here, not once per question.
33
44
  * @param serialized The serialized wallet state.
34
45
  * @param variantFor Resolves the variant registered for a protocol version.
35
46
  * @param headVariant The variant a snapshot with no declared version is restored into.
36
- * @returns The variant to restore with, or {@link UnsupportedSnapshotVersionError} when the declared version is one no
37
- * registered variant owns.
47
+ * @param variantWrittenBy Resolves the registered variant that a snapshot names as its writer, if it is registered.
48
+ * Defaults to none, which is the routing every caller had before snapshots named their writer.
49
+ * @param activationOf The protocol version a registered variant starts answering for, as the wallet registered it. The
50
+ * writer takes a snapshot only from that version upwards; below it the version routes, since the runtime cannot hand
51
+ * over backwards. Defaults to the minimum version, which lets the writer take any snapshot, for compositions that
52
+ * pass no writer resolver either.
53
+ * @returns The variant to restore with, or {@link UnsupportedSnapshotVersionError} when the writer is not registered and
54
+ * the declared version is one no registered variant owns.
38
55
  */
39
- export declare const variantForSnapshot: <TVariant>(serialized: string, variantFor: (version: ProtocolVersion.ProtocolVersion) => Option.Option<TVariant>, headVariant: TVariant) => Either.Either<TVariant, UnsupportedSnapshotVersionError>;
56
+ export declare const variantForSnapshot: <TVariant>(serialized: string, variantFor: (version: ProtocolVersion.ProtocolVersion) => Option.Option<TVariant>, headVariant: TVariant, variantWrittenBy?: (writer: SnapshotFormat.SnapshotWriter) => Option.Option<TVariant>, activationOf?: (variant: TVariant) => ProtocolVersion.ProtocolVersion) => Either.Either<TVariant, UnsupportedSnapshotVersionError>;
40
57
  export {};
package/dist/Restore.js CHANGED
@@ -10,8 +10,8 @@
10
10
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
- import { ProtocolVersion } from '@midnightntwrk/wallet-sdk-abstractions';
14
- import { Data, Either, Option, Schema } from 'effect';
13
+ import { ProtocolVersion, SnapshotRouting } from '@midnightntwrk/wallet-sdk-abstractions';
14
+ import { Data, Option } from 'effect';
15
15
  /**
16
16
  * Raised when a snapshot declares a protocol version that no registered variant is able to read.
17
17
  *
@@ -23,41 +23,50 @@ import { Data, Either, Option, Schema } from 'effect';
23
23
  export class UnsupportedSnapshotVersionError extends Data.TaggedError('@midnightntwrk/wallet-sdk-shielded/Restore/UnsupportedSnapshotVersionError') {
24
24
  }
25
25
  /**
26
- * The envelope the peek reads.
26
+ * Reads the protocol version a serialized shielded wallet snapshot declares.
27
27
  *
28
- * @remarks
29
- * Deliberately the smallest possible description of a snapshot: one optional field, every other ignored. It has to read
30
- * snapshots written by _any_ variant, including ones whose full schema this build does not have, so it must not
31
- * assert anything it does not need. Everything it cannot make sense of is reported as "no version declared", leaving
32
- * the real diagnosis to the deserializer that eventually reads the whole thing.
28
+ * @param serialized The serialized wallet state.
29
+ * @returns The declared version, or `Option.none()` when the snapshot declares none or cannot be read at all.
33
30
  */
34
- const EnvelopeSchema = Schema.Struct({
35
- protocolVersion: Schema.optional(ProtocolVersion.ProtocolVersionSchema),
36
- });
31
+ export const peekProtocolVersion = (serialized) => SnapshotRouting.readEnvelope(serialized).protocolVersion;
37
32
  /**
38
- * Reads the protocol version a serialized wallet snapshot declares.
33
+ * Reads which variant wrote a serialized shielded wallet snapshot, when it says.
39
34
  *
35
+ * @remarks
36
+ * Shielded snapshots carry nothing in their shape that says which variant wrote them, so only the name counts; a
37
+ * snapshot written before snapshots named their writer reads as naming none.
40
38
  * @param serialized The serialized wallet state.
41
- * @returns The declared version, or `Option.none()` when the snapshot declares none or cannot be read at all.
39
+ * @returns The writer, or `Option.none()` when the snapshot predates the field, names a writer this build does not
40
+ * know, or cannot be read at all.
42
41
  */
43
- export const peekProtocolVersion = (serialized) => Schema.decodeUnknownOption(Schema.parseJson(EnvelopeSchema))(serialized).pipe(Option.flatMap((envelope) => Option.fromNullable(envelope.protocolVersion)));
42
+ export const peekWriter = (serialized) => SnapshotRouting.readEnvelope(serialized).writer;
44
43
  /**
45
- * Chooses the variant that should read a serialized wallet snapshot.
44
+ * Chooses the variant that should read a serialized shielded wallet snapshot.
46
45
  *
47
46
  * @remarks
48
- * A snapshot that declares no version predates snapshots declaring one, and can only have been written by the variant
49
- * that shipped before the question arose — the head variant. The same fallback covers an envelope this function
50
- * cannot read at all: refusing it here would replace the deserializer's precise error with a vaguer one.
47
+ * The rule is {@link SnapshotRouting.routeSnapshot}'s: the variant that wrote the snapshot when it says which and that
48
+ * variant is registered, otherwise the variant that owns the declared version, otherwise the head variant. The
49
+ * envelope is read once here, not once per question.
51
50
  * @param serialized The serialized wallet state.
52
51
  * @param variantFor Resolves the variant registered for a protocol version.
53
52
  * @param headVariant The variant a snapshot with no declared version is restored into.
54
- * @returns The variant to restore with, or {@link UnsupportedSnapshotVersionError} when the declared version is one no
55
- * registered variant owns.
53
+ * @param variantWrittenBy Resolves the registered variant that a snapshot names as its writer, if it is registered.
54
+ * Defaults to none, which is the routing every caller had before snapshots named their writer.
55
+ * @param activationOf The protocol version a registered variant starts answering for, as the wallet registered it. The
56
+ * writer takes a snapshot only from that version upwards; below it the version routes, since the runtime cannot hand
57
+ * over backwards. Defaults to the minimum version, which lets the writer take any snapshot, for compositions that
58
+ * pass no writer resolver either.
59
+ * @returns The variant to restore with, or {@link UnsupportedSnapshotVersionError} when the writer is not registered and
60
+ * the declared version is one no registered variant owns.
56
61
  */
57
- export const variantForSnapshot = (serialized, variantFor, headVariant) => Option.match(peekProtocolVersion(serialized), {
58
- onNone: () => Either.right(headVariant),
59
- onSome: (protocolVersion) => Either.fromOption(variantFor(protocolVersion), () => new UnsupportedSnapshotVersionError({
62
+ export const variantForSnapshot = (serialized, variantFor, headVariant, variantWrittenBy = () => Option.none(), activationOf = () => ProtocolVersion.MinSupportedVersion) => SnapshotRouting.routeSnapshot({
63
+ activationOf,
64
+ envelope: SnapshotRouting.readEnvelope(serialized),
65
+ variantFor,
66
+ headVariant,
67
+ variantWrittenBy,
68
+ unsupported: (protocolVersion) => new UnsupportedSnapshotVersionError({
60
69
  message: `No registered variant reads shielded wallet snapshots of protocol version ${protocolVersion}.`,
61
70
  protocolVersion,
62
- })),
71
+ }),
63
72
  });
@@ -10,12 +10,12 @@
10
10
  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
- import { ProtocolVersion, WalletSeed, WalletTransaction, } from '@midnightntwrk/wallet-sdk-abstractions';
13
+ import { ProtocolVersion, WalletSeed, WalletTransaction, SnapshotFormat, } from '@midnightntwrk/wallet-sdk-abstractions';
14
14
  import { makeIndexerChainVersionProbe, } from '@midnightntwrk/wallet-sdk-capabilities/chainVersion';
15
15
  import { WalletBuilder } from '@midnightntwrk/wallet-sdk-runtime';
16
16
  import { StartMaterial, Variant, } from '@midnightntwrk/wallet-sdk-runtime/abstractions';
17
17
  import { EitherOps, HList } from '@midnightntwrk/wallet-sdk-utilities';
18
- import { Duration, Effect, Either, Option, Ref, pipe } from 'effect';
18
+ import { Duration, Effect, Either, Match, Option, Ref, pipe } from 'effect';
19
19
  import * as rx from 'rxjs';
20
20
  import { variantForSnapshot } from './Restore.js';
21
21
  import { ShieldedWalletState, } from './ShieldedWalletAPI.js';
@@ -167,9 +167,26 @@ export function CustomForkingShieldedWallet(configuration, v1, v2) {
167
167
  wallet.#retainKeys({ v8: Option.some(keys.v8), v9: Option.some(keys.v9) });
168
168
  return wallet;
169
169
  }
170
+ /**
171
+ * The registered variant a snapshot names as its writer, if that variant is registered.
172
+ *
173
+ * @remarks
174
+ * Snapshots name their writer by the ordinal in the variant's name, and each variant registers under its tag; this
175
+ * is the one place the two are paired, so routing can send a snapshot home whatever version it carries.
176
+ * @param writer The writer a snapshot names.
177
+ * @returns That variant, or none when this composition does not register it.
178
+ */
179
+ static variantWrittenBy(writer) {
180
+ // Exhaustive on purpose: a writer added to `SNAPSHOT_WRITERS` must fail here at compile time, never fall
181
+ // through to a variant that did not write the snapshot.
182
+ const tag = Match.value(writer).pipe(Match.when(SnapshotFormat.V1_SNAPSHOT_WRITER, () => V1Tag), Match.when(SnapshotFormat.V2_SNAPSHOT_WRITER, () => V2Tag), Match.exhaustive);
183
+ return Option.fromNullable(ForkingShieldedWalletImplementation.allVariants().find((variant) => Variant.getVersionedVariantTag(variant) === tag));
184
+ }
170
185
  static tryRestore(serializedState) {
171
186
  const headVariant = HList.head(ForkingShieldedWalletImplementation.allVariants());
172
- return variantForSnapshot(serializedState, (version) => ForkingShieldedWalletImplementation.variantFor(version), headVariant).pipe(
187
+ return variantForSnapshot(serializedState, (version) => ForkingShieldedWalletImplementation.variantFor(version), headVariant, (writer) => ForkingShieldedWalletImplementation.variantWrittenBy(writer),
188
+ // Each variant's activation as registered here, so a writer never takes a snapshot from below its own range.
189
+ (variant) => variant.sinceVersion).pipe(
173
190
  // Stated with its result type because the resolved variant is either of the two, so its deserializer is
174
191
  // either of theirs: what comes back is a state of whichever one wrote the snapshot, which is what
175
192
  // `startAtVariant` takes.
@@ -182,7 +199,7 @@ export function CustomForkingShieldedWallet(configuration, v1, v2) {
182
199
  }));
183
200
  }
184
201
  static restore(serializedState) {
185
- return Either.getOrThrow(ForkingShieldedWalletImplementation.tryRestore(serializedState));
202
+ return Either.getOrThrowWith(ForkingShieldedWalletImplementation.tryRestore(serializedState), (error) => error);
186
203
  }
187
204
  state;
188
205
  /**
@@ -82,10 +82,14 @@ export function CustomShieldedWallet(configuration, builder) {
82
82
  // Routing reads a serialized envelope, which only a wallet keeping the default string serialization has. A
83
83
  // custom format is left with the behaviour it has always had.
84
84
  typeof serializedState === 'string'
85
- ? variantForSnapshot(serializedState, (version) => CustomShieldedWalletImplementation.variantFor(version), headVariant)
85
+ ? variantForSnapshot(serializedState, (version) => CustomShieldedWalletImplementation.variantFor(version), headVariant,
86
+ // A custom composition registers one variant, so there is no other writer to route to.
87
+ () => Option.none())
86
88
  : Either.right(headVariant);
87
- const variant = routed.pipe(Either.getOrThrow);
88
- const deserialized = variant.variant.deserializeState(serializedState).pipe(Either.getOrThrow);
89
+ const variant = routed.pipe(Either.getOrThrowWith((error) => error));
90
+ const deserialized = variant.variant
91
+ .deserializeState(serializedState)
92
+ .pipe(Either.getOrThrowWith((error) => error));
89
93
  return CustomShieldedWalletImplementation.startAtVariant(CustomShieldedWalletImplementation, variant, deserialized);
90
94
  }
91
95
  state;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The format version this build writes into a shielded snapshot. It versions the encoded shape of the snapshot and is
3
+ * not `protocolVersion`, which is the chain's hard-fork number and lives inside it.
4
+ *
5
+ * Snapshots written before this field existed carry the same fields under a different name for the same shape, so a
6
+ * missing `version` reads as this one and no upgrade runs. The field earns its keep on the day the shape does change:
7
+ * that shape becomes `v2` and a single v1-to-v2 step is written, rather than every reader having to guess.
8
+ *
9
+ * Declared here, with no ledger import, so that both variants share one constant and neither pulls the other's ledger
10
+ * into its module graph to read it.
11
+ */
12
+ export declare const SNAPSHOT_FORMAT_VERSION = "v1";
@@ -0,0 +1,24 @@
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
+ /**
14
+ * The format version this build writes into a shielded snapshot. It versions the encoded shape of the snapshot and is
15
+ * not `protocolVersion`, which is the chain's hard-fork number and lives inside it.
16
+ *
17
+ * Snapshots written before this field existed carry the same fields under a different name for the same shape, so a
18
+ * missing `version` reads as this one and no upgrade runs. The field earns its keep on the day the shape does change:
19
+ * that shape becomes `v2` and a single v1-to-v2 step is written, rather than every reader having to guess.
20
+ *
21
+ * Declared here, with no ledger import, so that both variants share one constant and neither pulls the other's ledger
22
+ * into its module graph to read it.
23
+ */
24
+ export const SNAPSHOT_FORMAT_VERSION = 'v1';
@@ -28,12 +28,31 @@ export type CoreWallet = Readonly<{
28
28
  progress: SyncProgress.SyncProgress;
29
29
  networkId: string;
30
30
  coinHashes: CoinHashesMap;
31
+ /**
32
+ * The transaction history that a 1.0.0 snapshot embedded, as the hex-encoded proven ledger transactions it was
33
+ * written with. Carried across a restore and written back out untouched, so upgrading does not destroy it; this SDK
34
+ * keeps history in its own storage and never reads or adds to this list.
35
+ *
36
+ * Absent for every snapshot written since the field was dropped from the schema, and absent means absent — it is not
37
+ * written back as an empty list, so those snapshots keep the bytes they had.
38
+ */
39
+ legacyTxHistory?: readonly string[];
40
+ /**
41
+ * The V2 variant's mid-crossing marker, when a snapshot this variant was handed happened to carry one.
42
+ *
43
+ * This variant has no such state: it never crosses into itself, so it neither sets this nor reads it. It is kept only
44
+ * so that reading such a snapshot and writing it back does not quietly discard the field — the same reason
45
+ * {@link legacyTxHistory} is kept, and the same failure it exists to prevent. `Restore.ts` routes a snapshot to the
46
+ * variant that owns its `protocolVersion`, so in a build registering both twins this is never populated; it is the
47
+ * reader's promise not to lose what it cannot represent, not a state this variant can be in.
48
+ */
49
+ foreignCoinHashesPending?: true;
31
50
  }>;
32
51
  export declare const CoreWallet: {
33
52
  init(localState: ledger.ZswapLocalState, secretKeys: ledger.ZswapSecretKeys, networkId: string): CoreWallet;
34
53
  empty(publicKeys: PublicKeys, networkId: string): CoreWallet;
35
54
  restore(localState: ledger.ZswapLocalState, secretKeys: ledger.ZswapSecretKeys, syncProgress: Omit<SyncProgress.SyncProgressData, "isConnected">, protocolVersion: bigint, networkId: string): CoreWallet;
36
- restoreWithCoinHashes(publicKeys: PublicKeys, localState: ledger.ZswapLocalState, coinHashes: CoinHashesMap, syncProgress: SyncProgress.SyncProgressData, protocolVersion: bigint, networkId: string): Either.Either<CoreWallet, WalletError>;
55
+ restoreWithCoinHashes(publicKeys: PublicKeys, localState: ledger.ZswapLocalState, coinHashes: CoinHashesMap, syncProgress: SyncProgress.SyncProgressData, protocolVersion: bigint, networkId: string, legacyTxHistory?: readonly string[], foreignCoinHashesPending?: true): Either.Either<CoreWallet, WalletError>;
37
56
  initEmpty(keys: ledger.ZswapSecretKeys, networkId: string): CoreWallet;
38
57
  /**
39
58
  * Records an observed protocol version on the wallet, never going backwards.
@@ -81,7 +81,7 @@ export const CoreWallet = {
81
81
  protocolVersion: ProtocolVersion.ProtocolVersion(protocolVersion),
82
82
  };
83
83
  },
84
- restoreWithCoinHashes(publicKeys, localState, coinHashes, syncProgress, protocolVersion, networkId) {
84
+ restoreWithCoinHashes(publicKeys, localState, coinHashes, syncProgress, protocolVersion, networkId, legacyTxHistory, foreignCoinHashesPending) {
85
85
  return CoinHashesMap.assertValid(coinHashes, localState).pipe(Either.mapBoth({
86
86
  onLeft: (missingNonces) => new InvalidCoinHashesError({ message: 'Missing coin hashes for coins present in the state', missingNonces }),
87
87
  onRight: () => ({
@@ -91,6 +91,10 @@ export const CoreWallet = {
91
91
  coinHashes,
92
92
  progress: SyncProgress.createSyncProgress(syncProgress),
93
93
  protocolVersion: ProtocolVersion.ProtocolVersion(protocolVersion),
94
+ // Spread conditionally: a snapshot that carried no embedded history must not gain a `legacyTxHistory` key,
95
+ // so that re-serializing it writes no `txHistory` field it did not already have.
96
+ ...(legacyTxHistory === undefined ? {} : { legacyTxHistory }),
97
+ ...(foreignCoinHashesPending === undefined ? {} : { foreignCoinHashesPending }),
94
98
  }),
95
99
  }));
96
100
  },
@@ -1,5 +1,5 @@
1
1
  import { Either } from 'effect';
2
- import { WalletError } from './WalletError.js';
2
+ import { type WalletError } from './WalletError.js';
3
3
  import { CoreWallet } from './CoreWallet.js';
4
4
  import { type NetworkId } from '@midnightntwrk/wallet-sdk-abstractions';
5
5
  export type SerializationCapability<TWallet, TAux, TSerialized> = {
@@ -9,4 +9,5 @@ export type SerializationCapability<TWallet, TAux, TSerialized> = {
9
9
  export type DefaultSerializationConfiguration = {
10
10
  networkId: NetworkId.NetworkId;
11
11
  };
12
+ export { SNAPSHOT_FORMAT_VERSION } from '../SnapshotFormat.js';
12
13
  export declare const makeDefaultV1SerializationCapability: () => SerializationCapability<CoreWallet, null, string>;
@@ -11,9 +11,9 @@
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
13
  import { Effect, ParseResult, Either, pipe, Schema } from 'effect';
14
- import { WalletError } from './WalletError.js';
15
14
  import * as ledger from '@midnight-ntwrk/ledger-v8';
16
15
  import { CoreWallet } from './CoreWallet.js';
16
+ import { SnapshotFormat } from '@midnightntwrk/wallet-sdk-abstractions';
17
17
  const StateSchema = Schema.declare((input) => input instanceof ledger.ZswapLocalState).annotations({
18
18
  identifier: 'ledger.ZswapLocalState',
19
19
  });
@@ -39,8 +39,14 @@ const StateFromUInt8Array = () => Schema.asSchema(Schema.transformOrFail(Uint8Ar
39
39
  }),
40
40
  }));
41
41
  const HexedState = () => pipe(Schema.Uint8ArrayFromHex, Schema.compose(StateFromUInt8Array()));
42
+ // The format version lives beside the twins, not in either of them, so the V2 variant can read it without loading
43
+ // ledger-v8. Re-exported here because the version is part of this variant's serialization surface.
44
+ export { SNAPSHOT_FORMAT_VERSION } from '../SnapshotFormat.js';
45
+ import { SNAPSHOT_FORMAT_VERSION } from '../SnapshotFormat.js';
42
46
  export const makeDefaultV1SerializationCapability = () => {
43
47
  const SnapshotSchema = Schema.Struct({
48
+ version: SnapshotFormat.versionField('shielded', SNAPSHOT_FORMAT_VERSION),
49
+ writtenBy: SnapshotFormat.writtenByField(),
44
50
  publicKeys: Schema.Struct({
45
51
  coinPublicKey: Schema.String,
46
52
  encryptionPublicKey: Schema.String,
@@ -53,27 +59,43 @@ export const makeDefaultV1SerializationCapability = () => {
53
59
  key: Schema.String,
54
60
  value: Schema.Struct({ nullifier: Schema.String, commitment: Schema.String }),
55
61
  }),
62
+ // 1.0.0 embedded the transaction history here, as hex-encoded proven ledger transactions. The field was dropped
63
+ // from this schema when history moved to its own storage, and because Effect Schema ignores keys it does not
64
+ // know, those snapshots restored without complaint and lost the history on the next write. Declaring it optional
65
+ // carries it back out untouched. Optional, not defaulted: a snapshot written without it keeps its exact bytes.
66
+ txHistory: Schema.optional(Schema.Array(Schema.String)),
67
+ // The V2 variant's mid-crossing marker. This variant is never in that state and never reads this, but the twins
68
+ // share one format version, so a V2-written snapshot is this schema's shape too — and a key this schema did not
69
+ // declare would be ignored on the way in and dropped on the way out, which is exactly how `txHistory` above was
70
+ // lost. Declared here so it is carried instead. See `CoreWallet.foreignCoinHashesPending`.
71
+ coinHashesPending: Schema.optional(Schema.Literal(true)),
56
72
  });
57
73
  return {
58
74
  serialize: (wallet) => {
59
75
  const buildSnapshot = (w) => ({
76
+ version: SNAPSHOT_FORMAT_VERSION,
77
+ writtenBy: SnapshotFormat.V1_SNAPSHOT_WRITER,
60
78
  publicKeys: w.publicKeys,
61
79
  state: w.state,
62
80
  protocolVersion: w.protocolVersion,
63
81
  networkId: w.networkId,
64
- offset: w.progress?.appliedIndex,
65
82
  coinHashes: w.coinHashes,
83
+ // Optional fields are spread in only when present, so the snapshot object never carries an `undefined` key:
84
+ // the bytes must not depend on `JSON.stringify` dropping one.
85
+ ...(w.progress?.appliedIndex !== undefined ? { offset: w.progress.appliedIndex } : {}),
86
+ ...(w.legacyTxHistory !== undefined ? { txHistory: w.legacyTxHistory } : {}),
87
+ ...(w.foreignCoinHashesPending !== undefined ? { coinHashesPending: w.foreignCoinHashesPending } : {}),
66
88
  });
67
89
  return pipe(wallet, buildSnapshot, Schema.encodeSync(SnapshotSchema), JSON.stringify);
68
90
  },
69
91
  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, {
92
+ return pipe(serialized, SnapshotFormat.readSnapshot({ surface: 'shielded', reads: [SNAPSHOT_FORMAT_VERSION], schema: SnapshotSchema }), Either.flatMap((snapshot) => CoreWallet.restoreWithCoinHashes(snapshot.publicKeys, snapshot.state, snapshot.coinHashes, {
71
93
  appliedIndex: snapshot.offset ?? 0n,
72
94
  highestRelevantWalletIndex: 0n,
73
95
  highestIndex: 0n,
74
96
  highestRelevantIndex: 0n,
75
97
  isConnected: false,
76
- }, snapshot.protocolVersion, snapshot.networkId)));
98
+ }, snapshot.protocolVersion, snapshot.networkId, snapshot.txHistory, snapshot.coinHashesPending)));
77
99
  },
78
100
  };
79
101
  };
@@ -1,7 +1,7 @@
1
1
  import { TransactionHistoryStorage } from '@midnightntwrk/wallet-sdk-abstractions';
2
2
  import type * as ledger from '@midnight-ntwrk/ledger-v8';
3
3
  import { Duration, Effect, Schema } from 'effect';
4
- import { TransactionHistoryError } from './WalletError.js';
4
+ import { BlocklessFinalizedEntryError, TransactionHistoryError } from './WalletError.js';
5
5
  export declare const QualifiedShieldedCoinInfoSchema: Schema.Struct<{
6
6
  type: typeof Schema.String;
7
7
  nonce: typeof Schema.String;
@@ -39,11 +39,11 @@ export declare const ShieldedTransactionHistoryEntrySchema: Schema.Struct<Readon
39
39
  submittedAt: typeof Schema.Date;
40
40
  }>, Schema.Struct<{
41
41
  status: Schema.Literal<["finalized"]>;
42
- finalizedBlock: Schema.Struct<{
42
+ finalizedBlock: Schema.optional<Schema.Struct<{
43
43
  hash: typeof Schema.String;
44
44
  height: typeof Schema.Number;
45
45
  timestamp: typeof Schema.Date;
46
- }>;
46
+ }>>;
47
47
  }>, Schema.Struct<{
48
48
  status: Schema.Literal<["rejected"]>;
49
49
  rejectedAt: typeof Schema.Date;
@@ -92,7 +92,7 @@ export type TransactionDetails = {
92
92
  };
93
93
  export type TransactionHistoryService = {
94
94
  put(changes: ledger.ZswapStateChanges, metadata: TransactionDetails, protocolVersion: number): Effect.Effect<void, TransactionHistoryError>;
95
- getTransactionDetails(hash: TransactionHistoryStorage.TransactionHash): Effect.Effect<TransactionDetails, TransactionHistoryError>;
95
+ getTransactionDetails(hash: TransactionHistoryStorage.TransactionHash): Effect.Effect<TransactionDetails, TransactionHistoryError | BlocklessFinalizedEntryError>;
96
96
  };
97
97
  export declare const mergeShieldedSections: (existing: ShieldedSection, incoming: ShieldedSection) => ShieldedSection;
98
98
  export declare const makeDefaultTransactionHistoryService: (config: DefaultTransactionHistoryConfiguration, _getContext: () => unknown) => TransactionHistoryService;
@@ -14,7 +14,7 @@ import { TransactionHistoryStorage } from '@midnightntwrk/wallet-sdk-abstraction
14
14
  import { Duration, Array as EArray, Effect, Schedule, Schema } from 'effect';
15
15
  import { TransactionHistoryDetail } from '@midnightntwrk/wallet-sdk-indexer-client';
16
16
  import { HttpQueryClient } from '@midnightntwrk/wallet-sdk-indexer-client/effect';
17
- import { TransactionHistoryError } from './WalletError.js';
17
+ import { BlocklessFinalizedEntryError, TransactionHistoryError } from './WalletError.js';
18
18
  export const QualifiedShieldedCoinInfoSchema = Schema.Struct({
19
19
  type: Schema.String,
20
20
  nonce: Schema.String,
@@ -116,17 +116,26 @@ export const makeSimulatorTransactionHistoryService = (config, _getContext) => {
116
116
  getTransactionDetails: (hash) => Effect.tryPromise({
117
117
  try: () => txHistoryStorage.get(hash),
118
118
  catch: (e) => new TransactionHistoryError({ message: `Failed to get transaction details for ${hash}`, cause: e }),
119
- }).pipe(Effect.flatMap((entry) => isFinalized(entry)
120
- ? Effect.succeed({
121
- hash: entry.hash,
122
- block: {
123
- hash: entry.lifecycle.finalizedBlock.hash,
124
- height: entry.lifecycle.finalizedBlock.height,
125
- timestamp: entry.lifecycle.finalizedBlock.timestamp.getTime(),
126
- },
127
- status: entry.status ?? 'SUCCESS',
128
- identifiers: entry.identifiers,
129
- })
130
- : Effect.fail(new TransactionHistoryError({ message: `No transaction found in storage for hash: ${hash}` })))),
119
+ }).pipe(
120
+ // Two distinct failures, reported distinctly. Storage may never have heard of the transaction; or it holds a
121
+ // finalized entry restored from a history written before the lifecycle field existed, which records no block.
122
+ // This service backs the simulator, where storage is the only source, so neither case can be repaired here.
123
+ Effect.flatMap((entry) => !isFinalized(entry)
124
+ ? Effect.fail(new TransactionHistoryError({ message: `No transaction found in storage for hash: ${hash}` }))
125
+ : entry.lifecycle.finalizedBlock === undefined
126
+ ? Effect.fail(new BlocklessFinalizedEntryError({
127
+ message: `Transaction ${hash} is finalized but its history entry records no block (restored from a pre-lifecycle history)`,
128
+ hash,
129
+ }))
130
+ : Effect.succeed({
131
+ hash: entry.hash,
132
+ block: {
133
+ hash: entry.lifecycle.finalizedBlock.hash,
134
+ height: entry.lifecycle.finalizedBlock.height,
135
+ timestamp: entry.lifecycle.finalizedBlock.timestamp.getTime(),
136
+ },
137
+ status: entry.status ?? 'SUCCESS',
138
+ identifiers: entry.identifiers,
139
+ }))),
131
140
  };
132
141
  };
@@ -203,6 +203,9 @@ export class V1Builder {
203
203
  message: 'Failed to migrate shielded wallet state into this variant',
204
204
  cause: error,
205
205
  }))),
206
+ // The version the state was left at: where the chain was when a snapshot was written, or what the chain answered
207
+ // when a fresh state was stamped from it. The runtime starts at this version rather than the variant's lower bound.
208
+ protocolVersionOf: (state) => state.protocolVersion,
206
209
  deserializeState: (serialized) => {
207
210
  return v1Context.serializationCapability.deserialize(null, serialized);
208
211
  },
@@ -1,10 +1,11 @@
1
+ import { type SnapshotFormat } from '@midnightntwrk/wallet-sdk-abstractions';
1
2
  import type * as ledger from '@midnight-ntwrk/ledger-v8';
2
3
  import { type LedgerOps } from '@midnightntwrk/wallet-sdk-utilities';
3
4
  export declare const WalletError: {
4
5
  other(err: unknown): WalletError;
5
6
  submission(err: unknown): WalletError;
6
7
  };
7
- export type WalletError = OtherWalletError | InsufficientFundsError | SubmissionError | AddressError | SyncWalletError | InvalidCoinHashesError | TransactingError | OutOfOrderSyncUpdateError | LedgerOps.LedgerError;
8
+ export type WalletError = SnapshotFormat.SnapshotRestoreError | OtherWalletError | InsufficientFundsError | SubmissionError | AddressError | SyncWalletError | InvalidCoinHashesError | TransactingError | OutOfOrderSyncUpdateError | LedgerOps.LedgerError;
8
9
  declare const OtherWalletError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
9
10
  readonly _tag: "Wallet.Other";
10
11
  } & Readonly<A>;
@@ -71,6 +72,28 @@ export declare class TransactionHistoryError extends TransactionHistoryError_bas
71
72
  cause?: unknown;
72
73
  }> {
73
74
  }
75
+ declare const BlocklessFinalizedEntryError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
76
+ readonly _tag: "Wallet.BlocklessFinalizedEntry";
77
+ } & Readonly<A>;
78
+ /**
79
+ * A finalized history entry that records no block, met where a block is needed.
80
+ *
81
+ * @remarks
82
+ * Only an entry restored from a history written before the lifecycle field existed looks like this: it is known to have
83
+ * been finalized, but the block it landed in was never recorded, and none is invented. It is its own error rather
84
+ * than a message on {@link TransactionHistoryError} so that a caller which can fetch the block by hash matches on the
85
+ * tag.
86
+ * @example
87
+ * ```ts
88
+ * if (error._tag === 'Wallet.BlocklessFinalizedEntry') fetchBlockFor(error.hash);
89
+ * ```;
90
+ */
91
+ export declare class BlocklessFinalizedEntryError extends BlocklessFinalizedEntryError_base<{
92
+ readonly message: string;
93
+ /** The transaction whose history entry records no block. */
94
+ readonly hash: string;
95
+ }> {
96
+ }
74
97
  declare const OutOfOrderSyncUpdateError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
75
98
  readonly _tag: "Wallet.OutOfOrderSyncUpdate";
76
99
  } & Readonly<A>;
@@ -51,6 +51,21 @@ export class TransactingError extends Data.TaggedError('Wallet.Transacting') {
51
51
  }
52
52
  export class TransactionHistoryError extends Data.TaggedError('Wallet.TransactionHistory') {
53
53
  }
54
+ /**
55
+ * A finalized history entry that records no block, met where a block is needed.
56
+ *
57
+ * @remarks
58
+ * Only an entry restored from a history written before the lifecycle field existed looks like this: it is known to have
59
+ * been finalized, but the block it landed in was never recorded, and none is invented. It is its own error rather
60
+ * than a message on {@link TransactionHistoryError} so that a caller which can fetch the block by hash matches on the
61
+ * tag.
62
+ * @example
63
+ * ```ts
64
+ * if (error._tag === 'Wallet.BlocklessFinalizedEntry') fetchBlockFor(error.hash);
65
+ * ```;
66
+ */
67
+ export class BlocklessFinalizedEntryError extends Data.TaggedError('Wallet.BlocklessFinalizedEntry') {
68
+ }
54
69
  /**
55
70
  * A batch of zswap ledger events was not in ascending id order: an event arrived at or below the one before it, or at
56
71
  * or below the applied cursor.
@@ -43,12 +43,21 @@ export type CoreWallet = Readonly<{
43
43
  * inhabitant, so "pending" and "absent" are the only two states.
44
44
  */
45
45
  coinHashesPending?: true;
46
+ /**
47
+ * The transaction history that a 1.0.0 snapshot embedded, as the hex-encoded proven ledger transactions it was
48
+ * written with. Carried across a restore and across the ledger-version boundary, and written back out untouched, so
49
+ * upgrading does not destroy it; this SDK keeps history in its own storage and never reads or adds to this list.
50
+ *
51
+ * Absent for every snapshot written since the field was dropped from the schema, and absent means absent — it is not
52
+ * written back as an empty list, so those snapshots keep the bytes they had.
53
+ */
54
+ legacyTxHistory?: readonly string[];
46
55
  }>;
47
56
  export declare const CoreWallet: {
48
57
  init(localState: ledger.ZswapLocalState, secretKeys: ledger.ZswapSecretKeys, networkId: string): CoreWallet;
49
58
  empty(publicKeys: PublicKeys, networkId: string): CoreWallet;
50
59
  restore(localState: ledger.ZswapLocalState, secretKeys: ledger.ZswapSecretKeys, syncProgress: Omit<SyncProgress.SyncProgressData, "isConnected">, protocolVersion: bigint, networkId: string): CoreWallet;
51
- restoreWithCoinHashes(publicKeys: PublicKeys, localState: ledger.ZswapLocalState, coinHashes: CoinHashesMap, syncProgress: SyncProgress.SyncProgressData, protocolVersion: bigint, networkId: string): Either.Either<CoreWallet, WalletError>;
60
+ restoreWithCoinHashes(publicKeys: PublicKeys, localState: ledger.ZswapLocalState, coinHashes: CoinHashesMap, syncProgress: SyncProgress.SyncProgressData, protocolVersion: bigint, networkId: string, legacyTxHistory?: readonly string[]): Either.Either<CoreWallet, WalletError>;
52
61
  /**
53
62
  * Restores a wallet whose coin hashes were never computed — a snapshot taken mid-crossing.
54
63
  *
@@ -64,9 +73,10 @@ export declare const CoreWallet: {
64
73
  * @param syncProgress Where the snapshot's reading had got to.
65
74
  * @param protocolVersion The version the snapshot was written under.
66
75
  * @param networkId The network the snapshot claims.
76
+ * @param legacyTxHistory The transaction history the snapshot embedded, when it carried one.
67
77
  * @returns A wallet holding that state, its coin hashes still pending.
68
78
  */
69
- restoreWithPendingCoinHashes(publicKeys: PublicKeys, localState: ledger.ZswapLocalState, syncProgress: SyncProgress.SyncProgressData, protocolVersion: bigint, networkId: string): CoreWallet;
79
+ restoreWithPendingCoinHashes(publicKeys: PublicKeys, localState: ledger.ZswapLocalState, syncProgress: SyncProgress.SyncProgressData, protocolVersion: bigint, networkId: string, legacyTxHistory?: readonly string[]): CoreWallet;
70
80
  initEmpty(keys: ledger.ZswapSecretKeys, networkId: string): CoreWallet;
71
81
  /**
72
82
  * Records an observed protocol version on the wallet, never going backwards.
@@ -105,7 +115,9 @@ export declare const CoreWallet: {
105
115
  * the first place in this variant where keys and state meet. The same step releases the spends the state crossed
106
116
  * with: those transactions belong to the ledger version the chain has left behind and can never be included, so
107
117
  * nothing else would ever free the coins they reserved.
108
- * @param previous The identity and position read off the previous ledger version's wallet, and its decoded state.
118
+ * @param previous The identity and position read off the previous ledger version's wallet, and its decoded state. Its
119
+ * embedded transaction history, when it holds one, crosses too: the far side writes it back out and nothing there
120
+ * could rebuild it.
109
121
  * @returns A wallet of this ledger version holding what its predecessor held, positioned at the fork.
110
122
  */
111
123
  fromPreviousVersion(previous: {
@@ -114,6 +126,7 @@ export declare const CoreWallet: {
114
126
  readonly networkId: string;
115
127
  readonly protocolVersion: bigint;
116
128
  readonly progress: SyncProgress.SyncProgressData;
129
+ readonly legacyTxHistory?: readonly string[];
117
130
  }): CoreWallet;
118
131
  /**
119
132
  * Finishes a crossing at the first sync update: the reservations released, then the coin hashes computed.
@@ -102,7 +102,7 @@ export const CoreWallet = {
102
102
  protocolVersion: ProtocolVersion.ProtocolVersion(protocolVersion),
103
103
  };
104
104
  },
105
- restoreWithCoinHashes(publicKeys, localState, coinHashes, syncProgress, protocolVersion, networkId) {
105
+ restoreWithCoinHashes(publicKeys, localState, coinHashes, syncProgress, protocolVersion, networkId, legacyTxHistory) {
106
106
  return CoinHashesMap.assertValid(coinHashes, localState).pipe(Either.mapBoth({
107
107
  onLeft: (missingNonces) => new InvalidCoinHashesError({ message: 'Missing coin hashes for coins present in the state', missingNonces }),
108
108
  onRight: () => ({
@@ -112,6 +112,9 @@ export const CoreWallet = {
112
112
  coinHashes,
113
113
  progress: SyncProgress.createSyncProgress(syncProgress),
114
114
  protocolVersion: ProtocolVersion.ProtocolVersion(protocolVersion),
115
+ // Spread conditionally: a snapshot that carried no embedded history must not gain a `legacyTxHistory` key,
116
+ // so that re-serializing it writes no `txHistory` field it did not already have.
117
+ ...(legacyTxHistory === undefined ? {} : { legacyTxHistory }),
115
118
  }),
116
119
  }));
117
120
  },
@@ -130,9 +133,10 @@ export const CoreWallet = {
130
133
  * @param syncProgress Where the snapshot's reading had got to.
131
134
  * @param protocolVersion The version the snapshot was written under.
132
135
  * @param networkId The network the snapshot claims.
136
+ * @param legacyTxHistory The transaction history the snapshot embedded, when it carried one.
133
137
  * @returns A wallet holding that state, its coin hashes still pending.
134
138
  */
135
- restoreWithPendingCoinHashes(publicKeys, localState, syncProgress, protocolVersion, networkId) {
139
+ restoreWithPendingCoinHashes(publicKeys, localState, syncProgress, protocolVersion, networkId, legacyTxHistory) {
136
140
  return {
137
141
  state: localState,
138
142
  publicKeys,
@@ -141,6 +145,7 @@ export const CoreWallet = {
141
145
  coinHashesPending: true,
142
146
  progress: SyncProgress.createSyncProgress(syncProgress),
143
147
  protocolVersion: ProtocolVersion.ProtocolVersion(protocolVersion),
148
+ ...(legacyTxHistory === undefined ? {} : { legacyTxHistory }),
144
149
  };
145
150
  },
146
151
  initEmpty(keys, networkId) {
@@ -185,7 +190,9 @@ export const CoreWallet = {
185
190
  * the first place in this variant where keys and state meet. The same step releases the spends the state crossed
186
191
  * with: those transactions belong to the ledger version the chain has left behind and can never be included, so
187
192
  * nothing else would ever free the coins they reserved.
188
- * @param previous The identity and position read off the previous ledger version's wallet, and its decoded state.
193
+ * @param previous The identity and position read off the previous ledger version's wallet, and its decoded state. Its
194
+ * embedded transaction history, when it holds one, crosses too: the far side writes it back out and nothing there
195
+ * could rebuild it.
189
196
  * @returns A wallet of this ledger version holding what its predecessor held, positioned at the fork.
190
197
  */
191
198
  fromPreviousVersion(previous) {
@@ -197,6 +204,7 @@ export const CoreWallet = {
197
204
  coinHashesPending: true,
198
205
  progress: SyncProgress.createSyncProgress({ ...previous.progress, isConnected: false }),
199
206
  protocolVersion: ProtocolVersion.ProtocolVersion(previous.protocolVersion),
207
+ ...(previous.legacyTxHistory === undefined ? {} : { legacyTxHistory: previous.legacyTxHistory }),
200
208
  };
201
209
  },
202
210
  /**
@@ -238,16 +246,12 @@ export const CoreWallet = {
238
246
  * @returns `wallet` unchanged if its hashes were never pending, otherwise a copy holding them.
239
247
  */
240
248
  resolveCoinHashes(wallet, secretKeys) {
241
- return wallet.coinHashesPending === undefined
242
- ? wallet
243
- : {
244
- state: wallet.state,
245
- publicKeys: wallet.publicKeys,
246
- networkId: wallet.networkId,
247
- coinHashes: CoinHashesMap.init(secretKeys, CoinHashesMap.pickAllCoins(wallet.state)),
248
- progress: wallet.progress,
249
- protocolVersion: wallet.protocolVersion,
250
- };
249
+ if (wallet.coinHashesPending === undefined)
250
+ return wallet;
251
+ // Rebuilt by spread rather than from a list of fields, so nothing the wallet carried can be left behind: only the
252
+ // marker is shed, and the embedded transaction history of a 1.0.0 snapshot survives the first update after crossing.
253
+ const { coinHashesPending: _pending, ...carried } = wallet;
254
+ return { ...carried, coinHashes: CoinHashesMap.init(secretKeys, CoinHashesMap.pickAllCoins(wallet.state)) };
251
255
  },
252
256
  applyCollapsedUpdate(wallet, collapsed) {
253
257
  const newState = wallet.state.applyCollapsedUpdate(collapsed);
@@ -72,6 +72,8 @@ export type PreviousLedgerWallet = Readonly<{
72
72
  state: Readonly<{
73
73
  serialize: () => Uint8Array;
74
74
  }>;
75
+ /** The transaction history a 1.0.0 snapshot embedded, when the previous wallet was restored from one. */
76
+ legacyTxHistory?: readonly string[];
75
77
  }>;
76
78
  /**
77
79
  * The migration across a ledger-version boundary: the local state crosses as bytes, identity and position beside it.
@@ -92,5 +92,6 @@ export const makeCrossLedgerMigration = () => ({
92
92
  networkId: previousState.networkId,
93
93
  protocolVersion: previousState.protocolVersion,
94
94
  progress: previousState.progress,
95
+ ...(previousState.legacyTxHistory === undefined ? {} : { legacyTxHistory: previousState.legacyTxHistory }),
95
96
  })), EitherOps.toEffect),
96
97
  });
@@ -1,6 +1,7 @@
1
1
  import { Either } from 'effect';
2
- import { WalletError } from './WalletError.js';
2
+ import { type WalletError } from './WalletError.js';
3
3
  import { CoreWallet } from './CoreWallet.js';
4
+ export { SNAPSHOT_FORMAT_VERSION } from '../SnapshotFormat.js';
4
5
  import { type NetworkId } from '@midnightntwrk/wallet-sdk-abstractions';
5
6
  export type SerializationCapability<TWallet, TAux, TSerialized> = {
6
7
  serialize(wallet: TWallet): TSerialized;
@@ -11,9 +11,12 @@
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
13
  import { Effect, ParseResult, Either, pipe, Schema } from 'effect';
14
- import { WalletError } from './WalletError.js';
15
14
  import * as ledger from '@midnightntwrk/ledger-v9';
16
15
  import { CoreWallet } from './CoreWallet.js';
16
+ import { SNAPSHOT_FORMAT_VERSION } from '../SnapshotFormat.js';
17
+ // Re-exported because the version this variant writes is part of its serialization surface, as on the V1 twin.
18
+ export { SNAPSHOT_FORMAT_VERSION } from '../SnapshotFormat.js';
19
+ import { SnapshotFormat } from '@midnightntwrk/wallet-sdk-abstractions';
17
20
  const StateSchema = Schema.declare((input) => input instanceof ledger.ZswapLocalState).annotations({
18
21
  identifier: 'ledger.ZswapLocalState',
19
22
  });
@@ -41,6 +44,13 @@ const StateFromUInt8Array = () => Schema.asSchema(Schema.transformOrFail(Uint8Ar
41
44
  const HexedState = () => pipe(Schema.Uint8ArrayFromHex, Schema.compose(StateFromUInt8Array()));
42
45
  export const makeDefaultV2SerializationCapability = () => {
43
46
  const SnapshotSchema = Schema.Struct({
47
+ // The version names the snapshot's shape, not the variant that wrote it, and the constant lives in
48
+ // `../SnapshotFormat.ts` so the twins cannot drift apart. Both variants write this shape — the V2 variant adds
49
+ // only optional fields to it, which is not a new version. A V1 reader never meets a V2 snapshot anyway:
50
+ // `../Restore.ts` routes each snapshot to the variant it names as its writer, and by the variant that owns its
51
+ // `protocolVersion` when it names none.
52
+ version: SnapshotFormat.versionField('shielded', SNAPSHOT_FORMAT_VERSION),
53
+ writtenBy: SnapshotFormat.writtenByField(),
44
54
  publicKeys: Schema.Struct({
45
55
  coinPublicKey: Schema.String,
46
56
  encryptionPublicKey: Schema.String,
@@ -58,22 +68,30 @@ export const makeDefaultV2SerializationCapability = () => {
58
68
  // `CoreWallet.coinHashesPending`). Optional twice over — a wallet that is not mid-crossing has nothing to declare,
59
69
  // and snapshots written before the field existed must keep decoding unchanged.
60
70
  coinHashesPending: Schema.optional(Schema.Literal(true)),
71
+ // The transaction history a 1.0.0 snapshot embedded. Read back and written out untouched, never added to; absent
72
+ // for every snapshot written since, and kept absent for them. See `CoreWallet.legacyTxHistory`.
73
+ txHistory: Schema.optional(Schema.Array(Schema.String)),
61
74
  });
62
75
  return {
63
76
  serialize: (wallet) => {
64
77
  const buildSnapshot = (w) => ({
78
+ version: SNAPSHOT_FORMAT_VERSION,
79
+ writtenBy: SnapshotFormat.V2_SNAPSHOT_WRITER,
65
80
  publicKeys: w.publicKeys,
66
81
  state: w.state,
67
82
  protocolVersion: w.protocolVersion,
68
83
  networkId: w.networkId,
69
- offset: w.progress?.appliedIndex,
70
84
  coinHashes: w.coinHashes,
85
+ // Optional fields are spread in only when present, so the snapshot object never carries an `undefined` key:
86
+ // the bytes must not depend on `JSON.stringify` dropping one.
87
+ ...(w.progress?.appliedIndex !== undefined ? { offset: w.progress.appliedIndex } : {}),
88
+ ...(w.legacyTxHistory !== undefined ? { txHistory: w.legacyTxHistory } : {}),
71
89
  ...(w.coinHashesPending !== undefined ? { coinHashesPending: w.coinHashesPending } : {}),
72
90
  });
73
91
  return pipe(wallet, buildSnapshot, Schema.encodeSync(SnapshotSchema), JSON.stringify);
74
92
  },
75
93
  deserialize: (aux, serialized) => {
76
- return pipe(serialized, Schema.decodeUnknownEither(Schema.parseJson(SnapshotSchema)), Either.mapLeft((err) => WalletError.other(err)), Either.flatMap((snapshot) => {
94
+ return pipe(serialized, SnapshotFormat.readSnapshot({ surface: 'shielded', reads: [SNAPSHOT_FORMAT_VERSION], schema: SnapshotSchema }), Either.flatMap((snapshot) => {
77
95
  const progress = {
78
96
  appliedIndex: snapshot.offset ?? 0n,
79
97
  highestRelevantWalletIndex: 0n,
@@ -85,8 +103,8 @@ export const makeDefaultV2SerializationCapability = () => {
85
103
  // its state is full and its hash map is empty by construction, because the migration that wrote it had no
86
104
  // secret keys. Every other snapshot goes on being checked against its own state.
87
105
  return snapshot.coinHashesPending === true
88
- ? Either.right(CoreWallet.restoreWithPendingCoinHashes(snapshot.publicKeys, snapshot.state, progress, snapshot.protocolVersion, snapshot.networkId))
89
- : CoreWallet.restoreWithCoinHashes(snapshot.publicKeys, snapshot.state, snapshot.coinHashes, progress, snapshot.protocolVersion, snapshot.networkId);
106
+ ? Either.right(CoreWallet.restoreWithPendingCoinHashes(snapshot.publicKeys, snapshot.state, progress, snapshot.protocolVersion, snapshot.networkId, snapshot.txHistory))
107
+ : CoreWallet.restoreWithCoinHashes(snapshot.publicKeys, snapshot.state, snapshot.coinHashes, progress, snapshot.protocolVersion, snapshot.networkId, snapshot.txHistory);
90
108
  }));
91
109
  },
92
110
  };
@@ -1,7 +1,7 @@
1
1
  import { TransactionHistoryStorage } from '@midnightntwrk/wallet-sdk-abstractions';
2
2
  import type * as ledger from '@midnightntwrk/ledger-v9';
3
3
  import { Duration, Effect, Schema } from 'effect';
4
- import { TransactionHistoryError } from './WalletError.js';
4
+ import { BlocklessFinalizedEntryError, TransactionHistoryError } from './WalletError.js';
5
5
  export declare const QualifiedShieldedCoinInfoSchema: Schema.Struct<{
6
6
  type: typeof Schema.String;
7
7
  nonce: typeof Schema.String;
@@ -39,11 +39,11 @@ export declare const ShieldedTransactionHistoryEntrySchema: Schema.Struct<Readon
39
39
  submittedAt: typeof Schema.Date;
40
40
  }>, Schema.Struct<{
41
41
  status: Schema.Literal<["finalized"]>;
42
- finalizedBlock: Schema.Struct<{
42
+ finalizedBlock: Schema.optional<Schema.Struct<{
43
43
  hash: typeof Schema.String;
44
44
  height: typeof Schema.Number;
45
45
  timestamp: typeof Schema.Date;
46
- }>;
46
+ }>>;
47
47
  }>, Schema.Struct<{
48
48
  status: Schema.Literal<["rejected"]>;
49
49
  rejectedAt: typeof Schema.Date;
@@ -92,7 +92,7 @@ export type TransactionDetails = {
92
92
  };
93
93
  export type TransactionHistoryService = {
94
94
  put(changes: ledger.ZswapStateChanges, metadata: TransactionDetails, protocolVersion: number): Effect.Effect<void, TransactionHistoryError>;
95
- getTransactionDetails(hash: TransactionHistoryStorage.TransactionHash): Effect.Effect<TransactionDetails, TransactionHistoryError>;
95
+ getTransactionDetails(hash: TransactionHistoryStorage.TransactionHash): Effect.Effect<TransactionDetails, TransactionHistoryError | BlocklessFinalizedEntryError>;
96
96
  };
97
97
  export declare const mergeShieldedSections: (existing: ShieldedSection, incoming: ShieldedSection) => ShieldedSection;
98
98
  export declare const makeDefaultTransactionHistoryService: (config: DefaultTransactionHistoryConfiguration, _getContext: () => unknown) => TransactionHistoryService;
@@ -14,7 +14,7 @@ import { TransactionHistoryStorage } from '@midnightntwrk/wallet-sdk-abstraction
14
14
  import { Duration, Array as EArray, Effect, Schedule, Schema } from 'effect';
15
15
  import { TransactionHistoryDetail } from '@midnightntwrk/wallet-sdk-indexer-client';
16
16
  import { HttpQueryClient } from '@midnightntwrk/wallet-sdk-indexer-client/effect';
17
- import { TransactionHistoryError } from './WalletError.js';
17
+ import { BlocklessFinalizedEntryError, TransactionHistoryError } from './WalletError.js';
18
18
  export const QualifiedShieldedCoinInfoSchema = Schema.Struct({
19
19
  type: Schema.String,
20
20
  nonce: Schema.String,
@@ -116,17 +116,26 @@ export const makeSimulatorTransactionHistoryService = (config, _getContext) => {
116
116
  getTransactionDetails: (hash) => Effect.tryPromise({
117
117
  try: () => txHistoryStorage.get(hash),
118
118
  catch: (e) => new TransactionHistoryError({ message: `Failed to get transaction details for ${hash}`, cause: e }),
119
- }).pipe(Effect.flatMap((entry) => isFinalized(entry)
120
- ? Effect.succeed({
121
- hash: entry.hash,
122
- block: {
123
- hash: entry.lifecycle.finalizedBlock.hash,
124
- height: entry.lifecycle.finalizedBlock.height,
125
- timestamp: entry.lifecycle.finalizedBlock.timestamp.getTime(),
126
- },
127
- status: entry.status ?? 'SUCCESS',
128
- identifiers: entry.identifiers,
129
- })
130
- : Effect.fail(new TransactionHistoryError({ message: `No transaction found in storage for hash: ${hash}` })))),
119
+ }).pipe(
120
+ // Two distinct failures, reported distinctly. Storage may never have heard of the transaction; or it holds a
121
+ // finalized entry restored from a history written before the lifecycle field existed, which records no block.
122
+ // This service backs the simulator, where storage is the only source, so neither case can be repaired here.
123
+ Effect.flatMap((entry) => !isFinalized(entry)
124
+ ? Effect.fail(new TransactionHistoryError({ message: `No transaction found in storage for hash: ${hash}` }))
125
+ : entry.lifecycle.finalizedBlock === undefined
126
+ ? Effect.fail(new BlocklessFinalizedEntryError({
127
+ message: `Transaction ${hash} is finalized but its history entry records no block (restored from a pre-lifecycle history)`,
128
+ hash,
129
+ }))
130
+ : Effect.succeed({
131
+ hash: entry.hash,
132
+ block: {
133
+ hash: entry.lifecycle.finalizedBlock.hash,
134
+ height: entry.lifecycle.finalizedBlock.height,
135
+ timestamp: entry.lifecycle.finalizedBlock.timestamp.getTime(),
136
+ },
137
+ status: entry.status ?? 'SUCCESS',
138
+ identifiers: entry.identifiers,
139
+ }))),
131
140
  };
132
141
  };
@@ -203,6 +203,9 @@ export class V2Builder {
203
203
  message: 'Failed to migrate shielded wallet state into this variant',
204
204
  cause: error,
205
205
  }))),
206
+ // The version the state was left at: where the chain was when a snapshot was written, or what the chain answered
207
+ // when a fresh state was stamped from it. The runtime starts at this version rather than the variant's lower bound.
208
+ protocolVersionOf: (state) => state.protocolVersion,
206
209
  deserializeState: (serialized) => {
207
210
  return v2Context.serializationCapability.deserialize(null, serialized);
208
211
  },
@@ -1,10 +1,11 @@
1
+ import { type SnapshotFormat } from '@midnightntwrk/wallet-sdk-abstractions';
1
2
  import type * as ledger from '@midnightntwrk/ledger-v9';
2
3
  import { type LedgerOps } from '@midnightntwrk/wallet-sdk-utilities';
3
4
  export declare const WalletError: {
4
5
  other(err: unknown): WalletError;
5
6
  submission(err: unknown): WalletError;
6
7
  };
7
- export type WalletError = OtherWalletError | InsufficientFundsError | SubmissionError | AddressError | SyncWalletError | InvalidCoinHashesError | TransactingError | OutOfOrderSyncUpdateError | LedgerOps.LedgerError;
8
+ export type WalletError = SnapshotFormat.SnapshotRestoreError | OtherWalletError | InsufficientFundsError | SubmissionError | AddressError | SyncWalletError | InvalidCoinHashesError | TransactingError | OutOfOrderSyncUpdateError | LedgerOps.LedgerError;
8
9
  declare const OtherWalletError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
9
10
  readonly _tag: "Wallet.Other";
10
11
  } & Readonly<A>;
@@ -71,6 +72,28 @@ export declare class TransactionHistoryError extends TransactionHistoryError_bas
71
72
  cause?: unknown;
72
73
  }> {
73
74
  }
75
+ declare const BlocklessFinalizedEntryError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
76
+ readonly _tag: "Wallet.BlocklessFinalizedEntry";
77
+ } & Readonly<A>;
78
+ /**
79
+ * A finalized history entry that records no block, met where a block is needed.
80
+ *
81
+ * @remarks
82
+ * Only an entry restored from a history written before the lifecycle field existed looks like this: it is known to have
83
+ * been finalized, but the block it landed in was never recorded, and none is invented. It is its own error rather
84
+ * than a message on {@link TransactionHistoryError} so that a caller which can fetch the block by hash matches on the
85
+ * tag.
86
+ * @example
87
+ * ```ts
88
+ * if (error._tag === 'Wallet.BlocklessFinalizedEntry') fetchBlockFor(error.hash);
89
+ * ```;
90
+ */
91
+ export declare class BlocklessFinalizedEntryError extends BlocklessFinalizedEntryError_base<{
92
+ readonly message: string;
93
+ /** The transaction whose history entry records no block. */
94
+ readonly hash: string;
95
+ }> {
96
+ }
74
97
  declare const OutOfOrderSyncUpdateError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
75
98
  readonly _tag: "Wallet.OutOfOrderSyncUpdate";
76
99
  } & Readonly<A>;
@@ -51,6 +51,21 @@ export class TransactingError extends Data.TaggedError('Wallet.Transacting') {
51
51
  }
52
52
  export class TransactionHistoryError extends Data.TaggedError('Wallet.TransactionHistory') {
53
53
  }
54
+ /**
55
+ * A finalized history entry that records no block, met where a block is needed.
56
+ *
57
+ * @remarks
58
+ * Only an entry restored from a history written before the lifecycle field existed looks like this: it is known to have
59
+ * been finalized, but the block it landed in was never recorded, and none is invented. It is its own error rather
60
+ * than a message on {@link TransactionHistoryError} so that a caller which can fetch the block by hash matches on the
61
+ * tag.
62
+ * @example
63
+ * ```ts
64
+ * if (error._tag === 'Wallet.BlocklessFinalizedEntry') fetchBlockFor(error.hash);
65
+ * ```;
66
+ */
67
+ export class BlocklessFinalizedEntryError extends Data.TaggedError('Wallet.BlocklessFinalizedEntry') {
68
+ }
54
69
  /**
55
70
  * A batch of zswap ledger events was not in ascending id order: an event arrived at or below the one before it, or at
56
71
  * or below the applied cursor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@midnightntwrk/wallet-sdk-shielded",
3
- "version": "4.0.0-rc.0",
3
+ "version": "4.0.0-rc.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -36,11 +36,11 @@
36
36
  "dependencies": {
37
37
  "@midnight-ntwrk/ledger-v8": "^8.1.0",
38
38
  "@midnightntwrk/ledger-v9": "1.0.0-rc.5",
39
- "@midnightntwrk/wallet-sdk-abstractions": "3.0.0-rc.0",
39
+ "@midnightntwrk/wallet-sdk-abstractions": "3.0.0-rc.1",
40
40
  "@midnightntwrk/wallet-sdk-address-format": "4.0.0-rc.0",
41
- "@midnightntwrk/wallet-sdk-capabilities": "4.0.0-rc.0",
42
- "@midnightntwrk/wallet-sdk-indexer-client": "2.0.0-rc.0",
43
- "@midnightntwrk/wallet-sdk-runtime": "1.1.0-rc.0",
41
+ "@midnightntwrk/wallet-sdk-capabilities": "4.0.0-rc.1",
42
+ "@midnightntwrk/wallet-sdk-indexer-client": "2.0.0-rc.1",
43
+ "@midnightntwrk/wallet-sdk-runtime": "2.0.0-rc.1",
44
44
  "@midnightntwrk/wallet-sdk-utilities": "1.2.2-rc.0",
45
45
  "effect": "^3.19.19",
46
46
  "rxjs": "^7.8.2"