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.
- package/AGENTS.md +209 -0
- package/LICENSE +21 -0
- package/LLMS.md +6707 -0
- package/README.md +300 -0
- package/dist/domain/bcs.d.ts +101 -0
- package/dist/domain/bcs.d.ts.map +1 -0
- package/dist/domain/bcs.js +183 -0
- package/dist/domain/bcs.js.map +1 -0
- package/dist/domain/errors.d.ts +595 -0
- package/dist/domain/errors.d.ts.map +1 -0
- package/dist/domain/errors.js +476 -0
- package/dist/domain/errors.js.map +1 -0
- package/dist/domain/executed.d.ts +325 -0
- package/dist/domain/executed.d.ts.map +1 -0
- package/dist/domain/executed.js +253 -0
- package/dist/domain/executed.js.map +1 -0
- package/dist/domain/journal-entry.d.ts +468 -0
- package/dist/domain/journal-entry.d.ts.map +1 -0
- package/dist/domain/journal-entry.js +63 -0
- package/dist/domain/journal-entry.js.map +1 -0
- package/dist/domain/schemas.d.ts +950 -0
- package/dist/domain/schemas.d.ts.map +1 -0
- package/dist/domain/schemas.js +571 -0
- package/dist/domain/schemas.js.map +1 -0
- package/dist/domain/sui-schema.d.ts +12 -0
- package/dist/domain/sui-schema.d.ts.map +1 -0
- package/dist/domain/sui-schema.js +12 -0
- package/dist/domain/sui-schema.js.map +1 -0
- package/dist/extension.d.ts +7 -0
- package/dist/extension.d.ts.map +1 -0
- package/dist/extension.js +7 -0
- package/dist/extension.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +18 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +18 -0
- package/dist/internal.js.map +1 -0
- package/dist/journal.d.ts +51 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +18 -0
- package/dist/journal.js.map +1 -0
- package/dist/script.d.ts +7 -0
- package/dist/script.d.ts.map +1 -0
- package/dist/script.js +7 -0
- package/dist/script.js.map +1 -0
- package/dist/services/Journal.d.ts +76 -0
- package/dist/services/Journal.d.ts.map +1 -0
- package/dist/services/Journal.js +70 -0
- package/dist/services/Journal.js.map +1 -0
- package/dist/services/JournalKeyValueStore.d.ts +73 -0
- package/dist/services/JournalKeyValueStore.d.ts.map +1 -0
- package/dist/services/JournalKeyValueStore.js +155 -0
- package/dist/services/JournalKeyValueStore.js.map +1 -0
- package/dist/services/Script.d.ts +210 -0
- package/dist/services/Script.d.ts.map +1 -0
- package/dist/services/Script.js +441 -0
- package/dist/services/Script.js.map +1 -0
- package/dist/services/Signer.d.ts +132 -0
- package/dist/services/Signer.d.ts.map +1 -0
- package/dist/services/Signer.js +158 -0
- package/dist/services/Signer.js.map +1 -0
- package/dist/services/SubmitConfig.d.ts +175 -0
- package/dist/services/SubmitConfig.d.ts.map +1 -0
- package/dist/services/SubmitConfig.js +52 -0
- package/dist/services/SubmitConfig.js.map +1 -0
- package/dist/services/Sui.d.ts +295 -0
- package/dist/services/Sui.d.ts.map +1 -0
- package/dist/services/Sui.js +414 -0
- package/dist/services/Sui.js.map +1 -0
- package/dist/services/SuiCore.d.ts +227 -0
- package/dist/services/SuiCore.d.ts.map +1 -0
- package/dist/services/SuiCore.js +400 -0
- package/dist/services/SuiCore.js.map +1 -0
- package/dist/services/SuiCoreFake.d.ts +278 -0
- package/dist/services/SuiCoreFake.d.ts.map +1 -0
- package/dist/services/SuiCoreFake.js +1003 -0
- package/dist/services/SuiCoreFake.js.map +1 -0
- package/dist/services/SuiExtension.d.ts +208 -0
- package/dist/services/SuiExtension.d.ts.map +1 -0
- package/dist/services/SuiExtension.js +355 -0
- package/dist/services/SuiExtension.js.map +1 -0
- package/dist/services/SuiGraphQL.d.ts +98 -0
- package/dist/services/SuiGraphQL.d.ts.map +1 -0
- package/dist/services/SuiGraphQL.js +118 -0
- package/dist/services/SuiGraphQL.js.map +1 -0
- package/dist/services/Tx.d.ts +687 -0
- package/dist/services/Tx.d.ts.map +1 -0
- package/dist/services/Tx.js +1224 -0
- package/dist/services/Tx.js.map +1 -0
- package/dist/testing.d.ts +88 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +163 -0
- package/dist/testing.js.map +1 -0
- package/dist/tx.d.ts +24 -0
- package/dist/tx.d.ts.map +1 -0
- package/dist/tx.js +24 -0
- package/dist/tx.js.map +1 -0
- package/docs/extensions.md +1685 -0
- package/examples/extension-template/README.md +170 -0
- package/examples/extension-template/package.json +44 -0
- package/examples/extension-template/scripts/check-package.ts +145 -0
- package/examples/extension-template/src/Escrow.ts +445 -0
- package/examples/extension-template/src/Platform.ts +136 -0
- package/examples/extension-template/src/errors.ts +61 -0
- package/examples/extension-template/src/extension.ts +63 -0
- package/examples/extension-template/src/index.ts +34 -0
- package/examples/extension-template/src/schema.ts +158 -0
- package/examples/extension-template/src/upstream.ts +66 -0
- package/examples/extension-template/test/escrow.test.ts +559 -0
- package/examples/extension-template/tsconfig.build.json +26 -0
- package/examples/extension-template/tsconfig.json +35 -0
- 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
|