@unconfirmed/sui-effect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/AGENTS.md +209 -0
  2. package/LICENSE +21 -0
  3. package/LLMS.md +6707 -0
  4. package/README.md +300 -0
  5. package/dist/domain/bcs.d.ts +101 -0
  6. package/dist/domain/bcs.d.ts.map +1 -0
  7. package/dist/domain/bcs.js +183 -0
  8. package/dist/domain/bcs.js.map +1 -0
  9. package/dist/domain/errors.d.ts +595 -0
  10. package/dist/domain/errors.d.ts.map +1 -0
  11. package/dist/domain/errors.js +476 -0
  12. package/dist/domain/errors.js.map +1 -0
  13. package/dist/domain/executed.d.ts +325 -0
  14. package/dist/domain/executed.d.ts.map +1 -0
  15. package/dist/domain/executed.js +253 -0
  16. package/dist/domain/executed.js.map +1 -0
  17. package/dist/domain/journal-entry.d.ts +468 -0
  18. package/dist/domain/journal-entry.d.ts.map +1 -0
  19. package/dist/domain/journal-entry.js +63 -0
  20. package/dist/domain/journal-entry.js.map +1 -0
  21. package/dist/domain/schemas.d.ts +950 -0
  22. package/dist/domain/schemas.d.ts.map +1 -0
  23. package/dist/domain/schemas.js +571 -0
  24. package/dist/domain/schemas.js.map +1 -0
  25. package/dist/domain/sui-schema.d.ts +12 -0
  26. package/dist/domain/sui-schema.d.ts.map +1 -0
  27. package/dist/domain/sui-schema.js +12 -0
  28. package/dist/domain/sui-schema.js.map +1 -0
  29. package/dist/extension.d.ts +7 -0
  30. package/dist/extension.d.ts.map +1 -0
  31. package/dist/extension.js +7 -0
  32. package/dist/extension.js.map +1 -0
  33. package/dist/index.d.ts +31 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +31 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/internal.d.ts +18 -0
  38. package/dist/internal.d.ts.map +1 -0
  39. package/dist/internal.js +18 -0
  40. package/dist/internal.js.map +1 -0
  41. package/dist/journal.d.ts +51 -0
  42. package/dist/journal.d.ts.map +1 -0
  43. package/dist/journal.js +18 -0
  44. package/dist/journal.js.map +1 -0
  45. package/dist/script.d.ts +7 -0
  46. package/dist/script.d.ts.map +1 -0
  47. package/dist/script.js +7 -0
  48. package/dist/script.js.map +1 -0
  49. package/dist/services/Journal.d.ts +76 -0
  50. package/dist/services/Journal.d.ts.map +1 -0
  51. package/dist/services/Journal.js +70 -0
  52. package/dist/services/Journal.js.map +1 -0
  53. package/dist/services/JournalKeyValueStore.d.ts +73 -0
  54. package/dist/services/JournalKeyValueStore.d.ts.map +1 -0
  55. package/dist/services/JournalKeyValueStore.js +155 -0
  56. package/dist/services/JournalKeyValueStore.js.map +1 -0
  57. package/dist/services/Script.d.ts +210 -0
  58. package/dist/services/Script.d.ts.map +1 -0
  59. package/dist/services/Script.js +441 -0
  60. package/dist/services/Script.js.map +1 -0
  61. package/dist/services/Signer.d.ts +132 -0
  62. package/dist/services/Signer.d.ts.map +1 -0
  63. package/dist/services/Signer.js +158 -0
  64. package/dist/services/Signer.js.map +1 -0
  65. package/dist/services/SubmitConfig.d.ts +175 -0
  66. package/dist/services/SubmitConfig.d.ts.map +1 -0
  67. package/dist/services/SubmitConfig.js +52 -0
  68. package/dist/services/SubmitConfig.js.map +1 -0
  69. package/dist/services/Sui.d.ts +295 -0
  70. package/dist/services/Sui.d.ts.map +1 -0
  71. package/dist/services/Sui.js +414 -0
  72. package/dist/services/Sui.js.map +1 -0
  73. package/dist/services/SuiCore.d.ts +227 -0
  74. package/dist/services/SuiCore.d.ts.map +1 -0
  75. package/dist/services/SuiCore.js +400 -0
  76. package/dist/services/SuiCore.js.map +1 -0
  77. package/dist/services/SuiCoreFake.d.ts +278 -0
  78. package/dist/services/SuiCoreFake.d.ts.map +1 -0
  79. package/dist/services/SuiCoreFake.js +1003 -0
  80. package/dist/services/SuiCoreFake.js.map +1 -0
  81. package/dist/services/SuiExtension.d.ts +208 -0
  82. package/dist/services/SuiExtension.d.ts.map +1 -0
  83. package/dist/services/SuiExtension.js +355 -0
  84. package/dist/services/SuiExtension.js.map +1 -0
  85. package/dist/services/SuiGraphQL.d.ts +98 -0
  86. package/dist/services/SuiGraphQL.d.ts.map +1 -0
  87. package/dist/services/SuiGraphQL.js +118 -0
  88. package/dist/services/SuiGraphQL.js.map +1 -0
  89. package/dist/services/Tx.d.ts +687 -0
  90. package/dist/services/Tx.d.ts.map +1 -0
  91. package/dist/services/Tx.js +1224 -0
  92. package/dist/services/Tx.js.map +1 -0
  93. package/dist/testing.d.ts +88 -0
  94. package/dist/testing.d.ts.map +1 -0
  95. package/dist/testing.js +163 -0
  96. package/dist/testing.js.map +1 -0
  97. package/dist/tx.d.ts +24 -0
  98. package/dist/tx.d.ts.map +1 -0
  99. package/dist/tx.js +24 -0
  100. package/dist/tx.js.map +1 -0
  101. package/docs/extensions.md +1685 -0
  102. package/examples/extension-template/README.md +170 -0
  103. package/examples/extension-template/package.json +44 -0
  104. package/examples/extension-template/scripts/check-package.ts +147 -0
  105. package/examples/extension-template/src/Escrow.ts +445 -0
  106. package/examples/extension-template/src/Platform.ts +136 -0
  107. package/examples/extension-template/src/errors.ts +61 -0
  108. package/examples/extension-template/src/extension.ts +63 -0
  109. package/examples/extension-template/src/index.ts +34 -0
  110. package/examples/extension-template/src/schema.ts +158 -0
  111. package/examples/extension-template/src/upstream.ts +66 -0
  112. package/examples/extension-template/test/escrow.test.ts +559 -0
  113. package/examples/extension-template/tsconfig.build.json +26 -0
  114. package/examples/extension-template/tsconfig.json +35 -0
  115. package/package.json +87 -0
