@cardano-slips/flow 0.1.0 → 0.3.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/README.md +53 -9
- package/dist/address.d.ts +12 -0
- package/dist/address.d.ts.map +1 -1
- package/dist/address.js +43 -0
- package/dist/address.js.map +1 -1
- package/dist/balance-error.d.ts +48 -0
- package/dist/balance-error.d.ts.map +1 -0
- package/dist/balance-error.js +45 -0
- package/dist/balance-error.js.map +1 -0
- package/dist/balance.d.ts +54 -0
- package/dist/balance.d.ts.map +1 -0
- package/dist/balance.js +175 -0
- package/dist/balance.js.map +1 -0
- package/dist/card.css +492 -0
- package/dist/card.d.ts +41 -0
- package/dist/card.d.ts.map +1 -0
- package/dist/card.js +69 -0
- package/dist/card.js.map +1 -0
- package/dist/cip30.d.ts +10 -0
- package/dist/cip30.d.ts.map +1 -1
- package/dist/cip30.js +16 -0
- package/dist/cip30.js.map +1 -1
- package/dist/complete-error.d.ts +57 -0
- package/dist/complete-error.d.ts.map +1 -0
- package/dist/complete-error.js +52 -0
- package/dist/complete-error.js.map +1 -0
- package/dist/complete.d.ts +77 -0
- package/dist/complete.d.ts.map +1 -0
- package/dist/complete.js +182 -0
- package/dist/complete.js.map +1 -0
- package/dist/derived.d.ts +8 -0
- package/dist/derived.d.ts.map +1 -0
- package/dist/derived.js +2 -0
- package/dist/derived.js.map +1 -0
- package/dist/effects-panel.d.ts +37 -0
- package/dist/effects-panel.d.ts.map +1 -0
- package/dist/effects-panel.js +64 -0
- package/dist/effects-panel.js.map +1 -0
- package/dist/effects.css +629 -0
- package/dist/exchange.d.ts +54 -0
- package/dist/exchange.d.ts.map +1 -0
- package/dist/exchange.js +136 -0
- package/dist/exchange.js.map +1 -0
- package/dist/explain.d.ts +39 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +172 -0
- package/dist/explain.js.map +1 -0
- package/dist/index.d.ts +34 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +74 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +402 -0
- package/dist/ledger.js.map +1 -0
- package/dist/outcome-panel.d.ts +63 -0
- package/dist/outcome-panel.d.ts.map +1 -0
- package/dist/outcome-panel.js +40 -0
- package/dist/outcome-panel.js.map +1 -0
- package/dist/parameter-form.d.ts +26 -0
- package/dist/parameter-form.d.ts.map +1 -0
- package/dist/parameter-form.js +42 -0
- package/dist/parameter-form.js.map +1 -0
- package/dist/resolve.d.ts +11 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +21 -0
- package/dist/resolve.js.map +1 -0
- package/dist/sign-error.d.ts +62 -0
- package/dist/sign-error.d.ts.map +1 -0
- package/dist/sign-error.js +42 -0
- package/dist/sign-error.js.map +1 -0
- package/dist/sign.d.ts +38 -0
- package/dist/sign.d.ts.map +1 -0
- package/dist/sign.js +79 -0
- package/dist/sign.js.map +1 -0
- package/dist/slip-card.d.ts +55 -0
- package/dist/slip-card.d.ts.map +1 -0
- package/dist/slip-card.js +89 -0
- package/dist/slip-card.js.map +1 -0
- package/dist/tokens.css +97 -123
- package/dist/utxo.d.ts +16 -0
- package/dist/utxo.d.ts.map +1 -0
- package/dist/utxo.js +51 -0
- package/dist/utxo.js.map +1 -0
- package/dist/witness.d.ts +23 -0
- package/dist/witness.d.ts.map +1 -0
- package/dist/witness.js +101 -0
- package/dist/witness.js.map +1 -0
- package/package.json +13 -6
- package/src/address.ts +53 -0
- package/src/balance-error.ts +77 -0
- package/src/balance.ts +285 -0
- package/src/card.css +492 -0
- package/src/card.ts +86 -0
- package/src/cip30.ts +19 -0
- package/src/complete-error.ts +95 -0
- package/src/complete.ts +307 -0
- package/src/derived.ts +8 -0
- package/src/effects-panel.tsx +386 -0
- package/src/effects.css +629 -0
- package/src/exchange.ts +212 -0
- package/src/explain.ts +211 -0
- package/src/index.ts +64 -3
- package/src/ledger.ts +503 -0
- package/src/outcome-panel.tsx +263 -0
- package/src/parameter-form.tsx +138 -0
- package/src/resolve.ts +40 -0
- package/src/sign-error.ts +89 -0
- package/src/sign.ts +118 -0
- package/src/slip-card.tsx +244 -0
- package/src/tokens.css +97 -123
- package/src/utxo.ts +70 -0
- package/src/witness.ts +132 -0
package/src/complete.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole path: build against what the wallet holds, judge it with the
|
|
3
|
+
* verifier's own engine, sign, submit. When the funds move underneath, every
|
|
4
|
+
* one of those happens again — a rebuilt transaction is a different transaction,
|
|
5
|
+
* and asking for a signature on it without deriving its effects first would put
|
|
6
|
+
* a person's name on something nobody read.
|
|
7
|
+
*/
|
|
8
|
+
import type { Intent, Network } from "@cardano-slips/core"
|
|
9
|
+
import {
|
|
10
|
+
compare,
|
|
11
|
+
decodeBech32,
|
|
12
|
+
decodeTransaction,
|
|
13
|
+
deriveAssets,
|
|
14
|
+
deriveEffects,
|
|
15
|
+
deriveLovelace,
|
|
16
|
+
type Effects,
|
|
17
|
+
type ResolvedInput
|
|
18
|
+
} from "@cardano-slips/verifier"
|
|
19
|
+
import { Effect, Either } from "effect"
|
|
20
|
+
|
|
21
|
+
import { readOwnAddress } from "./address.js"
|
|
22
|
+
import { balanceIntent, type BalancingParameters } from "./balance.js"
|
|
23
|
+
import type { BalanceError } from "./balance-error.js"
|
|
24
|
+
import { type Cip30Api, describeApiError, readApiError } from "./cip30.js"
|
|
25
|
+
import { type CompletionError, refuse } from "./complete-error.js"
|
|
26
|
+
import { networkIdFor } from "./connect.js"
|
|
27
|
+
import type { Derived } from "./derived.js"
|
|
28
|
+
import { asResolvedInputs } from "./resolve.js"
|
|
29
|
+
import { signTransaction, submitTransaction } from "./sign.js"
|
|
30
|
+
import type { SigningError } from "./sign-error.js"
|
|
31
|
+
import { readWalletUtxos } from "./utxo.js"
|
|
32
|
+
import { transactionIdOf } from "./witness.js"
|
|
33
|
+
|
|
34
|
+
/** Enough rebuilds to outlast an unlucky moment, few enough that nobody is asked forever. */
|
|
35
|
+
const defaultAttempts = 3
|
|
36
|
+
|
|
37
|
+
export type Attempt = Derived & {
|
|
38
|
+
/** Counting from one, so a rendered "attempt 2 of 3" reads as a person would say it. */
|
|
39
|
+
readonly number: number
|
|
40
|
+
readonly transactionId: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Where the path has got to after the person agreed, for a screen that says so. */
|
|
44
|
+
export type Progress =
|
|
45
|
+
/** The wallet has been asked for a signature on this transaction. */
|
|
46
|
+
| { readonly _tag: "Signing"; readonly transactionId: string }
|
|
47
|
+
/** The witnesses are in the body and the wallet has been asked to submit it. */
|
|
48
|
+
| { readonly _tag: "Submitting"; readonly transactionId: string }
|
|
49
|
+
/** The funds moved under the last transaction; attempt `number` of `of` is being built. */
|
|
50
|
+
| { readonly _tag: "Rebuilding"; readonly number: number; readonly of: number }
|
|
51
|
+
|
|
52
|
+
export type CompletionRequest = {
|
|
53
|
+
readonly api: Cip30Api
|
|
54
|
+
/** What the endpoint declared, and what the transaction is judged against. */
|
|
55
|
+
readonly intent: Intent
|
|
56
|
+
readonly network: Network
|
|
57
|
+
readonly changeAddress: string
|
|
58
|
+
readonly parameters: BalancingParameters
|
|
59
|
+
readonly rewardBalance?: bigint
|
|
60
|
+
/** Read once per attempt: a rebuilt transaction gets its own validity window. */
|
|
61
|
+
readonly now?: () => number
|
|
62
|
+
/** How many times the transaction may be built. Beyond this the funds are moving faster than we can follow. */
|
|
63
|
+
readonly attempts?: number
|
|
64
|
+
/** Called once per attempt, after the effects are derived and before the wallet is asked to sign. */
|
|
65
|
+
readonly onAttempt?: (attempt: Attempt) => void
|
|
66
|
+
/**
|
|
67
|
+
* Resolves `true` once the person has pressed sign on this attempt, `false`
|
|
68
|
+
* if they turned it down. Asked again for every rebuilt transaction, because
|
|
69
|
+
* agreeing to one body is not agreeing to the next. Without it, showing the
|
|
70
|
+
* effects is taken as agreement.
|
|
71
|
+
*/
|
|
72
|
+
readonly confirm?: (attempt: Attempt) => Promise<boolean>
|
|
73
|
+
readonly onProgress?: (progress: Progress) => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type Receipt = {
|
|
77
|
+
readonly transactionId: string
|
|
78
|
+
readonly effects: Effects
|
|
79
|
+
/** How many transactions it took, which is how many times the funds moved plus one. */
|
|
80
|
+
readonly attempts: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Failure = BalanceError | CompletionError | SigningError
|
|
84
|
+
|
|
85
|
+
const fromHex = (hex: string): Uint8Array =>
|
|
86
|
+
Uint8Array.from({ length: hex.length / 2 }, (_, index) => Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16))
|
|
87
|
+
|
|
88
|
+
const walletUtxos = (api: Cip30Api) =>
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const answered = yield* Effect.tryPromise({
|
|
91
|
+
try: () => api.getUtxos(),
|
|
92
|
+
catch: (cause) => refuse("UnreadableUtxos", "The wallet could not say what it holds.", { cause })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// `null` is CIP-30's own answer for a wallet with nothing to give.
|
|
96
|
+
const hexes = answered ?? []
|
|
97
|
+
const read = readWalletUtxos(hexes)
|
|
98
|
+
if (Either.isLeft(read)) {
|
|
99
|
+
return yield* Effect.fail(
|
|
100
|
+
refuse("UnreadableUtxos", `The wallet's unspent outputs could not be read: ${read.left}`)
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
if (read.right.length === 0) {
|
|
104
|
+
return yield* Effect.fail(refuse("NoUtxos", "This wallet holds nothing to pay with."))
|
|
105
|
+
}
|
|
106
|
+
return read.right
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const addressesFrom = (api: Cip30Api, method: "getUsedAddresses" | "getUnusedAddresses" | "getRewardAddresses") =>
|
|
110
|
+
Effect.tryPromise({
|
|
111
|
+
try: () => api[method](),
|
|
112
|
+
catch: (cause) => {
|
|
113
|
+
const cip30 = readApiError(cause)
|
|
114
|
+
const said = cip30 === undefined ? String(cause) : describeApiError(cip30)
|
|
115
|
+
return refuse("UnreadableAddresses", `The wallet could not say which addresses are its own: ${said}`, { cause })
|
|
116
|
+
}
|
|
117
|
+
}).pipe(
|
|
118
|
+
Effect.filterOrFail(
|
|
119
|
+
(answered): answered is ReadonlyArray<string> => Array.isArray(answered),
|
|
120
|
+
() => refuse("UnreadableAddresses", `The wallet answered ${method} with something that is not a list.`)
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every address the wallet vouches for: those CIP-30 lists, the change address,
|
|
126
|
+
* and the address of every output it said it holds. Read with the outputs on
|
|
127
|
+
* each attempt, since both can change when the funds move. One missing is read
|
|
128
|
+
* as a stranger's, and its value would be shown as someone else's. The outputs'
|
|
129
|
+
* addresses are ahead of the spec's list, until
|
|
130
|
+
* https://github.com/emmanuel-musau/cardano-slips/issues/186 brings it into line.
|
|
131
|
+
*/
|
|
132
|
+
const ownAddresses = (request: CompletionRequest, inputs: ReadonlyArray<ResolvedInput>) =>
|
|
133
|
+
Effect.gen(function* () {
|
|
134
|
+
const listed = [
|
|
135
|
+
...(yield* addressesFrom(request.api, "getUsedAddresses")),
|
|
136
|
+
...(yield* addressesFrom(request.api, "getUnusedAddresses")),
|
|
137
|
+
...(yield* addressesFrom(request.api, "getRewardAddresses"))
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
const network = networkIdFor(request.network)
|
|
141
|
+
const read: Array<Uint8Array> = []
|
|
142
|
+
for (const hex of listed) {
|
|
143
|
+
const address = readOwnAddress(hex)
|
|
144
|
+
if (Either.isLeft(address)) {
|
|
145
|
+
return yield* Effect.fail(
|
|
146
|
+
refuse("UnreadableAddresses", `The wallet named an address of its own that cannot be read: ${address.left}`)
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
if (address.right.networkId !== undefined && address.right.networkId !== network) {
|
|
150
|
+
return yield* Effect.fail(
|
|
151
|
+
refuse("UnreadableAddresses", "The wallet named an address on another network as its own.")
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
read.push(address.right.bytes)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const change = decodeBech32(request.changeAddress)
|
|
158
|
+
if (Either.isLeft(change)) {
|
|
159
|
+
return yield* Effect.fail(refuse("UnreadableAddresses", "The change address is not an address."))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return [...read, change.right.bytes, ...inputs.map((input) => input.address)]
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One transaction, from the wallet's outputs to a verdict. The judging happens
|
|
167
|
+
* here rather than at the caller so that no path reaches `signTransaction`
|
|
168
|
+
* without having passed it.
|
|
169
|
+
*/
|
|
170
|
+
const buildAndJudge = (request: CompletionRequest, number: number) =>
|
|
171
|
+
Effect.gen(function* () {
|
|
172
|
+
const utxos = yield* walletUtxos(request.api)
|
|
173
|
+
const now = (request.now ?? Date.now)()
|
|
174
|
+
|
|
175
|
+
const built = yield* balanceIntent({
|
|
176
|
+
intent: request.intent,
|
|
177
|
+
network: request.network,
|
|
178
|
+
changeAddress: request.changeAddress,
|
|
179
|
+
utxos,
|
|
180
|
+
parameters: request.parameters,
|
|
181
|
+
...(request.rewardBalance === undefined ? {} : { rewardBalance: request.rewardBalance }),
|
|
182
|
+
now
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const transaction = decodeTransaction(fromHex(built.cbor))
|
|
186
|
+
if (Either.isLeft(transaction)) {
|
|
187
|
+
return yield* Effect.fail(
|
|
188
|
+
refuse("CannotJudge", `This transaction could not be read back: ${transaction.left.message}`)
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const resolvedInputs = asResolvedInputs(utxos)
|
|
193
|
+
const derivation = {
|
|
194
|
+
transaction: transaction.right,
|
|
195
|
+
userAddresses: yield* ownAddresses(request, resolvedInputs),
|
|
196
|
+
resolvedInputs,
|
|
197
|
+
protocolParameters: request.parameters
|
|
198
|
+
}
|
|
199
|
+
const read = Either.all({
|
|
200
|
+
effects: deriveEffects(derivation),
|
|
201
|
+
lovelace: deriveLovelace(derivation),
|
|
202
|
+
assets: deriveAssets(derivation)
|
|
203
|
+
})
|
|
204
|
+
if (Either.isLeft(read)) {
|
|
205
|
+
return yield* Effect.fail(
|
|
206
|
+
refuse("CannotJudge", `What this transaction does could not be worked out: ${read.left.message}`)
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
const derived: Derived = read.right
|
|
210
|
+
|
|
211
|
+
const verdict = compare({
|
|
212
|
+
effects: derived.effects,
|
|
213
|
+
declared: request.intent,
|
|
214
|
+
changeAddress: request.changeAddress,
|
|
215
|
+
now: BigInt(now),
|
|
216
|
+
protocolParameters: request.parameters
|
|
217
|
+
})
|
|
218
|
+
if (Either.isLeft(verdict)) {
|
|
219
|
+
return yield* Effect.fail(
|
|
220
|
+
refuse(
|
|
221
|
+
"CannotJudge",
|
|
222
|
+
`This transaction could not be checked against what was declared: ${verdict.left.message}`
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (verdict.right._tag === "mismatch") {
|
|
228
|
+
return yield* Effect.fail(
|
|
229
|
+
refuse("Blocked", "This transaction does not do what the Slip said it would, so it will not be signed.", {
|
|
230
|
+
reasons: verdict.right.reasons,
|
|
231
|
+
derived
|
|
232
|
+
})
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const transactionId = yield* transactionIdOf(built.cbor)
|
|
237
|
+
const attempt: Attempt = { ...derived, number, transactionId }
|
|
238
|
+
|
|
239
|
+
// The caller is what puts these effects in front of a person. If that
|
|
240
|
+
// throws, the person has not seen them, so this fails closed rather than
|
|
241
|
+
// carrying on to a signature — and as a refusal rather than a stack trace.
|
|
242
|
+
yield* Effect.try({
|
|
243
|
+
try: () => request.onAttempt?.(attempt),
|
|
244
|
+
catch: (cause) => refuse("NotShown", "This transaction could not be shown, so it will not be signed.", { cause })
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
return { cbor: built.cbor, attempt }
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
/** A progress report is a courtesy to the screen; one that throws must not stop a submission already under way. */
|
|
251
|
+
const report = (request: CompletionRequest, progress: Progress): void => {
|
|
252
|
+
try {
|
|
253
|
+
request.onProgress?.(progress)
|
|
254
|
+
} catch {
|
|
255
|
+
// Nothing to do: the flow carries on, and the next state the screen is given corrects it.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Completes the Slip, or says why not. A submission that failed because an
|
|
261
|
+
* input was spent is the one failure worth repeating: everything else either
|
|
262
|
+
* cannot be helped by a rebuild or is a person having said no.
|
|
263
|
+
*/
|
|
264
|
+
export const completeIntent = (request: CompletionRequest): Effect.Effect<Receipt, Failure> =>
|
|
265
|
+
Effect.gen(function* () {
|
|
266
|
+
const allowed = Math.max(1, request.attempts ?? defaultAttempts)
|
|
267
|
+
|
|
268
|
+
for (let number = 1; number <= allowed; number += 1) {
|
|
269
|
+
const { attempt, cbor } = yield* buildAndJudge(request, number)
|
|
270
|
+
|
|
271
|
+
const { confirm } = request
|
|
272
|
+
if (confirm !== undefined) {
|
|
273
|
+
const agreed = yield* Effect.tryPromise({
|
|
274
|
+
try: () => confirm(attempt),
|
|
275
|
+
catch: (cause) =>
|
|
276
|
+
refuse("NotShown", "This transaction could not be put to you, so it will not be signed.", { cause })
|
|
277
|
+
})
|
|
278
|
+
if (!agreed) {
|
|
279
|
+
return yield* Effect.fail(refuse("Cancelled", "The transaction was closed without signing."))
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
report(request, { _tag: "Signing", transactionId: attempt.transactionId })
|
|
284
|
+
const signed = yield* signTransaction({
|
|
285
|
+
api: request.api,
|
|
286
|
+
transaction: cbor,
|
|
287
|
+
transactionId: attempt.transactionId
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
report(request, { _tag: "Submitting", transactionId: attempt.transactionId })
|
|
291
|
+
const submitted = yield* Effect.either(submitTransaction(request.api, signed))
|
|
292
|
+
if (Either.isRight(submitted)) {
|
|
293
|
+
return { transactionId: submitted.right, effects: attempt.effects, attempts: number }
|
|
294
|
+
}
|
|
295
|
+
if (submitted.left.refusal !== "InputsSpent") {
|
|
296
|
+
return yield* Effect.fail(submitted.left)
|
|
297
|
+
}
|
|
298
|
+
if (number < allowed) report(request, { _tag: "Rebuilding", number: number + 1, of: allowed })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return yield* Effect.fail(
|
|
302
|
+
refuse(
|
|
303
|
+
"OutOfAttempts",
|
|
304
|
+
`The wallet's funds moved every time this was built, ${allowed} times over. Try again in a moment.`
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
})
|
package/src/derived.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** What a transaction does, read from its own bytes: the part the comparison judges, and the figures a person is shown. */
|
|
2
|
+
import type { AssetEffects, Effects, LovelaceEffects } from "@cardano-slips/verifier"
|
|
3
|
+
|
|
4
|
+
export type Derived = {
|
|
5
|
+
readonly effects: Effects
|
|
6
|
+
readonly lovelace: LovelaceEffects
|
|
7
|
+
readonly assets: AssetEffects
|
|
8
|
+
}
|