@cardano-slips/flow 0.2.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/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/complete-error.d.ts +8 -1
- package/dist/complete-error.d.ts.map +1 -1
- package/dist/complete-error.js +6 -1
- package/dist/complete-error.js.map +1 -1
- package/dist/complete.d.ts +29 -10
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +83 -9
- package/dist/complete.js.map +1 -1
- 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 +19 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- 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/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 +57 -32
- package/package.json +9 -5
- package/src/address.ts +53 -0
- package/src/card.css +492 -0
- package/src/card.ts +86 -0
- package/src/complete-error.ts +14 -2
- package/src/complete.ts +128 -19
- 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 +39 -3
- package/src/ledger.ts +503 -0
- package/src/outcome-panel.tsx +263 -0
- package/src/parameter-form.tsx +138 -0
- package/src/slip-card.tsx +244 -0
- package/src/tokens.css +57 -32
package/src/complete.ts
CHANGED
|
@@ -6,13 +6,25 @@
|
|
|
6
6
|
* a person's name on something nobody read.
|
|
7
7
|
*/
|
|
8
8
|
import type { Intent, Network } from "@cardano-slips/core"
|
|
9
|
-
import {
|
|
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"
|
|
10
19
|
import { Effect, Either } from "effect"
|
|
11
20
|
|
|
21
|
+
import { readOwnAddress } from "./address.js"
|
|
12
22
|
import { balanceIntent, type BalancingParameters } from "./balance.js"
|
|
13
23
|
import type { BalanceError } from "./balance-error.js"
|
|
14
|
-
import type
|
|
24
|
+
import { type Cip30Api, describeApiError, readApiError } from "./cip30.js"
|
|
15
25
|
import { type CompletionError, refuse } from "./complete-error.js"
|
|
26
|
+
import { networkIdFor } from "./connect.js"
|
|
27
|
+
import type { Derived } from "./derived.js"
|
|
16
28
|
import { asResolvedInputs } from "./resolve.js"
|
|
17
29
|
import { signTransaction, submitTransaction } from "./sign.js"
|
|
18
30
|
import type { SigningError } from "./sign-error.js"
|
|
@@ -22,26 +34,27 @@ import { transactionIdOf } from "./witness.js"
|
|
|
22
34
|
/** Enough rebuilds to outlast an unlucky moment, few enough that nobody is asked forever. */
|
|
23
35
|
const defaultAttempts = 3
|
|
24
36
|
|
|
25
|
-
export type Attempt = {
|
|
37
|
+
export type Attempt = Derived & {
|
|
26
38
|
/** Counting from one, so a rendered "attempt 2 of 3" reads as a person would say it. */
|
|
27
39
|
readonly number: number
|
|
28
40
|
readonly transactionId: string
|
|
29
|
-
/** What this transaction does, derived from its own bytes. */
|
|
30
|
-
readonly effects: Effects
|
|
31
41
|
}
|
|
32
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
|
+
|
|
33
52
|
export type CompletionRequest = {
|
|
34
53
|
readonly api: Cip30Api
|
|
35
54
|
/** What the endpoint declared, and what the transaction is judged against. */
|
|
36
55
|
readonly intent: Intent
|
|
37
56
|
readonly network: Network
|
|
38
57
|
readonly changeAddress: string
|
|
39
|
-
/**
|
|
40
|
-
* Every address the wallet calls its own, its reward account included. An
|
|
41
|
-
* address missing from here is read as a stranger's, which turns the wallet's
|
|
42
|
-
* own change into an undeclared payment and blocks the Slip.
|
|
43
|
-
*/
|
|
44
|
-
readonly userAddresses: ReadonlyArray<Uint8Array>
|
|
45
58
|
readonly parameters: BalancingParameters
|
|
46
59
|
readonly rewardBalance?: bigint
|
|
47
60
|
/** Read once per attempt: a rebuilt transaction gets its own validity window. */
|
|
@@ -50,6 +63,14 @@ export type CompletionRequest = {
|
|
|
50
63
|
readonly attempts?: number
|
|
51
64
|
/** Called once per attempt, after the effects are derived and before the wallet is asked to sign. */
|
|
52
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
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
export type Receipt = {
|
|
@@ -85,6 +106,62 @@ const walletUtxos = (api: Cip30Api) =>
|
|
|
85
106
|
return read.right
|
|
86
107
|
})
|
|
87
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
|
+
|
|
88
165
|
/**
|
|
89
166
|
* One transaction, from the wallet's outputs to a verdict. The judging happens
|
|
90
167
|
* here rather than at the caller so that no path reaches `signTransaction`
|
|
@@ -112,20 +189,27 @@ const buildAndJudge = (request: CompletionRequest, number: number) =>
|
|
|
112
189
|
)
|
|
113
190
|
}
|
|
114
191
|
|
|
115
|
-
const
|
|
192
|
+
const resolvedInputs = asResolvedInputs(utxos)
|
|
193
|
+
const derivation = {
|
|
116
194
|
transaction: transaction.right,
|
|
117
|
-
userAddresses: request
|
|
118
|
-
resolvedInputs
|
|
195
|
+
userAddresses: yield* ownAddresses(request, resolvedInputs),
|
|
196
|
+
resolvedInputs,
|
|
119
197
|
protocolParameters: request.parameters
|
|
198
|
+
}
|
|
199
|
+
const read = Either.all({
|
|
200
|
+
effects: deriveEffects(derivation),
|
|
201
|
+
lovelace: deriveLovelace(derivation),
|
|
202
|
+
assets: deriveAssets(derivation)
|
|
120
203
|
})
|
|
121
|
-
if (Either.isLeft(
|
|
204
|
+
if (Either.isLeft(read)) {
|
|
122
205
|
return yield* Effect.fail(
|
|
123
|
-
refuse("CannotJudge", `What this transaction does could not be worked out: ${
|
|
206
|
+
refuse("CannotJudge", `What this transaction does could not be worked out: ${read.left.message}`)
|
|
124
207
|
)
|
|
125
208
|
}
|
|
209
|
+
const derived: Derived = read.right
|
|
126
210
|
|
|
127
211
|
const verdict = compare({
|
|
128
|
-
effects: effects
|
|
212
|
+
effects: derived.effects,
|
|
129
213
|
declared: request.intent,
|
|
130
214
|
changeAddress: request.changeAddress,
|
|
131
215
|
now: BigInt(now),
|
|
@@ -143,13 +227,14 @@ const buildAndJudge = (request: CompletionRequest, number: number) =>
|
|
|
143
227
|
if (verdict.right._tag === "mismatch") {
|
|
144
228
|
return yield* Effect.fail(
|
|
145
229
|
refuse("Blocked", "This transaction does not do what the Slip said it would, so it will not be signed.", {
|
|
146
|
-
reasons: verdict.right.reasons
|
|
230
|
+
reasons: verdict.right.reasons,
|
|
231
|
+
derived
|
|
147
232
|
})
|
|
148
233
|
)
|
|
149
234
|
}
|
|
150
235
|
|
|
151
236
|
const transactionId = yield* transactionIdOf(built.cbor)
|
|
152
|
-
const attempt: Attempt = { number, transactionId
|
|
237
|
+
const attempt: Attempt = { ...derived, number, transactionId }
|
|
153
238
|
|
|
154
239
|
// The caller is what puts these effects in front of a person. If that
|
|
155
240
|
// throws, the person has not seen them, so this fails closed rather than
|
|
@@ -162,6 +247,15 @@ const buildAndJudge = (request: CompletionRequest, number: number) =>
|
|
|
162
247
|
return { cbor: built.cbor, attempt }
|
|
163
248
|
})
|
|
164
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
|
+
|
|
165
259
|
/**
|
|
166
260
|
* Completes the Slip, or says why not. A submission that failed because an
|
|
167
261
|
* input was spent is the one failure worth repeating: everything else either
|
|
@@ -174,12 +268,26 @@ export const completeIntent = (request: CompletionRequest): Effect.Effect<Receip
|
|
|
174
268
|
for (let number = 1; number <= allowed; number += 1) {
|
|
175
269
|
const { attempt, cbor } = yield* buildAndJudge(request, number)
|
|
176
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 })
|
|
177
284
|
const signed = yield* signTransaction({
|
|
178
285
|
api: request.api,
|
|
179
286
|
transaction: cbor,
|
|
180
287
|
transactionId: attempt.transactionId
|
|
181
288
|
})
|
|
182
289
|
|
|
290
|
+
report(request, { _tag: "Submitting", transactionId: attempt.transactionId })
|
|
183
291
|
const submitted = yield* Effect.either(submitTransaction(request.api, signed))
|
|
184
292
|
if (Either.isRight(submitted)) {
|
|
185
293
|
return { transactionId: submitted.right, effects: attempt.effects, attempts: number }
|
|
@@ -187,6 +295,7 @@ export const completeIntent = (request: CompletionRequest): Effect.Effect<Receip
|
|
|
187
295
|
if (submitted.left.refusal !== "InputsSpent") {
|
|
188
296
|
return yield* Effect.fail(submitted.left)
|
|
189
297
|
}
|
|
298
|
+
if (number < allowed) report(request, { _tag: "Rebuilding", number: number + 1, of: allowed })
|
|
190
299
|
}
|
|
191
300
|
|
|
192
301
|
return yield* Effect.fail(
|
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
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The transaction preview, drawn from `4 · Anatomy` and `6 · Preview states` on
|
|
5
|
+
* the design sheet. Every figure is derived from the transaction; the claim is
|
|
6
|
+
* only the title it is held against. A mismatch removes the sign button rather
|
|
7
|
+
* than disabling it, and no prop brings it back.
|
|
8
|
+
*/
|
|
9
|
+
import type { Verdict } from "@cardano-slips/verifier"
|
|
10
|
+
import { useEffect, useId, useState } from "react"
|
|
11
|
+
|
|
12
|
+
import type { Derived } from "./derived.js"
|
|
13
|
+
import { formatCountdown, secondsLeft } from "./explain.js"
|
|
14
|
+
import {
|
|
15
|
+
blockedLine,
|
|
16
|
+
evidenceOf,
|
|
17
|
+
groupsOf,
|
|
18
|
+
headlineOf,
|
|
19
|
+
isCollapsed,
|
|
20
|
+
isGrouped,
|
|
21
|
+
ledgerOf,
|
|
22
|
+
matchLine,
|
|
23
|
+
rawLines,
|
|
24
|
+
type Group,
|
|
25
|
+
type Row
|
|
26
|
+
} from "./ledger.js"
|
|
27
|
+
|
|
28
|
+
export type EffectsPanelProps = {
|
|
29
|
+
/** What the card promised, restated so it can be compared with what follows. */
|
|
30
|
+
readonly claim: string
|
|
31
|
+
/** The card's description, set beside the transaction when the two disagree. */
|
|
32
|
+
readonly description?: string
|
|
33
|
+
/** The host that served the link, as a host only. Whether it is verified is the identity chip's to say. */
|
|
34
|
+
readonly origin?: string
|
|
35
|
+
readonly derived: Derived
|
|
36
|
+
/** The verifier's own verdict. Required, so no caller renders a match by leaving the reasons out. */
|
|
37
|
+
readonly verdict: Verdict
|
|
38
|
+
/** Read once a second for the countdown. */
|
|
39
|
+
readonly clock?: () => number
|
|
40
|
+
readonly onSign?: () => void
|
|
41
|
+
readonly onCancel?: () => void
|
|
42
|
+
readonly onRebuild?: () => void
|
|
43
|
+
/** "Report this link" is offered only where there is somewhere to send the report. */
|
|
44
|
+
readonly onReport?: () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Under this, the countdown leaves the meta row and takes the headline's place (state f). */
|
|
48
|
+
const expiringUnder = 60
|
|
49
|
+
|
|
50
|
+
const groupNames: { readonly [group in Group]: string } = {
|
|
51
|
+
arriving: "Arriving",
|
|
52
|
+
leaving: "Leaving",
|
|
53
|
+
unmoved: "No value moved"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const markWords = { "not-in-the-link": "Not in the link", "not-as-the-link-says": "Not as the link says" } as const
|
|
57
|
+
|
|
58
|
+
const chipWords = { refundable: "Refundable", "as-stated": "As stated" } as const
|
|
59
|
+
|
|
60
|
+
const useNow = (clock: () => number): number => {
|
|
61
|
+
const [now, setNow] = useState(clock)
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const timer = setInterval(() => setNow(clock()), 1000)
|
|
64
|
+
return () => clearInterval(timer)
|
|
65
|
+
}, [clock])
|
|
66
|
+
return now
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const LedgerRow = ({ row }: { readonly row: Row }): React.JSX.Element => (
|
|
70
|
+
<div className="slip-panel__row" data-mark={row.mark}>
|
|
71
|
+
<div className="slip-panel__row-name">
|
|
72
|
+
<div className="slip-panel__row-label">
|
|
73
|
+
<span className="slip-panel__label">{row.label}</span>
|
|
74
|
+
{row.chip === undefined ? undefined : (
|
|
75
|
+
<span className="slip-panel__chip" data-chip={row.chip}>
|
|
76
|
+
{chipWords[row.chip]}
|
|
77
|
+
</span>
|
|
78
|
+
)}
|
|
79
|
+
{row.mark === undefined ? undefined : (
|
|
80
|
+
<span className="slip-panel__chip" data-chip="mark">
|
|
81
|
+
{markWords[row.mark]}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
{row.detail === undefined ? undefined : <span className="slip-panel__detail">{row.detail}</span>}
|
|
86
|
+
</div>
|
|
87
|
+
<div className="slip-panel__amounts">
|
|
88
|
+
{row.identifier === undefined ? undefined : <span className="slip-panel__identifier">{row.identifier}</span>}
|
|
89
|
+
{row.amounts.map((amount, index) => (
|
|
90
|
+
<span key={index} className="slip-panel__amount" data-tone={amount.tone}>
|
|
91
|
+
{amount.text}
|
|
92
|
+
</span>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
{row.note === undefined ? undefined : <p className="slip-panel__note">{row.note}</p>}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const Rows = ({ rows }: { readonly rows: ReadonlyArray<Row> }): React.JSX.Element => (
|
|
100
|
+
<div className="slip-panel__ledger">
|
|
101
|
+
{rows.map((row) => (
|
|
102
|
+
<LedgerRow key={row.key} row={row} />
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Past eight rows each group folds to its header and a count. A group holding
|
|
109
|
+
* a marked row never starts folded: the one thing the block points at is not
|
|
110
|
+
* something a person should have to open.
|
|
111
|
+
*/
|
|
112
|
+
export const Ledger = ({ rows }: { readonly rows: ReadonlyArray<Row> }): React.JSX.Element => {
|
|
113
|
+
if (!isGrouped(rows)) return <Rows rows={rows} />
|
|
114
|
+
const collapsed = isCollapsed(rows)
|
|
115
|
+
return (
|
|
116
|
+
<div className="slip-panel__groups">
|
|
117
|
+
{groupsOf(rows).map(({ group, rows: inGroup }) =>
|
|
118
|
+
collapsed ? (
|
|
119
|
+
<details
|
|
120
|
+
key={group}
|
|
121
|
+
className="slip-panel__group"
|
|
122
|
+
open={inGroup.some((row) => row.mark !== undefined) || undefined}
|
|
123
|
+
>
|
|
124
|
+
<summary className="slip-panel__group-name">
|
|
125
|
+
{groupNames[group]} · {inGroup.length}
|
|
126
|
+
</summary>
|
|
127
|
+
<Rows rows={inGroup} />
|
|
128
|
+
</details>
|
|
129
|
+
) : (
|
|
130
|
+
<div key={group} className="slip-panel__group">
|
|
131
|
+
<p className="slip-panel__group-name">{groupNames[group]}</p>
|
|
132
|
+
<Rows rows={inGroup} />
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const Raw = ({ derived }: { readonly derived: Derived }): React.JSX.Element => (
|
|
141
|
+
<pre className="slip-panel__raw-lines">{rawLines(derived).join("\n")}</pre>
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const Countdown = ({ seconds }: { readonly seconds: number }): React.JSX.Element => (
|
|
145
|
+
// The server and the browser read the clock a moment apart.
|
|
146
|
+
<span suppressHydrationWarning>{formatCountdown(seconds)}</span>
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const Blocked = ({
|
|
150
|
+
claim,
|
|
151
|
+
description,
|
|
152
|
+
derived,
|
|
153
|
+
reasons,
|
|
154
|
+
onCancel,
|
|
155
|
+
onReport
|
|
156
|
+
}: Pick<EffectsPanelProps, "claim" | "description" | "derived" | "onCancel" | "onReport"> & {
|
|
157
|
+
readonly reasons: Extract<Verdict, { _tag: "mismatch" }>["reasons"]
|
|
158
|
+
}): React.JSX.Element => {
|
|
159
|
+
const [rawOpen, setRawOpen] = useState(false)
|
|
160
|
+
const title = useId()
|
|
161
|
+
const rows = ledgerOf(derived, reasons)
|
|
162
|
+
return (
|
|
163
|
+
<section className="slip-root slip-panel" data-blocked="" aria-labelledby={title}>
|
|
164
|
+
<div className="slip-panel__claim" role="alert">
|
|
165
|
+
<p className="slip-panel__kicker" data-tone="bad">
|
|
166
|
+
Signing blocked
|
|
167
|
+
</p>
|
|
168
|
+
<h2 className="slip-panel__title" id={title}>
|
|
169
|
+
This transaction doesn't do what the link says
|
|
170
|
+
</h2>
|
|
171
|
+
<p className="slip-panel__lead">{blockedLine(reasons)}</p>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div className="slip-panel__compare">
|
|
175
|
+
<div className="slip-panel__side">
|
|
176
|
+
<p className="slip-panel__kicker">The link claims</p>
|
|
177
|
+
<p className="slip-panel__claimed">{claim}</p>
|
|
178
|
+
{description === undefined ? undefined : <p className="slip-panel__note">{description}</p>}
|
|
179
|
+
</div>
|
|
180
|
+
<div className="slip-panel__side" data-tone="bad">
|
|
181
|
+
<p className="slip-panel__kicker" data-tone="bad">
|
|
182
|
+
The transaction does
|
|
183
|
+
</p>
|
|
184
|
+
{reasons.map((reason, index) => {
|
|
185
|
+
const { figure, lines } = evidenceOf(reason, derived)
|
|
186
|
+
return (
|
|
187
|
+
<div key={index} className="slip-panel__evidence">
|
|
188
|
+
{figure === undefined ? undefined : <p className="slip-panel__evidence-figure">{figure}</p>}
|
|
189
|
+
{lines.map((line) => (
|
|
190
|
+
<p key={line} className="slip-panel__detail">
|
|
191
|
+
{line}
|
|
192
|
+
</p>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
})}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<Ledger rows={rows} />
|
|
201
|
+
|
|
202
|
+
{rawOpen ? <Raw derived={derived} /> : undefined}
|
|
203
|
+
|
|
204
|
+
<div className="slip-panel__exits">
|
|
205
|
+
<button type="button" className="slip-panel__button" data-kind="close" onClick={onCancel}>
|
|
206
|
+
Close without signing
|
|
207
|
+
</button>
|
|
208
|
+
<div className="slip-panel__quiet">
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
className="slip-panel__button"
|
|
212
|
+
data-kind="quiet"
|
|
213
|
+
aria-expanded={rawOpen}
|
|
214
|
+
onClick={() => setRawOpen((open) => !open)}
|
|
215
|
+
>
|
|
216
|
+
{rawOpen ? "Hide raw transaction" : "Show raw transaction"}
|
|
217
|
+
</button>
|
|
218
|
+
{onReport === undefined ? undefined : (
|
|
219
|
+
<button type="button" className="slip-panel__button" data-kind="quiet" onClick={onReport}>
|
|
220
|
+
Report this link
|
|
221
|
+
</button>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const EffectsPanel = ({
|
|
230
|
+
claim,
|
|
231
|
+
description,
|
|
232
|
+
origin,
|
|
233
|
+
derived,
|
|
234
|
+
verdict,
|
|
235
|
+
clock = Date.now,
|
|
236
|
+
onSign,
|
|
237
|
+
onCancel,
|
|
238
|
+
onRebuild,
|
|
239
|
+
onReport
|
|
240
|
+
}: EffectsPanelProps): React.JSX.Element => {
|
|
241
|
+
const now = useNow(clock)
|
|
242
|
+
const [rawOpen, setRawOpen] = useState(false)
|
|
243
|
+
const title = useId()
|
|
244
|
+
|
|
245
|
+
if (verdict._tag === "mismatch") {
|
|
246
|
+
return (
|
|
247
|
+
<Blocked
|
|
248
|
+
claim={claim}
|
|
249
|
+
derived={derived}
|
|
250
|
+
reasons={verdict.reasons}
|
|
251
|
+
{...(description === undefined ? {} : { description })}
|
|
252
|
+
{...(onCancel === undefined ? {} : { onCancel })}
|
|
253
|
+
{...(onReport === undefined ? {} : { onReport })}
|
|
254
|
+
/>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const rows = ledgerOf(derived)
|
|
259
|
+
const until = derived.effects.validity.validUntil
|
|
260
|
+
const seconds = until === null ? undefined : secondsLeft(until.time, BigInt(now))
|
|
261
|
+
|
|
262
|
+
if (seconds === 0) {
|
|
263
|
+
return (
|
|
264
|
+
<section className="slip-root slip-panel" data-expired="" aria-labelledby={title}>
|
|
265
|
+
<div className="slip-panel__claim">
|
|
266
|
+
<p className="slip-panel__kicker" data-tone="warn">
|
|
267
|
+
Expired
|
|
268
|
+
</p>
|
|
269
|
+
<h2 className="slip-panel__title" id={title}>
|
|
270
|
+
This transaction ran out of time
|
|
271
|
+
</h2>
|
|
272
|
+
<p className="slip-panel__lead">
|
|
273
|
+
Nothing was signed and nothing left your wallet. Building a new one takes a second, and the amounts will be
|
|
274
|
+
checked again.
|
|
275
|
+
</p>
|
|
276
|
+
</div>
|
|
277
|
+
<div className="slip-panel__stale">
|
|
278
|
+
<Ledger rows={rows} />
|
|
279
|
+
</div>
|
|
280
|
+
<div className="slip-panel__buttons">
|
|
281
|
+
<button type="button" className="slip-panel__button" data-kind="cancel" onClick={onCancel}>
|
|
282
|
+
Cancel
|
|
283
|
+
</button>
|
|
284
|
+
<button type="button" className="slip-panel__button" data-kind="primary" onClick={onRebuild}>
|
|
285
|
+
Build a new transaction
|
|
286
|
+
</button>
|
|
287
|
+
</div>
|
|
288
|
+
</section>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const headline = headlineOf(derived)
|
|
293
|
+
const expiring = seconds !== undefined && seconds < expiringUnder
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<section className="slip-root slip-panel" aria-labelledby={title}>
|
|
297
|
+
<div className="slip-panel__claim">
|
|
298
|
+
<p className="slip-panel__kicker">What you're signing</p>
|
|
299
|
+
<h2 className="slip-panel__title" id={title}>
|
|
300
|
+
{claim}
|
|
301
|
+
</h2>
|
|
302
|
+
{origin === undefined ? undefined : <span className="slip-panel__origin">{origin}</span>}
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{expiring ? (
|
|
306
|
+
<div className="slip-panel__expiring" role="timer">
|
|
307
|
+
<div className="slip-panel__expiring-head">
|
|
308
|
+
<span>This transaction expires in</span>
|
|
309
|
+
<span className="slip-panel__expiring-clock">
|
|
310
|
+
<Countdown seconds={seconds} />
|
|
311
|
+
</span>
|
|
312
|
+
</div>
|
|
313
|
+
<div className="slip-panel__bar" aria-hidden="true">
|
|
314
|
+
<span
|
|
315
|
+
className="slip-panel__bar-fill"
|
|
316
|
+
suppressHydrationWarning
|
|
317
|
+
style={{ width: `${(seconds / expiringUnder) * 100}%` }}
|
|
318
|
+
/>
|
|
319
|
+
</div>
|
|
320
|
+
<p className="slip-panel__note">After that the amounts could be stale, so we'll build a fresh one.</p>
|
|
321
|
+
</div>
|
|
322
|
+
) : (
|
|
323
|
+
<div className="slip-panel__headline">
|
|
324
|
+
<div className="slip-panel__figure">
|
|
325
|
+
<span className="slip-panel__note">{headline.label}</span>
|
|
326
|
+
<span className="slip-panel__figure-amount" data-tone={headline.tone}>
|
|
327
|
+
{headline.amount}
|
|
328
|
+
</span>
|
|
329
|
+
{headline.fee === undefined ? undefined : <span className="slip-panel__note">{headline.fee}</span>}
|
|
330
|
+
</div>
|
|
331
|
+
{headline.held === undefined ? undefined : (
|
|
332
|
+
<>
|
|
333
|
+
<span className="slip-panel__rule" aria-hidden="true" />
|
|
334
|
+
<div className="slip-panel__figure">
|
|
335
|
+
<span className="slip-panel__note">{headline.held.label}</span>
|
|
336
|
+
<span className="slip-panel__figure-amount" data-tone={headline.held.tone}>
|
|
337
|
+
{headline.held.amount}
|
|
338
|
+
</span>
|
|
339
|
+
</div>
|
|
340
|
+
</>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
<Ledger rows={rows} />
|
|
346
|
+
|
|
347
|
+
{expiring || seconds === undefined ? undefined : (
|
|
348
|
+
<div className="slip-panel__expiry">
|
|
349
|
+
<span className="slip-panel__note">Expires in</span>
|
|
350
|
+
<span className="slip-panel__clock">
|
|
351
|
+
<Countdown seconds={seconds} />
|
|
352
|
+
</span>
|
|
353
|
+
</div>
|
|
354
|
+
)}
|
|
355
|
+
|
|
356
|
+
<p className="slip-panel__verdict">
|
|
357
|
+
<span className="slip-panel__tick" aria-hidden="true">
|
|
358
|
+
✓
|
|
359
|
+
</span>
|
|
360
|
+
<span>{matchLine(derived, rows)}</span>
|
|
361
|
+
</p>
|
|
362
|
+
|
|
363
|
+
<div className="slip-panel__buttons">
|
|
364
|
+
<button type="button" className="slip-panel__button" data-kind="cancel" onClick={onCancel}>
|
|
365
|
+
Cancel
|
|
366
|
+
</button>
|
|
367
|
+
<button type="button" className="slip-panel__button" data-kind="primary" onClick={onSign}>
|
|
368
|
+
Sign transaction
|
|
369
|
+
</button>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<div className="slip-panel__raw">
|
|
373
|
+
<button
|
|
374
|
+
type="button"
|
|
375
|
+
className="slip-panel__raw-toggle"
|
|
376
|
+
aria-expanded={rawOpen}
|
|
377
|
+
onClick={() => setRawOpen((open) => !open)}
|
|
378
|
+
>
|
|
379
|
+
<span aria-hidden="true">{rawOpen ? "▾" : "▸"}</span>
|
|
380
|
+
<span>Raw transaction</span>
|
|
381
|
+
</button>
|
|
382
|
+
{rawOpen ? <Raw derived={derived} /> : undefined}
|
|
383
|
+
</div>
|
|
384
|
+
</section>
|
|
385
|
+
)
|
|
386
|
+
}
|