@@ -0,0 +1,1224 @@
1
+ import { GrpcCoreClient, isSuiGrpcClient } from "@mysten/sui/grpc";
2
+ import { Transaction, TransactionDataBuilder } from "@mysten/sui/transactions";
3
+ import { normalizeSuiAddress } from "@mysten/sui/utils";
4
+ import { Cause, DateTime, Duration, Effect, Schema } from "effect";
5
+ import { BuildError, ExecutionFailed, JournalError, NotApplied, PolicyDenied, SigningError, SimulationFailed, SubmissionUnknown, SuiError, TransportError } from "../domain/errors.js";
6
+ import { EXECUTE_INCLUDE, Executed, fromTransactionResult } from "../domain/executed.js";
7
+ import { isUnresolved, JournalEntry } from "../domain/journal-entry.js";
8
+ import { Built, chainOf, Digest, maxEpochOf, maxTimestampMsOf, ObjectId, SignedTransaction, SuiAddress, TransactionExpiration, U32_MAX, Version } from "../domain/schemas.js";
9
+ import { Journal } from "./Journal.js";
10
+ import { SubmitConfig } from "./SubmitConfig.js";
11
+ import { Sui } from "./Sui.js";
12
+ /** The schema of {@link Signed}. */
13
+ export const Signed = SignedTransaction;
14
+ const decodeExpiration = Schema.decodeUnknownOption(TransactionExpiration);
15
+ const decodeDigest = Schema.decodeUnknownEffect(Digest);
16
+ const decodeDigestOption = Schema.decodeUnknownOption(Digest);
17
+ const decodeVersion = Schema.decodeUnknownOption(Version);
18
+ const decodeObjectId = Schema.decodeUnknownOption(ObjectId);
19
+ const buildError = (message) => (cause) => new BuildError({ message, cause });
20
+ /** The `u32` nonce a `ValidDuring` expiration carries as its replay guard. */
21
+ const MAX_NONCE = U32_MAX;
22
+ /**
23
+ * The gRPC resolve plugin, re-created over a client whose
24
+ * `transactionExecutionService.simulateTransaction` carries the Effect's
25
+ * `AbortSignal`.
26
+ *
27
+ * This is the one request `abortableClient`'s `core` proxy cannot reach.
28
+ * `GrpcCoreClient#resolveTransactionPlugin` closes over the private grpc client
29
+ * it was constructed with and calls `simulateTransaction(request)` with no
30
+ * second argument, while every other method on that class passes
31
+ * `{ abort: options.signal }`. So an interrupted `Tx.build` on a gRPC client
32
+ * ended — releasing the sender lock — with the resolver's simulate still in
33
+ * flight.
34
+ *
35
+ * Nothing is re-implemented. `GrpcCoreClient` is public
36
+ * (`@mysten/sui/grpc`), and its constructor takes the grpc client it will use:
37
+ * handing it a `SuiGrpcClient` whose `transactionExecutionService` adds
38
+ * `{ abort: signal }` and whose `core` is the already-signalled proxy gives back
39
+ * the SDK's **own** plugin, line for line, with the signal attached. The cache
40
+ * is shared through `base`, so nothing is read twice.
41
+ *
42
+ * Never fails.
43
+ */
44
+ const grpcResolvePlugin = (client, core, signal) => {
45
+ const service = client.transactionExecutionService;
46
+ const abortable = new Proxy(service, {
47
+ get: (receiver, key) => {
48
+ const value = Reflect.get(receiver, key);
49
+ if (typeof value !== "function")
50
+ return value;
51
+ if (key !== "simulateTransaction") {
52
+ return (...args) => value.apply(receiver, args);
53
+ }
54
+ return (input, options) => value.call(receiver, input, {
55
+ ...(options ?? {}),
56
+ // A caller that passed its own signal keeps it; the resolver passes
57
+ // none, which is the whole bug.
58
+ abort: options?.["abort"] ?? signal
59
+ });
60
+ }
61
+ });
62
+ const shadow = new Proxy(client, {
63
+ get: (receiver, key) => {
64
+ // `setAddressBalanceTransactionExpirationFromSimulatedEpoch` reads
65
+ // `client.core.getChainIdentifier()` and `client.core
66
+ // .getCurrentSystemState()` out of this same object, so it gets the
67
+ // signalled core too.
68
+ if (key === "core")
69
+ return core;
70
+ if (key === "transactionExecutionService")
71
+ return abortable;
72
+ const value = Reflect.get(receiver, key);
73
+ return typeof value === "function"
74
+ ? value.bind(receiver)
75
+ : value;
76
+ }
77
+ });
78
+ return new GrpcCoreClient({
79
+ client: shadow,
80
+ base: client,
81
+ network: client.network
82
+ }).resolveTransactionPlugin();
83
+ };
84
+ /**
85
+ * The SDK client with the Effect's `AbortSignal` injected into every Core call.
86
+ *
87
+ * `Transaction#build` takes no signal: `BuildTransactionOptions` has no such
88
+ * field, and the gRPC resolve plugin simulates with whatever the client hands
89
+ * it. So an interrupted `Tx.build` used to end — releasing the sender lock —
90
+ * while the simulate it started stayed in flight. The only cancellation hook
91
+ * the SDK offers is the `signal` on each Core method, and the resolver reaches
92
+ * those through `options.client`, so a client whose `core` injects the signal is
93
+ * how an interrupt reaches the request.
94
+ *
95
+ * `resolveTransactionPlugin` is delegated untouched on every transport but
96
+ * gRPC, whose plugin simulates through a captured private client that no proxy
97
+ * of `core` can reach: there it is rebuilt by {@link grpcResolvePlugin}, which
98
+ * is the same SDK plugin over a client that carries the signal.
99
+ */
100
+ const abortableClient = (client, signal) => {
101
+ const grpc = isSuiGrpcClient(client) ? client : undefined;
102
+ let core;
103
+ const inject = (target) => new Proxy(target, {
104
+ get: (receiver, key) => {
105
+ const value = Reflect.get(receiver, key);
106
+ if (typeof key !== "string")
107
+ return value;
108
+ if (key === "resolveTransactionPlugin") {
109
+ if (grpc !== undefined) {
110
+ return () => grpcResolvePlugin(grpc, core ?? client.core, signal);
111
+ }
112
+ return typeof value === "function"
113
+ ? (...args) => value.apply(receiver, args)
114
+ : value;
115
+ }
116
+ if (typeof value === "function") {
117
+ return (options, ...rest) => value.call(receiver, { ...(options ?? {}), signal: options?.["signal"] ?? signal }, ...rest);
118
+ }
119
+ if (value !== null && typeof value === "object" &&
120
+ Object.getPrototypeOf(value) === Object.prototype) {
121
+ // `core.mvr` is a plain namespace of methods; it needs the same
122
+ // treatment and nothing else does.
123
+ return inject(value);
124
+ }
125
+ return value;
126
+ }
127
+ });
128
+ core = inject(client.core);
129
+ const coreProxy = core;
130
+ return new Proxy(client, {
131
+ get: (receiver, key) => {
132
+ if (key === "core")
133
+ return coreProxy;
134
+ const value = Reflect.get(receiver, key);
135
+ // Bound, so a method reached through the proxy still sees the real
136
+ // instance as `this` — a class with private fields would throw otherwise.
137
+ return typeof value === "function" ? value.bind(receiver) : value;
138
+ }
139
+ });
140
+ };
141
+ /**
142
+ * Whether the SDK will have to resolve this transaction, which is the same
143
+ * question as "will building simulate".
144
+ *
145
+ * It mirrors the SDK's own `needsTransactionResolution`, which is not exported:
146
+ * the resolve plugin returns early — no gas-budget simulation, no plugin call
147
+ * at all — when every input is resolved and the gas price, budget and payment
148
+ * are already set. `Tx.build` promises a simulation before any bytes are
149
+ * signed, so when this says `false` it runs one itself.
150
+ *
151
+ * Bytes this version cannot read answer `false`, which costs one simulate and
152
+ * keeps the guarantee. Never fails.
153
+ */
154
+ const willResolve = (tx) => {
155
+ try {
156
+ const data = tx.getData();
157
+ if (data.inputs.some((input) => input.UnresolvedObject !== undefined || input.UnresolvedPure !== undefined)) {
158
+ return true;
159
+ }
160
+ const gas = data.gasData;
161
+ if (!gas.price || !gas.budget)
162
+ return true;
163
+ const payment = gas.payment;
164
+ if (payment === null || payment === undefined)
165
+ return true;
166
+ if (payment.length === 0 && data.expiration == null)
167
+ return true;
168
+ return false;
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ };
174
+ /**
175
+ * The object inputs the transaction has not resolved yet, which are exactly
176
+ * the ones the resolver is about to look up. Never fails.
177
+ */
178
+ const unresolvedObjectIdsOf = (tx) => {
179
+ try {
180
+ return tx.getData().inputs
181
+ .map((input) => input.UnresolvedObject?.objectId)
182
+ .filter((id) => typeof id === "string");
183
+ }
184
+ catch {
185
+ return [];
186
+ }
187
+ };
188
+ /**
189
+ * The expiration a transaction was built with, read back out of the bytes so
190
+ * that what is recorded is what was signed rather than what was intended.
191
+ */
192
+ const expirationOfBytes = (bytes) => {
193
+ try {
194
+ const data = TransactionDataBuilder.fromBytes(bytes);
195
+ if (data.expiration === null)
196
+ return undefined;
197
+ const decoded = decodeExpiration(data.expiration);
198
+ return decoded._tag === "Some" ? decoded.value : undefined;
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
203
+ };
204
+ /** The owned object inputs a transaction referenced, with the versions it pinned. */
205
+ const ownedInputsOfBytes = (bytes) => {
206
+ try {
207
+ const data = TransactionDataBuilder.fromBytes(bytes);
208
+ const inputs = [];
209
+ for (const input of data.inputs) {
210
+ const object = input.Object;
211
+ if (object === undefined)
212
+ continue;
213
+ const owned = object.ImmOrOwnedObject ?? object.Receiving;
214
+ if (owned === undefined)
215
+ continue;
216
+ const version = decodeVersion(String(owned.version));
217
+ if (version._tag === "Some") {
218
+ inputs.push({ objectId: owned.objectId, version: version.value });
219
+ }
220
+ }
221
+ return inputs;
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ };
227
+ /**
228
+ * The gas coins a transaction pinned, which are owned inputs too: a sponsored
229
+ * transaction pays from an address balance and has none, but an ordinary one
230
+ * names its coins in `gasData.payment` rather than in `inputs`, and a coin that
231
+ * has moved on is exactly as good evidence as any other consumed input.
232
+ */
233
+ const gasPaymentOfBytes = (bytes) => {
234
+ try {
235
+ const payment = TransactionDataBuilder.fromBytes(bytes).gasData.payment ?? [];
236
+ const refs = [];
237
+ for (const ref of payment) {
238
+ const version = decodeVersion(String(ref.version));
239
+ if (version._tag === "Some")
240
+ refs.push({ objectId: ref.objectId, version: version.value });
241
+ }
242
+ return refs;
243
+ }
244
+ catch {
245
+ return [];
246
+ }
247
+ };
248
+ /** Every object reference whose version these bytes pinned, inputs and gas alike. */
249
+ const pinnedRefsOfBytes = (bytes) => [
250
+ ...ownedInputsOfBytes(bytes),
251
+ ...gasPaymentOfBytes(bytes)
252
+ ];
253
+ /** The addresses a transaction's bytes will accept a signature from. */
254
+ const signersOfBytes = (bytes) => {
255
+ try {
256
+ const data = TransactionDataBuilder.fromBytes(bytes);
257
+ return [data.sender, data.gasData.owner]
258
+ .filter((address) => typeof address === "string" && address.length > 0)
259
+ .map((address) => normalizeSuiAddress(address));
260
+ }
261
+ catch {
262
+ return [];
263
+ }
264
+ };
265
+ const gasBudgetOfBytes = (bytes) => {
266
+ try {
267
+ const budget = TransactionDataBuilder.fromBytes(bytes).gasData.budget;
268
+ return budget === null || budget === undefined ? undefined : BigInt(budget);
269
+ }
270
+ catch {
271
+ return undefined;
272
+ }
273
+ };
274
+ /** A timeout on an execute is a transport failure that may already have landed. */
275
+ const timeoutAsTransport = (method) => (error) => error._tag === "TimeoutError"
276
+ ? new TransportError({
277
+ method,
278
+ retryable: true,
279
+ status: "DEADLINE_EXCEEDED",
280
+ cause: error
281
+ })
282
+ : error;
283
+ const toTransaction = (input) => typeof input === "function"
284
+ ? Effect.try({
285
+ try: () => {
286
+ const tx = new Transaction();
287
+ input(tx);
288
+ return tx;
289
+ },
290
+ catch: buildError("the recipe threw")
291
+ })
292
+ : Effect.succeed(input);
293
+ /**
294
+ * Builds a transaction into signable bytes.
295
+ *
296
+ * **Building always simulates.** On gRPC the SDK's resolve plugin simulates
297
+ * with checks enabled to choose the gas budget, and an execution failure there
298
+ * arrives as `SimulationFailed`. That costs nothing extra — but the resolver
299
+ * returns early for a transaction that was **already fully resolved** (every
300
+ * input resolved, gas price, budget and payment set), and then nothing
301
+ * simulates at all. `Tx.build` detects that case and runs one explicit
302
+ * `simulateTransaction` with checks enabled, so simulate-before-submit holds
303
+ * for every transaction: it costs nothing extra when the SDK had to resolve,
304
+ * and one call otherwise.
305
+ *
306
+ * An interrupted build cancels the request it started: the SDK is handed a
307
+ * client whose Core calls carry the Effect's `AbortSignal`.
308
+ *
309
+ * When the recipe set no expiration, `SubmitConfig.expiration` decides one.
310
+ * The default, `ValidDuring`, bounds the transaction to the **current epoch and
311
+ * the next**, names the chain (bytes signed for testnet cannot land on mainnet)
312
+ * and carries a `u32` nonce from `SubmitConfig.nonce`. There is **no
313
+ * wall-clock bound** unless `SubmitConfig.validFor` asks for one: no Sui
314
+ * network accepts a timestamp expiration yet, and a node refuses any
315
+ * transaction carrying one. The expiration that ends up in the bytes is
316
+ * recorded on the result, because it is what `Tx.reconcile` needs later to
317
+ * prove a transaction can no longer land.
318
+ *
319
+ * `Tx.build` takes no sender lock; `Tx.run` is what holds one from build
320
+ * through submit. Called on its own, two concurrent builds for one address can
321
+ * pick the same gas coin.
322
+ *
323
+ * Fails with: `BuildError` (the recipe threw, an input could not be resolved,
324
+ * or the budget the node chose is over `SubmitConfig.maxGasBudget`),
325
+ * `SimulationFailed` (the transaction would abort on chain),
326
+ * `TransportError`.
327
+ */
328
+ export const build = Effect.fn("Tx.build")(function* (input, opts) {
329
+ const sui = yield* Sui;
330
+ const config = yield* SubmitConfig;
331
+ const tx = yield* toTransaction(input);
332
+ yield* Effect.try({
333
+ try: () => {
334
+ tx.setSender(opts.sender);
335
+ if (opts.gasOwner !== undefined)
336
+ tx.setGasOwner(opts.gasOwner);
337
+ },
338
+ catch: buildError("the transaction rejected its sender")
339
+ });
340
+ if (tx.getData().expiration == null) {
341
+ const expiration = yield* defaultExpiration(sui, config);
342
+ if (expiration !== undefined) {
343
+ yield* Effect.try({
344
+ try: () => tx.setExpiration(expiration),
345
+ catch: buildError("the transaction rejected its expiration")
346
+ });
347
+ }
348
+ }
349
+ const resolving = willResolve(tx);
350
+ const unresolved = unresolvedObjectIdsOf(tx);
351
+ const bytes = yield* sui.core
352
+ .use((client, signal) => tx.build({ client: abortableClient(client, signal) }))
353
+ .pipe(Effect.catchTag(["ObjectNotFound", "ObjectDeleted", "ObjectUnavailable", "TransactionNotFound"], (error) => Effect.fail(new BuildError({ message: `an input could not be resolved: ${SuiError.describe(error)}`, cause: error }))),
354
+ // A gRPC resolver that cannot find an input answers `NOT_FOUND` from
355
+ // `simulateTransaction` rather than raising the SDK's `ObjectError`, and
356
+ // that arrived as a bare `TransportError { method: "use" }` naming
357
+ // nothing. It is a `BuildError` like every other unresolvable input, and
358
+ // it names the object inputs the resolver was about to look up.
359
+ Effect.catchTag("TransportError", (error) => Effect.fail(error.status === "NOT_FOUND"
360
+ ? new BuildError({
361
+ message: "an input could not be resolved: the node answered NOT_FOUND" +
362
+ (unresolved.length === 0 ? "" : ` for one of ${unresolved.join(", ")}`),
363
+ cause: error
364
+ })
365
+ : error)));
366
+ if (!resolving) {
367
+ // The SDK resolved nothing, so it simulated nothing. One explicit simulate
368
+ // with checks enabled keeps the promise `Tx.run` is built on: nothing is
369
+ // signed that was not first shown to execute.
370
+ yield* sui.simulate(bytes);
371
+ }
372
+ const budget = gasBudgetOfBytes(bytes);
373
+ if (budget !== undefined && budget > config.maxGasBudget) {
374
+ return yield* new BuildError({
375
+ message: `the gas budget ${budget} is over the configured maximum ${config.maxGasBudget}`,
376
+ cause: budget
377
+ });
378
+ }
379
+ const digest = yield* decodeDigest(TransactionDataBuilder.getDigestFromBytes(bytes)).pipe(Effect.mapError((issue) => new BuildError({ message: `the built bytes have no readable digest: ${issue.message}`, cause: issue })));
380
+ const expiration = expirationOfBytes(bytes);
381
+ return {
382
+ digest,
383
+ bytes,
384
+ sender: opts.sender,
385
+ // The chain these bytes were built against, so `Tx.reconcile` can refuse to
386
+ // reason about them with a node on another one — including for the
387
+ // expiration variants that name no chain themselves.
388
+ chain: sui.chainId,
389
+ ...(opts.gasOwner === undefined ? {} : { gasOwner: opts.gasOwner }),
390
+ ...(expiration === undefined ? {} : { expiration })
391
+ };
392
+ });
393
+ /** The epoch a system-state read reported, as a number the expiration can carry. */
394
+ const epochOf = (epoch) => Effect.try({
395
+ try: () => BigInt(epoch),
396
+ catch: (cause) => new TransportError({
397
+ method: "getCurrentSystemState",
398
+ retryable: false,
399
+ status: "INVALID_ARGUMENT",
400
+ cause: `the node reported epoch ${epoch}, which is not a number: ${String(cause)}`
401
+ })
402
+ });
403
+ /**
404
+ * The nonce for a default `ValidDuring` expiration, from
405
+ * `SubmitConfig.nonce`, checked to be the `u32` the wire carries.
406
+ *
407
+ * A custom allocator that answers something else is a configuration mistake and
408
+ * fails the build, rather than producing bytes a validator refuses.
409
+ */
410
+ const nonceOf = (config) => Effect.flatMap(config.nonce, (nonce) => Number.isInteger(nonce) && nonce >= 0 && nonce <= MAX_NONCE
411
+ ? Effect.succeed(nonce)
412
+ : Effect.fail(
413
+ // A `BuildError`, not a `TransportError`: nothing was sent, no node was
414
+ // asked, and the build is what failed. `SubmitConfig.ts` and DESIGN
415
+ // section 6 both say so.
416
+ new BuildError({
417
+ message: `SubmitConfig.nonce produced ${nonce}, which is not a u32 (0 to ${MAX_NONCE})`,
418
+ cause: nonce
419
+ })));
420
+ /** The expiration `Tx.build` sets when the recipe set none. */
421
+ const defaultExpiration = Effect.fn("Tx.defaultExpiration")(function* (sui, config) {
422
+ switch (config.expiration) {
423
+ case "none":
424
+ return undefined;
425
+ case "epoch": {
426
+ const { systemState } = yield* sui.core.getCurrentSystemState();
427
+ return { Epoch: systemState.epoch };
428
+ }
429
+ case "validDuring": {
430
+ const { systemState } = yield* sui.core.getCurrentSystemState();
431
+ const epoch = yield* epochOf(systemState.epoch);
432
+ const nonce = yield* nonceOf(config);
433
+ const maxTimestamp = config.validFor === undefined
434
+ ? null
435
+ : (BigInt(DateTime.toEpochMillis(yield* sui.chainTime)) +
436
+ BigInt(Duration.toMillis(config.validFor))).toString();
437
+ return {
438
+ ValidDuring: {
439
+ // Epochs, not a clock. The validator rule is that a transaction must
440
+ // either have address-owned inputs or an expiration of at most two
441
+ // epochs, so a bound of some other kind is rejected outright for a
442
+ // PTB whose only object inputs are shared and for every
443
+ // `Tx.sponsored` transaction, which pays gas from an address balance
444
+ // and therefore has no gas coins either. Two epochs is the widest
445
+ // the rule allows.
446
+ minEpoch: epoch.toString(),
447
+ maxEpoch: (epoch + 1n).toString(),
448
+ minTimestamp: null,
449
+ // Null unless `SubmitConfig.validFor` asks for one: a live devnet
450
+ // node refuses any transaction carrying a timestamp bound with
451
+ // "Feature is not supported: Timestamp-based transaction expiration
452
+ // is not yet supported", epochs alongside it or not.
453
+ maxTimestamp,
454
+ chain: sui.chainId,
455
+ nonce
456
+ }
457
+ };
458
+ }
459
+ }
460
+ });
461
+ /**
462
+ * Refuses a signer whose address the bytes will not accept.
463
+ *
464
+ * A transaction takes a signature from its sender and, when it is sponsored,
465
+ * from its gas owner. A signature from anyone else is rejected by the node with
466
+ * a non-retryable `INVALID_ARGUMENT`, which `Tx.submit` can only report as
467
+ * `SubmissionUnknown` — exit 3, "reconcile before doing anything else" — for a
468
+ * transaction that never had a chance. Catching it here makes a misconfigured
469
+ * credential a `SigningError` before any bytes are sent.
470
+ *
471
+ * The addresses come from the bytes rather than from `Built.sender`, so the
472
+ * check is against what was actually signed. Bytes this version cannot parse
473
+ * are let through: refusing to sign because the guard could not read them would
474
+ * be worse than the failure it prevents.
475
+ */
476
+ const assertSignerAddress = (bytes, signer) => Effect.suspend(() => {
477
+ const allowed = signersOfBytes(bytes);
478
+ if (allowed.length === 0)
479
+ return Effect.void;
480
+ const address = normalizeSuiAddress(signer.address);
481
+ if (allowed.includes(address))
482
+ return Effect.void;
483
+ return Effect.fail(new SigningError({
484
+ cause: `this signer signs as ${signer.address}, which is neither the transaction's sender` +
485
+ ` nor its gas owner (${allowed.join(", ")}); the node would reject the signature`
486
+ }));
487
+ });
488
+ /**
489
+ * Signs built bytes.
490
+ *
491
+ * The signer's address must be the transaction's sender or, for a sponsored
492
+ * transaction, its gas owner; anything else is a `SigningError` rather than a
493
+ * rejection at execution time. A `Signer.remote` therefore has to report the
494
+ * address it signs as truthfully.
495
+ *
496
+ * Fails with: `SigningError`.
497
+ */
498
+ export const sign = Effect.fn("Tx.sign")(function* (built, signer) {
499
+ yield* assertSignerAddress(built.bytes, signer);
500
+ const signature = yield* signer.signTransaction(built.bytes);
501
+ return {
502
+ digest: built.digest,
503
+ bytes: built.bytes,
504
+ signatures: [signature],
505
+ sender: built.sender,
506
+ ...(built.expiration === undefined ? {} : { expiration: built.expiration }),
507
+ // Carried forward from the build, so `Tx.reconcile` knows which chain these
508
+ // bytes belong to even when the expiration names none.
509
+ ...(built.chain === undefined ? {} : { chain: built.chain })
510
+ };
511
+ });
512
+ /**
513
+ * Adds one more signature to already signed bytes, for a sponsored or
514
+ * multi-party transaction. The bytes are untouched, so both parties sign
515
+ * exactly the same transaction.
516
+ *
517
+ * As in {@link sign}, the co-signer's address must be the sender or the gas
518
+ * owner named in the bytes.
519
+ *
520
+ * Fails with: `SigningError`.
521
+ */
522
+ export const cosign = Effect.fn("Tx.cosign")(function* (signed, signer) {
523
+ yield* assertSignerAddress(signed.bytes, signer);
524
+ const signature = yield* signer.signTransaction(signed.bytes);
525
+ return { ...signed, signatures: [...signed.signatures, signature] };
526
+ });
527
+ /**
528
+ * Turns a recipe into a sponsored one: the sender owns the transaction, the
529
+ * gas owner pays, and gas comes from the sponsor's address balance rather than
530
+ * from coin objects, so the two parties never have to agree on a gas coin and
531
+ * can sign in either order.
532
+ *
533
+ * Never fails; the recipe it returns throws only if the recipe it wraps does,
534
+ * which `Tx.build` reports as `BuildError`.
535
+ */
536
+ export const sponsored = (opts) => (recipe) => (tx) => {
537
+ recipe(tx);
538
+ tx.setSender(opts.sender);
539
+ tx.setGasOwner(opts.gasOwner);
540
+ tx.setGasPayment([]);
541
+ };
542
+ const journalSigned = Effect.fn("Tx.journalSigned")(function* (signed) {
543
+ const journal = yield* Journal;
544
+ const now = yield* DateTime.now;
545
+ yield* journal.put(JournalEntry.cases.Signed.make({ _tag: "Signed", digest: signed.digest, signed, signedAt: now }));
546
+ });
547
+ const journalOutcome = Effect.fn("Tx.journalOutcome")(function* (entry) {
548
+ const journal = yield* Journal;
549
+ yield* journal.put(entry);
550
+ });
551
+ /**
552
+ * Records an outcome the network has already given, and never changes it.
553
+ *
554
+ * `JournalError` is reserved for the write that happens **before** the first
555
+ * `executeTransaction`: failing there is honest, because nothing has been sent
556
+ * and the caller can safely try again. Once execute or reconcile has answered,
557
+ * a journal that cannot be written is an operational problem, not a different
558
+ * outcome — reporting a charged `ExecutionFailed` as `JournalError` would put
559
+ * it on exit 4, "not applied, safe to retry", and the documented retry idiom
560
+ * would send the transaction a second time. So the failure is logged with the
561
+ * digest and swallowed, and the answer stands.
562
+ */
563
+ const journalSettled = (entry) => journalOutcome(entry).pipe(Effect.catchTag("JournalError", (error) => Effect.logError("the submission journal could not record a settled transaction; the outcome stands", error)), Effect.annotateLogs({ digest: entry.digest, entry: entry._tag }));
564
+ const executedEntry = Effect.fn("Tx.executedEntry")(function* (executed) {
565
+ const now = yield* DateTime.now;
566
+ return JournalEntry.cases.Executed.make({
567
+ _tag: "Executed",
568
+ digest: executed.digest,
569
+ ...(executed.checkpoint === null ? {} : { checkpoint: executed.checkpoint }),
570
+ at: now
571
+ });
572
+ });
573
+ const failedEntry = Effect.fn("Tx.failedEntry")(function* (error) {
574
+ const now = yield* DateTime.now;
575
+ return JournalEntry.cases.Failed.make({
576
+ _tag: "Failed",
577
+ digest: error.digest,
578
+ reason: error.reason,
579
+ at: now
580
+ });
581
+ });
582
+ const notAppliedEntry = Effect.fn("Tx.notAppliedEntry")(function* (error) {
583
+ const now = yield* DateTime.now;
584
+ return JournalEntry.cases.NotApplied.make({
585
+ _tag: "NotApplied",
586
+ digest: error.digest,
587
+ evidence: error.evidence,
588
+ at: now
589
+ });
590
+ });
591
+ const unknownEntry = Effect.fn("Tx.unknownEntry")(function* (signed, lastError, attempts) {
592
+ const now = yield* DateTime.now;
593
+ return JournalEntry.cases.Unknown.make({
594
+ _tag: "Unknown",
595
+ digest: signed.digest,
596
+ signed,
597
+ lastError,
598
+ attempts,
599
+ at: now
600
+ });
601
+ });
602
+ /**
603
+ * Waits for an execution to be visible to reads, before `Tx.submit` returns and
604
+ * the sender lock is released.
605
+ *
606
+ * Execute and indexing are two different things on a Sui node: a transaction
607
+ * that executed is not necessarily one the next `getObject` or the next build's
608
+ * input resolution can see. Serializing per sender stops two builds picking the
609
+ * same gas coin; it does not stop the second build resolving that coin at the
610
+ * version the first one already spent. `waitForTransaction` is the SDK's own
611
+ * answer, and holding the lock across it is what makes "the next `Tx.run` from
612
+ * this sender sees this one" true.
613
+ *
614
+ * **It never changes the outcome.** The transaction executed; that is a fact,
615
+ * and a wait that times out or fails does not unmake it. The failure is logged
616
+ * with the digest and `Tx.submit` returns the `Executed` it already has. Turn
617
+ * the wait off with `SubmitConfig.awaitVisibility: false` and bound it with
618
+ * `SubmitConfig.visibilityTimeout`. Never fails.
619
+ */
620
+ const awaitVisible = (sui, config, digest) => config.awaitVisibility === false ? Effect.void : sui.core
621
+ .waitForTransaction({ digest })
622
+ .pipe(Effect.timeout(config.visibilityTimeout), Effect.asVoid,
623
+ // `Effect.catch`, not `Effect.catchCause`: a timeout, a `TransportError`
624
+ // and a `TransactionNotFound` are the failures this is allowed to swallow
625
+ // — a **defect** is a bug, and turning one into a warning line hid it
626
+ // behind an outcome that was going to stand anyway.
627
+ Effect.catch(() => Effect.logWarning("the transaction executed but did not become visible to reads within" +
628
+ " SubmitConfig.visibilityTimeout; the outcome stands")), Effect.annotateLogs({ digest }), Effect.withSpan("Tx.awaitVisible"));
629
+ /**
630
+ * Sends signed bytes, and does not stop caring until it knows what happened.
631
+ *
632
+ * Before the first `executeTransaction` it writes a `Signed` journal entry, so
633
+ * a process that dies mid-flight leaves a record of bytes that may be on the
634
+ * wire. A retryable transport failure or a timeout re-sends the identical bytes
635
+ * on `SubmitConfig.resubmit`; nothing is ever rebuilt, so a retry can only land
636
+ * the transaction that was already signed. When the retries run out it runs
637
+ * `Tx.reconcile`, which either finds the transaction, proves it never applied,
638
+ * or says it does not know.
639
+ *
640
+ * `TransportError` never escapes: once bytes may have been sent, "the network
641
+ * was unreachable" is not an answer a caller can act on, so it becomes
642
+ * `SubmissionUnknown` carrying the signed bytes.
643
+ *
644
+ * `JournalError` can only come from the `Signed` write, before anything has
645
+ * been sent. Once the network has answered, a journal write that fails is
646
+ * logged with `Effect.logError` and the answer stands, because "the journal is
647
+ * broken" is not a thing a caller can act on and reporting it in place of a
648
+ * charged `ExecutionFailed` would invite a second submission.
649
+ *
650
+ * Fails with: `ExecutionFailed` (applied on chain and failed; gas was charged),
651
+ * `NotApplied` (provably never applied), `SubmissionUnknown` (the outcome is
652
+ * not known and the bytes are in the error), `JournalError` (only before the
653
+ * first send).
654
+ */
655
+ export const submit = Effect.fn("Tx.submit")(function* (signed) {
656
+ const sui = yield* Sui;
657
+ const config = yield* SubmitConfig;
658
+ yield* journalSigned(signed);
659
+ let attempts = 0;
660
+ const once = Effect.suspend(() => {
661
+ attempts += 1;
662
+ return sui.core.executeTransaction({
663
+ transaction: signed.bytes,
664
+ signatures: [...signed.signatures],
665
+ include: EXECUTE_INCLUDE
666
+ }).pipe(Effect.timeout(config.executeTimeout), Effect.mapError(timeoutAsTransport("executeTransaction")));
667
+ });
668
+ const result = yield* once.pipe(Effect.retry({
669
+ schedule: config.resubmit,
670
+ times: Math.max(config.resubmitAttempts - 1, 0),
671
+ while: (error) => error.retryable
672
+ }), Effect.flatMap(fromTransactionResult), Effect.catchTag("DecodeError", (error) => Effect.fail(new TransportError({ method: "executeTransaction", retryable: false, cause: error }))), Effect.result);
673
+ if (result._tag === "Success") {
674
+ yield* journalSettled(yield* executedEntry(result.success));
675
+ yield* awaitVisible(sui, config, signed.digest);
676
+ return result.success;
677
+ }
678
+ const failure = result.failure;
679
+ if (failure._tag === "ExecutionFailed") {
680
+ yield* journalSettled(yield* failedEntry(failure));
681
+ return yield* failure;
682
+ }
683
+ // Bytes may have reached the network: the only honest next step is to ask.
684
+ return yield* reconcileSigned(signed, failure, attempts);
685
+ });
686
+ /** `reconcile` plus the journal bookkeeping `submit` owes after it. */
687
+ const reconcileSigned = Effect.fn("Tx.reconcileSigned")(function* (signed, lastError, attempts) {
688
+ const settled = yield* Effect.result(reconcile(signed));
689
+ if (settled._tag === "Success") {
690
+ yield* journalSettled(yield* executedEntry(settled.success));
691
+ return settled.success;
692
+ }
693
+ const failure = settled.failure;
694
+ switch (failure._tag) {
695
+ case "ExecutionFailed":
696
+ yield* journalSettled(yield* failedEntry(failure));
697
+ return yield* failure;
698
+ case "NotApplied":
699
+ // Terminal: the transaction was proven never to have applied, so the
700
+ // journal records that rather than leaving an `Unknown` nothing will
701
+ // ever settle.
702
+ yield* journalSettled(yield* notAppliedEntry(failure));
703
+ return yield* failure;
704
+ default: {
705
+ const unknown = failure._tag === "SubmissionUnknown"
706
+ ? failure
707
+ : new SubmissionUnknown({ digest: signed.digest, signed, cause: failure });
708
+ yield* journalSettled(yield* unknownEntry(signed, SuiError.describe(lastError), attempts));
709
+ return yield* unknown;
710
+ }
711
+ }
712
+ });
713
+ const inputOf = (input) => {
714
+ if (typeof input === "string")
715
+ return { digest: input };
716
+ if (input instanceof SubmissionUnknown) {
717
+ return input.signed === undefined
718
+ ? { digest: input.digest }
719
+ : { digest: input.digest, signed: input.signed };
720
+ }
721
+ return { digest: input.digest, signed: input };
722
+ };
723
+ /**
724
+ * One read of a pinned object, asking for the digest that last mutated it.
725
+ *
726
+ * `previousTransaction` is the whole point of the read: it is the only field
727
+ * that says *which* transaction moved the object on, and therefore the only
728
+ * thing that can tell "someone else spent our input" from "we spent it
729
+ * ourselves and the node has not caught up". The include set asks for nothing
730
+ * else — no `content`, so a version bump on an object whose Move type we cannot
731
+ * decode is still readable evidence.
732
+ *
733
+ * Fails with: `TransportError`.
734
+ */
735
+ const inputStateOf = Effect.fn("Tx.inputStateOf")(function* (sui, objectId) {
736
+ const found = yield* Effect.result(sui.core.getObject({ objectId, include: { previousTransaction: true } }));
737
+ if (found._tag !== "Success") {
738
+ // Deleted, wrapped, or the node would not say: there is no version and no
739
+ // consuming digest to reason about, only absence.
740
+ if (found.failure._tag === "TransportError")
741
+ return yield* found.failure;
742
+ return { _tag: "Absent" };
743
+ }
744
+ const object = found.success.object;
745
+ const version = yield* Effect.try({
746
+ try: () => BigInt(object.version),
747
+ catch: (cause) => new TransportError({
748
+ method: "getObject",
749
+ retryable: false,
750
+ cause: `the node reported version ${object.version} for ${objectId}: ${String(cause)}`
751
+ })
752
+ });
753
+ return {
754
+ _tag: "Present",
755
+ version,
756
+ previousTransaction: object.previousTransaction ?? undefined
757
+ };
758
+ });
759
+ /**
760
+ * The version of one object that a given transaction took as **input**, read
761
+ * out of that transaction's own effects.
762
+ *
763
+ * `changedObjects[].inputVersion` is the only field on the network that answers
764
+ * "which version did this transaction consume": there is no `v + 1` rule to
765
+ * lean on, because Sui stamps every output of a transaction with that
766
+ * transaction's **Lamport version** — `max(input versions) + 1` across all of
767
+ * its inputs — so an owned object read alongside a newer gas coin jumps from
768
+ * version 4 to 6,436,928 and the object "at version 5" never existed.
769
+ *
770
+ * A transaction that **failed** on chain still consumed its inputs, so its
771
+ * effects are read out of `ExecutionFailed` as readily as out of `Executed`.
772
+ * `undefined` means the question cannot be answered here — the node does not
773
+ * serve that transaction, or its effects do not mention this object (it was
774
+ * created rather than consumed).
775
+ *
776
+ * Fails with: `TransportError`.
777
+ */
778
+ const consumedVersionOf = Effect.fn("Tx.consumedVersionOf")(function* (sui, by, objectId) {
779
+ const decoded = decodeDigestOption(by);
780
+ if (decoded._tag !== "Some")
781
+ return undefined;
782
+ const found = yield* Effect.result(sui.getTransaction(decoded.value));
783
+ if (found._tag === "Failure" && found.failure._tag === "TransportError") {
784
+ return yield* found.failure;
785
+ }
786
+ const effects = found._tag === "Success"
787
+ ? found.success.effects
788
+ : found.failure._tag === "ExecutionFailed"
789
+ ? found.failure.effects
790
+ : undefined;
791
+ if (effects === undefined)
792
+ return undefined;
793
+ const wanted = normalizeSuiAddress(objectId);
794
+ for (const change of effects.changedObjects) {
795
+ if (normalizeSuiAddress(change.objectId) !== wanted)
796
+ continue;
797
+ return change.inputVersion ?? undefined;
798
+ }
799
+ return undefined;
800
+ });
801
+ /**
802
+ * Reads every object the bytes pinned — owned inputs and gas coins alike — and
803
+ * says what their current state proves.
804
+ *
805
+ * A version that has not moved proves nothing and the next reference is tried.
806
+ * A version that has moved (or an object that is gone) says only *that* it
807
+ * moved; it does not say **who** moved it, and that is the whole question.
808
+ *
809
+ * The live object's `previousTransaction` names the **latest** mutation, which
810
+ * is not necessarily the consumer of the version these bytes pinned: T can
811
+ * consume version 3 and U version 4, and the object then names U. So the answer
812
+ * is read out of that transaction's own effects:
813
+ *
814
+ * - `previousTransaction` is **our** digest: the transaction applied, whatever
815
+ * the read replica that answered `getTransaction` thought;
816
+ * - it is a **different** digest, and that transaction's effects report
817
+ * `inputVersion` **equal to the version we pinned**: those exact bytes can
818
+ * never execute again, and that is the one thing `NotApplied
819
+ * { inputConsumed }` may be built on;
820
+ * - it is a different digest whose `inputVersion` is **greater** than ours:
821
+ * something between the two consumed our version and the node does not serve
822
+ * the state in between, so nothing is proven;
823
+ * - nothing readable at all — a deleted object, a node that names no
824
+ * transaction, a transaction the node has pruned — proves nothing either way.
825
+ *
826
+ * **Every pinned reference is tried.** A gas coin that merely moved on, or an
827
+ * input whose consumer cannot be read, does not end the search: the first
828
+ * inconclusive reference is remembered and the next one is read, and the
829
+ * remembered reason is only reported when no reference proved anything. Giving
830
+ * up on the first moved reference is how a reconcile with `AppliedByUs`
831
+ * evidence one read away returned `SubmissionUnknown`.
832
+ *
833
+ * Fails with: `TransportError`.
834
+ */
835
+ const inputEvidence = Effect.fn("Tx.inputEvidence")(function* (sui, digest, signed) {
836
+ let inconclusive;
837
+ const remember = (objectId, reason) => {
838
+ if (inconclusive === undefined)
839
+ inconclusive = { _tag: "Unreadable", objectId, reason };
840
+ };
841
+ for (const pinned of pinnedRefsOfBytes(signed.bytes)) {
842
+ const decoded = decodeObjectId(pinned.objectId);
843
+ if (decoded._tag !== "Some")
844
+ continue;
845
+ const objectId = decoded.value;
846
+ const state = yield* inputStateOf(sui, objectId);
847
+ if (state._tag === "Present" && state.version <= pinned.version)
848
+ continue;
849
+ if (state._tag === "Absent") {
850
+ remember(objectId, "it is gone, and a deleted object names no transaction");
851
+ continue;
852
+ }
853
+ const by = state.previousTransaction;
854
+ if (by === undefined) {
855
+ remember(objectId, "the node served the object but named no transaction as its last mutation");
856
+ continue;
857
+ }
858
+ if (by === digest)
859
+ return { _tag: "AppliedByUs", objectId };
860
+ const consumed = yield* consumedVersionOf(sui, by, objectId);
861
+ if (consumed === undefined) {
862
+ remember(objectId, `the node does not say which version transaction ${by}, which last mutated it, consumed`);
863
+ continue;
864
+ }
865
+ if (consumed === pinned.version)
866
+ return { _tag: "ConsumedByOther", objectId, by };
867
+ remember(objectId, `transaction ${by} consumed version ${consumed} of it, not the version ${pinned.version}` +
868
+ " these bytes pinned, and the state in between is not served");
869
+ }
870
+ return inconclusive ?? { _tag: "NoEvidence" };
871
+ });
872
+ /** The epoch the node currently reports. Fails with: `TransportError`. */
873
+ const currentEpoch = Effect.fn("Tx.currentEpoch")(function* (sui) {
874
+ const { systemState } = yield* sui.core.getCurrentSystemState();
875
+ return yield* epochOf(systemState.epoch);
876
+ });
877
+ /**
878
+ * Whether the transaction's expiration window is observably closed **right
879
+ * now**: the current epoch is past its `maxEpoch`, or `chainTime` is past its
880
+ * `maxTimestamp` by more than `SubmitConfig.expiryMargin`.
881
+ *
882
+ * One observation of this is not evidence of anything (see `reconcile`); it is
883
+ * the thing that has to hold twice, around a `getTransaction` miss each time.
884
+ *
885
+ * Fails with: `TransportError`.
886
+ */
887
+ const expiryClosed = Effect.fn("Tx.expiryClosed")(function* (sui, config, signed) {
888
+ const lastEpoch = maxEpochOf(signed.expiration);
889
+ if (lastEpoch !== undefined) {
890
+ // Epochs first: they are what the default expiration carries, and unlike a
891
+ // wall clock an epoch is a consensus fact, so no skew margin is needed.
892
+ const epoch = yield* currentEpoch(sui);
893
+ if (epoch > lastEpoch)
894
+ return true;
895
+ }
896
+ const bound = maxTimestampMsOf(signed.expiration);
897
+ if (bound !== undefined) {
898
+ const now = yield* sui.chainTime;
899
+ const margin = BigInt(Duration.toMillis(config.expiryMargin));
900
+ if (BigInt(DateTime.toEpochMillis(now)) > bound + margin)
901
+ return true;
902
+ }
903
+ return false;
904
+ });
905
+ /**
906
+ * The chain these bytes belong to: the one the expiration names, or the one
907
+ * `Tx.build` recorded for the variants that name none. `undefined` when neither
908
+ * is available, which is every hand-built `Signed` from before this field
909
+ * existed.
910
+ */
911
+ const chainOfSigned = (signed) => chainOf(signed.expiration) ?? signed.chain;
912
+ /**
913
+ * Finds out what happened to a transaction that was sent but never answered
914
+ * for.
915
+ *
916
+ * A transaction the node knows is `Executed`, or `ExecutionFailed` when it
917
+ * applied and aborted. A transaction the node does not know is only ever
918
+ * `NotApplied` on evidence, and there are exactly two kinds:
919
+ *
920
+ * - `"expired"`, under an **ordered and repeated** rule, because a closed
921
+ * expiration window proves only that the bytes cannot execute *later*, not
922
+ * that they did not execute *earlier*, and a transaction can execute between
923
+ * a lookup and an expiry check. So: the window must be observed closed, then
924
+ * `getTransaction` must miss, then — after `SubmitConfig.reconcileRecheck`
925
+ * (two seconds by default, through the `Clock`) — both must hold again. Any
926
+ * other order, or a single observation, is `SubmissionUnknown`. Set
927
+ * `SubmitConfig.expiryEvidence: "never"` to disable the rule entirely, which
928
+ * is what a deployment behind a mixed-node load balancer wants. The residual
929
+ * risk is a node whose transaction index lags its epoch view;
930
+ * - `"inputConsumed"` when the transaction that last mutated a pinned object is
931
+ * a **different** one **and its own effects report `inputVersion` equal to
932
+ * the version these bytes pinned**, so those exact bytes can never execute
933
+ * again.
934
+ *
935
+ * An input that merely advanced is not evidence: the transaction being
936
+ * reconciled is itself the likeliest thing to have advanced it, and calling
937
+ * that `NotApplied` would tell the documented retry idiom to execute the
938
+ * caller's intent a second time. When the live object names *our* digest the
939
+ * transaction applied and `getTransaction` is asked again; when the consuming
940
+ * transaction took a **later** version than ours — which is the common case,
941
+ * because Sui stamps every output with the transaction's Lamport version and
942
+ * the object "one version on" from ours usually never existed — nothing is
943
+ * proven and the answer is `SubmissionUnknown`, which carries the bytes so a
944
+ * later process, or a person, can settle it. Every pinned reference is tried
945
+ * before that answer is given.
946
+ *
947
+ * **Chain identity is checked before anything is asked.** Bytes built for one
948
+ * chain must never be declared expired by another chain's epoch, which a
949
+ * process-wide journal holding two networks' submissions makes easy to do. A
950
+ * mismatch is `SubmissionUnknown` naming both chains.
951
+ *
952
+ * **No `TransportError` escapes.** A recovery read that fails says nothing
953
+ * about whether the transaction applied, and `SuiError.outcome` puts
954
+ * `TransportError` on `"not_applied"` — which would tell a wrapper to retry a
955
+ * submission whose outcome is genuinely unknown. Every read failure here
956
+ * becomes `SubmissionUnknown` carrying the digest, the bytes and the cause. The
957
+ * tag stays in the signature so the union does not shrink under callers.
958
+ *
959
+ * Given only a `Digest` there can be no evidence, so an unknown transaction is
960
+ * always `SubmissionUnknown`. Pass the `Signed` bytes (or the
961
+ * `SubmissionUnknown` that carries them) to get the evidence rules.
962
+ *
963
+ * Fails with: `ExecutionFailed`, `NotApplied`, `SubmissionUnknown`,
964
+ * `TransportError`.
965
+ */
966
+ export const reconcile = Effect.fn("Tx.reconcile")(function* (input) {
967
+ const sui = yield* Sui;
968
+ const config = yield* SubmitConfig;
969
+ const { digest, signed } = inputOf(input);
970
+ return yield* recover(sui, config, digest, signed).pipe(Effect.catchTag("TransportError", (error) => Effect.fail(new SubmissionUnknown({
971
+ digest,
972
+ ...(signed === undefined ? {} : { signed }),
973
+ cause: error
974
+ }))));
975
+ });
976
+ /** The body of {@link reconcile}, before its read failures become unknown. */
977
+ const recover = Effect.fn("Tx.recover")(function* (sui, config, digest, signed) {
978
+ const unknown = (cause) => new SubmissionUnknown({
979
+ digest,
980
+ // Absent when `reconcile` was given only a digest: there is then nothing
981
+ // to re-send, and saying so is better than inventing empty bytes.
982
+ ...(signed === undefined ? {} : { signed }),
983
+ cause
984
+ });
985
+ if (signed !== undefined) {
986
+ const chain = chainOfSigned(signed);
987
+ if (chain !== undefined && chain !== sui.chainId) {
988
+ return yield* unknown(`these bytes were built for chain ${chain} and this Sui is on ${sui.chainId};` +
989
+ " nothing this node says about them is evidence");
990
+ }
991
+ }
992
+ /** One `getTransaction`, with "the node does not know it" as a value. */
993
+ const lookup = Effect.fn("Tx.recover.lookup")(function* () {
994
+ const found = yield* Effect.result(sui.getTransaction(digest));
995
+ if (found._tag === "Success")
996
+ return found.success;
997
+ if (found.failure._tag !== "TransactionNotFound")
998
+ return yield* found.failure;
999
+ return undefined;
1000
+ });
1001
+ const first = yield* lookup();
1002
+ if (first !== undefined)
1003
+ return first;
1004
+ if (signed === undefined) {
1005
+ return yield* unknown("the node does not know this digest and the signed bytes are not available");
1006
+ }
1007
+ if (config.expiryEvidence === "epochThenMiss") {
1008
+ // Ordered, and repeated: closed, then missing, then — after a delay —
1009
+ // closed and missing again. Anything else proves nothing.
1010
+ if (yield* expiryClosed(sui, config, signed)) {
1011
+ const second = yield* lookup();
1012
+ if (second !== undefined)
1013
+ return second;
1014
+ yield* Effect.sleep(config.reconcileRecheck);
1015
+ if (yield* expiryClosed(sui, config, signed)) {
1016
+ const third = yield* lookup();
1017
+ if (third !== undefined)
1018
+ return third;
1019
+ return yield* new NotApplied({ digest, evidence: "expired" });
1020
+ }
1021
+ }
1022
+ }
1023
+ const evidence = yield* inputEvidence(sui, digest, signed);
1024
+ switch (evidence._tag) {
1025
+ case "ConsumedByOther":
1026
+ return yield* new NotApplied({ digest, evidence: "inputConsumed" });
1027
+ case "AppliedByUs": {
1028
+ // Object ${objectId} names this very transaction as the one that last
1029
+ // mutated it, so it applied; the `getTransaction` above was answered by a
1030
+ // node that had not caught up. Ask once more for the receipt.
1031
+ const again = yield* lookup();
1032
+ if (again !== undefined)
1033
+ return again;
1034
+ return yield* unknown(`object ${evidence.objectId} names this transaction as the one that last mutated it,` +
1035
+ " so it applied, but the node still does not serve it");
1036
+ }
1037
+ case "Unreadable":
1038
+ return yield* unknown(`object ${evidence.objectId} has moved on or is gone, and ${evidence.reason},` +
1039
+ " so nothing is proven");
1040
+ case "NoEvidence":
1041
+ return yield* unknown("the node does not know this digest and nothing proves it cannot land");
1042
+ }
1043
+ });
1044
+ /**
1045
+ * The signer for an address the bytes require a second signature from, or a
1046
+ * `SigningError` saying which address is missing one.
1047
+ */
1048
+ const assertSponsor = (gasOwner, sponsor) => {
1049
+ if (sponsor === undefined) {
1050
+ return Effect.fail(new SigningError({
1051
+ cause: `this transaction's gas owner is ${gasOwner}, which is not the sender:` +
1052
+ " a sponsored transaction needs that party's signature too. Pass" +
1053
+ " Tx.run(recipe, { signer, gasOwner, sponsor }), or build, sign, cosign and" +
1054
+ " submit the steps yourself when the two parties cannot both sign here"
1055
+ }));
1056
+ }
1057
+ if (normalizeSuiAddress(sponsor.address) !== normalizeSuiAddress(gasOwner)) {
1058
+ return Effect.fail(new SigningError({
1059
+ cause: `the sponsor signs as ${sponsor.address}, but this transaction's gas owner` +
1060
+ ` is ${gasOwner}`
1061
+ }));
1062
+ }
1063
+ return Effect.succeed(sponsor);
1064
+ };
1065
+ /**
1066
+ * Build, preflight, sign and submit, with the sender lock held throughout.
1067
+ *
1068
+ * Gas coins are chosen during build, so two transactions from one address that
1069
+ * overlap can pick the same coin and one of them will fail on chain. `Tx.run`
1070
+ * holds the sender lock from build through submit, which is the whole reason to
1071
+ * prefer it over calling the steps separately.
1072
+ *
1073
+ * The address that matters is the one whose coins are being spent, which is the
1074
+ * **gas owner** when there is one: two sponsored runs for different senders
1075
+ * paid by one sponsor are exactly the case that picks the same coin twice. When
1076
+ * sender and gas owner differ, both locks are held, in ascending address order
1077
+ * — a fixed order, so two runs that each need the same pair cannot deadlock by
1078
+ * taking them the other way round.
1079
+ *
1080
+ * When `SubmitConfig.preflight` is set it costs one extra simulate, and is
1081
+ * where spend limits and target policies refuse a transaction before anything
1082
+ * is signed.
1083
+ *
1084
+ * **A sponsored run needs both signatures.** A transaction whose gas owner is
1085
+ * not its sender is signed by *both* parties; one signature is bytes a
1086
+ * validator rejects. So when `opts.gasOwner` differs from the signer's address
1087
+ * — or when the recipe itself set a different gas owner, which `Tx.sponsored`
1088
+ * does — `opts.sponsor` is required and co-signs the same bytes. Without it
1089
+ * `Tx.run` fails with `SigningError` naming the address whose signature is
1090
+ * missing, before anything is built when the gas owner was given as an option
1091
+ * and immediately after the build when it came out of the recipe. Use the
1092
+ * explicit lifecycle (`Tx.build`, `Tx.sign`, `Tx.cosign`, `Tx.submit`) when the
1093
+ * two parties cannot both sign in one process.
1094
+ *
1095
+ * Fails with: `BuildError`, `SimulationFailed`, `PolicyDenied`, `SigningError`,
1096
+ * `ExecutionFailed`, `NotApplied`, `SubmissionUnknown`, `JournalError`,
1097
+ * `TransportError` (from the build reads; once bytes are sent, transport
1098
+ * failures become `SubmissionUnknown`).
1099
+ */
1100
+ export const run = Effect.fn("Tx.run")(function* (recipe, opts) {
1101
+ const sui = yield* Sui;
1102
+ const config = yield* SubmitConfig;
1103
+ const sender = opts.signer.address;
1104
+ // Normalized on both sides: `0x2` and its 64-character spelling are one
1105
+ // address, and comparing them as written took two locks on one account — in
1106
+ // whichever order the two spellings happened to sort.
1107
+ const locked = opts.gasOwner === undefined ||
1108
+ normalizeSuiAddress(opts.gasOwner) === normalizeSuiAddress(sender)
1109
+ ? [sender]
1110
+ : [sender, opts.gasOwner].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
1111
+ // Before anything is built: a gas owner the caller named, with no signer for
1112
+ // it, can never produce bytes a validator accepts.
1113
+ if (opts.gasOwner !== undefined && normalizeSuiAddress(opts.gasOwner) !== normalizeSuiAddress(sender)) {
1114
+ yield* assertSponsor(opts.gasOwner, opts.sponsor);
1115
+ }
1116
+ const body = Effect.gen(function* () {
1117
+ const built = yield* build(recipe, {
1118
+ sender,
1119
+ ...(opts.gasOwner === undefined ? {} : { gasOwner: opts.gasOwner })
1120
+ });
1121
+ const preflight = config.preflight;
1122
+ if (preflight !== undefined) {
1123
+ const simulation = yield* sui.simulate(built.bytes);
1124
+ yield* preflight(simulation);
1125
+ }
1126
+ let signed = yield* sign(built, opts.signer);
1127
+ // Read back out of the bytes, so a recipe that set its own gas owner —
1128
+ // which `Tx.sponsored` does — is covered as well as the option.
1129
+ for (const required of signersOfBytes(built.bytes)) {
1130
+ if (required === normalizeSuiAddress(sender))
1131
+ continue;
1132
+ const sponsor = yield* assertSponsor(required, opts.sponsor);
1133
+ signed = yield* cosign(signed, sponsor);
1134
+ }
1135
+ return yield* submit(signed);
1136
+ });
1137
+ return yield* config.lockSender
1138
+ ? locked.reduceRight((inner, address) => sui.withSenderLock(address)(inner), body)
1139
+ : body;
1140
+ });
1141
+ /**
1142
+ * Settles every unresolved entry in the journal: the explicit startup call a
1143
+ * long-lived application makes after building a durable `Journal`.
1144
+ *
1145
+ * Nothing here fails per entry: each one settles to an `Executed`, an
1146
+ * `ExecutionFailed`, a `NotApplied` or a `SubmissionUnknown`, in the order the
1147
+ * journal listed them, and the journal is updated to match. Every settled entry
1148
+ * gets the same evidence rules `Tx.reconcile` applies — the ordered, repeated
1149
+ * expiry rule, the chain-identity guard and the versioned consumer check — so a
1150
+ * startup never reports a transaction that applied as `NotApplied`, and a
1151
+ * recovery read that fails becomes that entry's `SubmissionUnknown` rather than
1152
+ * escaping as a `TransportError` the taxonomy would call "not applied".
1153
+ *
1154
+ * The whole call fails only if the journal itself cannot be **read**: a write
1155
+ * that fails after an entry has been settled is logged and the answer stands,
1156
+ * the same rule `Tx.submit` follows.
1157
+ *
1158
+ * Fails with: `JournalError`, `TransportError`.
1159
+ */
1160
+ export const reconcileAll = Effect.fn("Tx.reconcileAll")(function* () {
1161
+ const journal = yield* Journal;
1162
+ const entries = yield* journal.listUnresolved;
1163
+ const settled = [];
1164
+ for (const entry of entries) {
1165
+ if (!isUnresolved(entry))
1166
+ continue;
1167
+ const attempts = entry._tag === "Unknown" ? entry.attempts : 1;
1168
+ const result = yield* Effect.result(reconcile(entry.signed));
1169
+ if (result._tag === "Success") {
1170
+ yield* journalSettled(yield* executedEntry(result.success));
1171
+ settled.push(result.success);
1172
+ continue;
1173
+ }
1174
+ const failure = result.failure;
1175
+ switch (failure._tag) {
1176
+ case "ExecutionFailed":
1177
+ yield* journalSettled(yield* failedEntry(failure));
1178
+ settled.push(failure);
1179
+ break;
1180
+ case "NotApplied":
1181
+ // Terminal, so the entry leaves the unresolved index: without this a
1182
+ // durable journal would hold a proven-dead submission forever and
1183
+ // `onUnresolved: "fail"` would refuse to build for the life of it.
1184
+ yield* journalSettled(yield* notAppliedEntry(failure));
1185
+ settled.push(failure);
1186
+ break;
1187
+ default: {
1188
+ // `Tx.reconcile` converts every recovery read failure into
1189
+ // `SubmissionUnknown`, so `TransportError` is in its signature and
1190
+ // never in its values; it is mapped here anyway rather than given a
1191
+ // case of its own that nothing could reach. Either way the failure
1192
+ // settles **this** entry and the loop goes on, instead of aborting a
1193
+ // whole startup on one unreachable read.
1194
+ const unresolvedOutcome = failure._tag === "SubmissionUnknown"
1195
+ ? failure
1196
+ : new SubmissionUnknown({
1197
+ digest: entry.digest,
1198
+ signed: entry.signed,
1199
+ cause: failure
1200
+ });
1201
+ yield* journalSettled(yield* unknownEntry(entry.signed, SuiError.describe(failure), attempts));
1202
+ settled.push(unresolvedOutcome);
1203
+ break;
1204
+ }
1205
+ }
1206
+ }
1207
+ return settled;
1208
+ });
1209
+ /**
1210
+ * The lifecycle, namespaced the way the spec spells it: `Tx.build`, `Tx.sign`,
1211
+ * `Tx.cosign`, `Tx.sponsored`, `Tx.submit`, `Tx.reconcile`, `Tx.run`,
1212
+ * `Tx.reconcileAll`.
1213
+ */
1214
+ export const Tx = {
1215
+ build,
1216
+ sign,
1217
+ cosign,
1218
+ sponsored,
1219
+ submit,
1220
+ reconcile,
1221
+ run,
1222
+ reconcileAll
1223
+ };
1224
+ //# sourceMappingURL=Tx.js.map