@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.
Files changed (73) hide show
  1. package/README.md +53 -9
  2. package/dist/address.d.ts +12 -0
  3. package/dist/address.d.ts.map +1 -1
  4. package/dist/address.js +43 -0
  5. package/dist/address.js.map +1 -1
  6. package/dist/card.css +492 -0
  7. package/dist/card.d.ts +41 -0
  8. package/dist/card.d.ts.map +1 -0
  9. package/dist/card.js +69 -0
  10. package/dist/card.js.map +1 -0
  11. package/dist/complete-error.d.ts +8 -1
  12. package/dist/complete-error.d.ts.map +1 -1
  13. package/dist/complete-error.js +6 -1
  14. package/dist/complete-error.js.map +1 -1
  15. package/dist/complete.d.ts +29 -10
  16. package/dist/complete.d.ts.map +1 -1
  17. package/dist/complete.js +83 -9
  18. package/dist/complete.js.map +1 -1
  19. package/dist/derived.d.ts +8 -0
  20. package/dist/derived.d.ts.map +1 -0
  21. package/dist/derived.js +2 -0
  22. package/dist/derived.js.map +1 -0
  23. package/dist/effects-panel.d.ts +37 -0
  24. package/dist/effects-panel.d.ts.map +1 -0
  25. package/dist/effects-panel.js +64 -0
  26. package/dist/effects-panel.js.map +1 -0
  27. package/dist/effects.css +629 -0
  28. package/dist/exchange.d.ts +54 -0
  29. package/dist/exchange.d.ts.map +1 -0
  30. package/dist/exchange.js +136 -0
  31. package/dist/exchange.js.map +1 -0
  32. package/dist/explain.d.ts +39 -0
  33. package/dist/explain.d.ts.map +1 -0
  34. package/dist/explain.js +172 -0
  35. package/dist/explain.js.map +1 -0
  36. package/dist/index.d.ts +19 -3
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +9 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/ledger.d.ts +74 -0
  41. package/dist/ledger.d.ts.map +1 -0
  42. package/dist/ledger.js +402 -0
  43. package/dist/ledger.js.map +1 -0
  44. package/dist/outcome-panel.d.ts +63 -0
  45. package/dist/outcome-panel.d.ts.map +1 -0
  46. package/dist/outcome-panel.js +40 -0
  47. package/dist/outcome-panel.js.map +1 -0
  48. package/dist/parameter-form.d.ts +26 -0
  49. package/dist/parameter-form.d.ts.map +1 -0
  50. package/dist/parameter-form.js +42 -0
  51. package/dist/parameter-form.js.map +1 -0
  52. package/dist/slip-card.d.ts +55 -0
  53. package/dist/slip-card.d.ts.map +1 -0
  54. package/dist/slip-card.js +89 -0
  55. package/dist/slip-card.js.map +1 -0
  56. package/dist/tokens.css +57 -32
  57. package/package.json +9 -5
  58. package/src/address.ts +53 -0
  59. package/src/card.css +492 -0
  60. package/src/card.ts +86 -0
  61. package/src/complete-error.ts +14 -2
  62. package/src/complete.ts +128 -19
  63. package/src/derived.ts +8 -0
  64. package/src/effects-panel.tsx +386 -0
  65. package/src/effects.css +629 -0
  66. package/src/exchange.ts +212 -0
  67. package/src/explain.ts +211 -0
  68. package/src/index.ts +39 -3
  69. package/src/ledger.ts +503 -0
  70. package/src/outcome-panel.tsx +263 -0
  71. package/src/parameter-form.tsx +138 -0
  72. package/src/slip-card.tsx +244 -0
  73. 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 { compare, decodeTransaction, deriveEffects, type Effects } from "@cardano-slips/verifier"
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 { Cip30Api } from "./cip30.js"
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 effects = deriveEffects({
192
+ const resolvedInputs = asResolvedInputs(utxos)
193
+ const derivation = {
116
194
  transaction: transaction.right,
117
- userAddresses: request.userAddresses,
118
- resolvedInputs: asResolvedInputs(utxos),
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(effects)) {
204
+ if (Either.isLeft(read)) {
122
205
  return yield* Effect.fail(
123
- refuse("CannotJudge", `What this transaction does could not be worked out: ${effects.left.message}`)
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.right,
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, effects: effects.right }
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
+ }