@cardano-slips/flow 0.2.0 → 0.3.1

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 +11 -7
  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/ledger.ts ADDED
@@ -0,0 +1,503 @@
1
+ /**
2
+ * What the effects panel draws, worked out without React: the headline figure,
3
+ * one row per effect, which rows a mismatch marks, and the line that states
4
+ * the verdict. Every figure comes from the derived effects, never the claim.
5
+ */
6
+ import {
7
+ addressText,
8
+ type CertificateEffect,
9
+ type Deposit,
10
+ type DRep,
11
+ encodeBech32,
12
+ type Reason
13
+ } from "@cardano-slips/verifier"
14
+
15
+ import type { Derived } from "./derived.js"
16
+ import { assetLabel, explainReason, formatAda, formatQuantity, shortened } from "./explain.js"
17
+
18
+ /** How an amount reads: gone for good, arriving, taken by the network, held to be given back, or only claimed. */
19
+ export type Tone = "spent" | "gained" | "fee" | "held" | "stated"
20
+
21
+ export type Amount = { readonly text: string; readonly tone: Tone }
22
+
23
+ export type Group = "arriving" | "leaving" | "unmoved"
24
+
25
+ /** A row the link did not describe, or described otherwise. Only these rows take colour. */
26
+ export type Mark = "not-in-the-link" | "not-as-the-link-says"
27
+
28
+ export type Row = {
29
+ readonly key: string
30
+ readonly label: string
31
+ readonly detail?: string
32
+ readonly amounts: ReadonlyArray<Amount>
33
+ /** A pool or DRep, where the row moves no value and the amount column carries a name instead. */
34
+ readonly identifier?: string
35
+ /** `refundable` where the ledger guarantees the figure, `as-stated` where only the transaction claims it. */
36
+ readonly chip?: "refundable" | "as-stated"
37
+ /** The line under a deposit or refund saying what brings it back, or who claims it. */
38
+ readonly note?: string
39
+ readonly group: Group
40
+ readonly mark?: Mark
41
+ }
42
+
43
+ export type Headline = {
44
+ readonly label: string
45
+ readonly amount: string
46
+ readonly tone: "spent" | "gained"
47
+ /** Only beside what leaves for someone else, where the fee is the smaller figure and is said once. */
48
+ readonly fee?: string
49
+ /** What is locked or handed back, beside the headline and never counted in it. */
50
+ readonly held?: { readonly label: string; readonly amount: string; readonly tone: "held" | "stated" }
51
+ }
52
+
53
+ /** What the "The transaction does" box says for one reason. */
54
+ export type Evidence = { readonly figure?: string; readonly lines: ReadonlyArray<string> }
55
+
56
+ /** Rows past this group into what arrives, what leaves and what moves nothing (sheet state h). */
57
+ const groupedFrom = 5
58
+
59
+ /** Rows past this collapse each group to its header and a count. */
60
+ const collapsedFrom = 9
61
+
62
+ const minus = "−"
63
+
64
+ const hexOf = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
65
+
66
+ const spent = (text: string): Amount => ({ text: `${minus}${text}`, tone: "spent" })
67
+ const gained = (text: string): Amount => ({ text: `+${text}`, tone: "gained" })
68
+
69
+ /** A token by its name where the name is text, and by its bytes where it is not. The quantity stays in base units. */
70
+ const tokenText = (quantity: bigint, name: Uint8Array): string => {
71
+ const label = assetLabel(hexOf(name))
72
+ if (label !== null) return `${formatQuantity(quantity)} ${label}`
73
+ return name.length === 0
74
+ ? `${formatQuantity(quantity)} of an unnamed token`
75
+ : `${formatQuantity(quantity)} of token ${hexOf(name)}`
76
+ }
77
+
78
+ const policyLine = (policyId: Uint8Array): string => `policy ${shortenedHex(hexOf(policyId))}`
79
+
80
+ const shortenedHex = (hex: string): string => (hex.length <= 12 ? hex : `${hex.slice(0, 4)}…${hex.slice(-4)}`)
81
+
82
+ /** A DRep id per CIP-129: a header byte naming key or script, then the hash. */
83
+ const drepText = (drep: DRep): string => {
84
+ switch (drep._tag) {
85
+ case "Abstain":
86
+ return "abstain"
87
+ case "NoConfidence":
88
+ return "no confidence"
89
+ case "KeyHash":
90
+ return shortened(encodeBech32("drep", Uint8Array.of(0x22, ...drep.hash)))
91
+ case "ScriptHash":
92
+ return shortened(encodeBech32("drep", Uint8Array.of(0x23, ...drep.hash)))
93
+ }
94
+ }
95
+
96
+ const identifierOf = (certificate: CertificateEffect): string | undefined => {
97
+ if (certificate.pool !== null) return shortened(encodeBech32("pool", certificate.pool))
98
+ if (certificate.drep !== null) return drepText(certificate.drep)
99
+ return undefined
100
+ }
101
+
102
+ const voteWords = (drep: DRep | null, whose: string): string => {
103
+ if (drep?._tag === "Abstain") return `set ${whose} vote to always abstain`
104
+ if (drep?._tag === "NoConfidence") return `set ${whose} vote to always no confidence`
105
+ return `delegate ${whose} vote to a DRep`
106
+ }
107
+
108
+ /** What a certificate does, in words. "Your" only where the wallet holds the key it acts on. */
109
+ export const describeCertificate = (certificate: CertificateEffect): string => {
110
+ const whose = certificate.ours || certificate.credential === null ? "your" : "another wallet's"
111
+ const capital = (text: string): string => `${text.charAt(0).toUpperCase()}${text.slice(1)}`
112
+ switch (certificate.kind) {
113
+ case "StakeRegistration":
114
+ case "Registration":
115
+ return capital(`register ${whose} stake key`)
116
+ case "StakeDeregistration":
117
+ case "Deregistration":
118
+ return capital(`close ${whose} stake key and stop delegating`)
119
+ case "StakeDelegation":
120
+ return capital(`delegate ${whose} stake to a stake pool`)
121
+ case "VoteDelegation":
122
+ return capital(voteWords(certificate.drep, whose))
123
+ case "StakeVoteDelegation":
124
+ return capital(`delegate ${whose} stake to a stake pool and ${voteWords(certificate.drep, whose)}`)
125
+ case "StakeRegistrationDelegation":
126
+ return capital(`register ${whose} stake key and delegate it to a stake pool`)
127
+ case "VoteRegistrationDelegation":
128
+ return capital(`register ${whose} stake key and ${voteWords(certificate.drep, whose)}`)
129
+ case "StakeVoteRegistrationDelegation":
130
+ return capital(
131
+ `register ${whose} stake key, delegate it to a stake pool and ${voteWords(certificate.drep, whose)}`
132
+ )
133
+ case "PoolRegistration":
134
+ return "Register a stake pool"
135
+ case "PoolRetirement":
136
+ return "Retire a stake pool"
137
+ case "AuthorizeCommitteeHot":
138
+ return "Authorise a hot key for the constitutional committee"
139
+ case "ResignCommitteeCold":
140
+ return "Resign from the constitutional committee"
141
+ case "RegisterDrep":
142
+ return `Register ${whose === "your" ? "yourself" : "another wallet"} as a DRep`
143
+ case "UnregisterDrep":
144
+ return `Retire ${whose === "your" ? "yourself" : "another wallet"} as a DRep`
145
+ case "UpdateDrep":
146
+ return capital(`update ${whose} DRep details`)
147
+ }
148
+ }
149
+
150
+ /** The parameter and a stated deposit are both what the ledger takes and gives back. Only an assumed one is a claim. */
151
+ const depositChip = (deposit: Deposit): "refundable" | "as-stated" =>
152
+ deposit.basis === "assumed" ? "as-stated" : "refundable"
153
+
154
+ const depositNote = (deposit: Deposit): string => {
155
+ const all = formatAda(deposit.amount)
156
+ if (depositChip(deposit) === "as-stated") {
157
+ return deposit.kind === "pool"
158
+ ? "Today's pool deposit. A pool that is already registered pays nothing to register again."
159
+ : "Today's deposit, standing in for a figure the transaction does not settle."
160
+ }
161
+ switch (deposit.kind) {
162
+ case "stake":
163
+ return `Held by the network while you delegate. You get all ${all} back when you stop.`
164
+ case "drep":
165
+ return `Held by the network while you are a DRep. You get all ${all} back when you retire.`
166
+ case "pool":
167
+ return `Held by the network while the pool is registered. It comes back when the pool retires.`
168
+ case "governance-action":
169
+ return "Held with the proposal. It comes back once the proposal is decided."
170
+ }
171
+ }
172
+
173
+ const refundNote = (refund: Deposit): string => {
174
+ const what = refund.kind === "drep" ? "the DRep" : "this key"
175
+ return refund.basis === "assumed"
176
+ ? `The network returns what ${what} was registered under. The transaction doesn't say what that was, so this is today's deposit standing in for it.`
177
+ : `The transaction states this figure. The network returns what ${what} was registered under, so this is the transaction's claim.`
178
+ }
179
+
180
+ const depositKey = (side: "deposit" | "refund", deposit: Deposit): string =>
181
+ `${side}-${deposit.source}-${deposit.index}`
182
+
183
+ /** Every effect as a row, in the sheet's order: what is paid, what arrives, what moves nothing, what the network takes. */
184
+ const rowsOf = (derived: Derived): Array<Row> => {
185
+ const { assets, effects, lovelace } = derived
186
+ const minted = new Set(effects.mint.map((asset) => `${hexOf(asset.policyId)}.${hexOf(asset.name)}`))
187
+
188
+ const payments = effects.outputs
189
+ .filter((output) => !output.mine)
190
+ .map((output): Row => ({
191
+ key: `output-${output.index}`,
192
+ label: "You pay",
193
+ detail: shortened(addressText(output.address)),
194
+ amounts: [
195
+ ...output.value.assets.flatMap((policy) =>
196
+ policy.assets.map((asset) => spent(tokenText(asset.quantity, asset.name)))
197
+ ),
198
+ ...(output.value.coin > 0n ? [spent(formatAda(output.value.coin))] : [])
199
+ ],
200
+ group: "leaving"
201
+ }))
202
+
203
+ const arrivals = assets.user
204
+ .filter((asset) => asset.delta < 0n && !minted.has(`${hexOf(asset.policyId)}.${hexOf(asset.name)}`))
205
+ .map((asset): Row => ({
206
+ key: `receive-${hexOf(asset.policyId)}.${hexOf(asset.name)}`,
207
+ label: "You receive",
208
+ detail: policyLine(asset.policyId),
209
+ amounts: [gained(tokenText(asset.delta, asset.name))],
210
+ group: "arriving"
211
+ }))
212
+
213
+ const mint = effects.mint.map((asset, index): Row =>
214
+ asset.quantity > 0n
215
+ ? {
216
+ key: `mint-${index}`,
217
+ label: "Tokens created",
218
+ detail: policyLine(asset.policyId),
219
+ amounts: [gained(tokenText(asset.quantity, asset.name))],
220
+ group: "arriving"
221
+ }
222
+ : {
223
+ key: `mint-${index}`,
224
+ label: "Tokens destroyed",
225
+ detail: policyLine(asset.policyId),
226
+ amounts: [spent(tokenText(asset.quantity, asset.name))],
227
+ group: "leaving"
228
+ }
229
+ )
230
+
231
+ const withdrawals = effects.withdrawals.map((withdrawal, index): Row => ({
232
+ key: `withdrawal-${index}`,
233
+ label: "Withdrawal",
234
+ detail: withdrawal.ours ? "Staking rewards to date" : shortened(addressText(withdrawal.rewardAccount)),
235
+ amounts: [gained(formatAda(withdrawal.amount))],
236
+ group: "arriving"
237
+ }))
238
+
239
+ const certificates = effects.certificates.map((certificate): Row => {
240
+ const identifier = identifierOf(certificate)
241
+ return {
242
+ key: `certificate-${certificate.index}`,
243
+ label: "Certificate",
244
+ detail: describeCertificate(certificate),
245
+ amounts: [],
246
+ ...(identifier === undefined ? {} : { identifier }),
247
+ group: "unmoved"
248
+ }
249
+ })
250
+
251
+ const fee: Row = {
252
+ key: "fee",
253
+ label: "Network fee",
254
+ amounts: [{ text: `${minus}${formatAda(lovelace.fee)}`, tone: "fee" }],
255
+ group: "leaving"
256
+ }
257
+
258
+ const deposits = lovelace.deposits.map((deposit): Row => ({
259
+ key: depositKey("deposit", deposit),
260
+ label: "Deposit",
261
+ amounts: [
262
+ { text: `${minus}${formatAda(deposit.amount)}`, tone: depositChip(deposit) === "refundable" ? "held" : "stated" }
263
+ ],
264
+ chip: depositChip(deposit),
265
+ note: depositNote(deposit),
266
+ group: "leaving"
267
+ }))
268
+
269
+ const refunds = lovelace.refunds.map((refund): Row => ({
270
+ key: depositKey("refund", refund),
271
+ label: "Refund",
272
+ amounts: [{ text: `+${formatAda(refund.amount)}`, tone: "stated" }],
273
+ chip: "as-stated",
274
+ note: refundNote(refund),
275
+ group: "arriving"
276
+ }))
277
+
278
+ return [...payments, ...arrivals, ...mint, ...withdrawals, ...certificates, fee, ...deposits, ...refunds]
279
+ }
280
+
281
+ /**
282
+ * The rows each reason is about. A reason with nothing in the transaction to
283
+ * point at — a payment left out, certificates out of order — marks no row and
284
+ * is carried by the sentence alone.
285
+ */
286
+ const marksOf = (derived: Derived, reasons: ReadonlyArray<Reason>): Map<string, Mark> => {
287
+ const marks = new Map<string, Mark>()
288
+ const mark = (key: string, how: Mark): void => {
289
+ if (!marks.has(key)) marks.set(key, how)
290
+ }
291
+ const { effects } = derived
292
+ const paidTo = (address: string) =>
293
+ effects.outputs.filter((output) => !output.mine && addressText(output.address) === address)
294
+
295
+ for (const reason of reasons) {
296
+ switch (reason.code) {
297
+ case "output.undeclared":
298
+ // Those past the count the link asked for are the extra ones.
299
+ for (const output of paidTo(reason.address).slice(reason.declared))
300
+ mark(`output-${output.index}`, "not-in-the-link")
301
+ break
302
+ case "output.lovelace":
303
+ case "output.assets":
304
+ for (const output of paidTo(reason.address)) mark(`output-${output.index}`, "not-as-the-link-says")
305
+ break
306
+ case "certificate.undeclared":
307
+ for (const certificate of effects.certificates.slice(reason.declared)) {
308
+ mark(`certificate-${certificate.index}`, "not-in-the-link")
309
+ }
310
+ break
311
+ case "certificate.target":
312
+ case "certificate.credential":
313
+ mark(`certificate-${reason.index}`, "not-as-the-link-says")
314
+ break
315
+ case "certificate.deposit":
316
+ mark(`certificate-${reason.index}`, "not-as-the-link-says")
317
+ mark(`deposit-certificate-${reason.index}`, "not-as-the-link-says")
318
+ break
319
+ case "withdrawal.undeclared":
320
+ effects.withdrawals.forEach((_, index) => mark(`withdrawal-${index}`, "not-in-the-link"))
321
+ break
322
+ case "withdrawal.account":
323
+ effects.withdrawals.forEach((withdrawal, index) => {
324
+ if (addressText(withdrawal.rewardAccount) === reason.account) mark(`withdrawal-${index}`, "not-in-the-link")
325
+ })
326
+ break
327
+ case "mint.undeclared":
328
+ effects.mint.forEach((_, index) => mark(`mint-${index}`, "not-in-the-link"))
329
+ break
330
+ case "fee.excessive":
331
+ mark("fee", "not-as-the-link-says")
332
+ break
333
+ case "output.missing":
334
+ case "certificate.missing":
335
+ case "certificate.order":
336
+ case "withdrawal.missing":
337
+ case "body.unsupported":
338
+ case "interval.beyond-declared":
339
+ case "interval.not-yet-valid":
340
+ break
341
+ }
342
+ }
343
+ return marks
344
+ }
345
+
346
+ /** The rows, with each mismatch marked on the row it is about. */
347
+ export const ledgerOf = (derived: Derived, reasons: ReadonlyArray<Reason> = []): ReadonlyArray<Row> => {
348
+ const marks = marksOf(derived, reasons)
349
+ return rowsOf(derived).map((row) => {
350
+ const mark = marks.get(row.key)
351
+ return mark === undefined ? row : { ...row, mark }
352
+ })
353
+ }
354
+
355
+ export const isGrouped = (rows: ReadonlyArray<Row>): boolean => rows.length >= groupedFrom
356
+
357
+ export const isCollapsed = (rows: ReadonlyArray<Row>): boolean => rows.length >= collapsedFrom
358
+
359
+ /** The sheet's grouped order: what arrives, what leaves, what moves nothing. Empty groups are left out. */
360
+ export const groupsOf = (
361
+ rows: ReadonlyArray<Row>
362
+ ): ReadonlyArray<{ readonly group: Group; readonly rows: ReadonlyArray<Row> }> =>
363
+ (["arriving", "leaving", "unmoved"] as const)
364
+ .map((group) => ({ group, rows: rows.filter((row) => row.group === group) }))
365
+ .filter(({ rows: inGroup }) => inGroup.length > 0)
366
+
367
+ const sum = (values: ReadonlyArray<bigint>): bigint => values.reduce((total, value) => total + value, 0n)
368
+
369
+ /**
370
+ * The one large figure. What goes to someone else, where anything does;
371
+ * otherwise what the person ends up with or spends. A deposit and a refund are
372
+ * never counted in it — one comes back and the other is only claimed.
373
+ */
374
+ export const headlineOf = (derived: Derived): Headline => {
375
+ const { effects, lovelace } = derived
376
+ const paid = effects.outputs.filter((output) => !output.mine)
377
+
378
+ const tokens = new Map<string, { readonly name: Uint8Array; quantity: bigint }>()
379
+ for (const output of paid) {
380
+ for (const policy of output.value.assets) {
381
+ for (const asset of policy.assets) {
382
+ const key = `${hexOf(policy.policyId)}.${hexOf(asset.name)}`
383
+ const held = tokens.get(key)
384
+ if (held === undefined) tokens.set(key, { name: asset.name, quantity: asset.quantity })
385
+ else held.quantity += asset.quantity
386
+ }
387
+ }
388
+ }
389
+ const coin = sum(paid.map((output) => output.value.coin))
390
+
391
+ const deposited = sum(lovelace.deposits.map((deposit) => deposit.amount))
392
+ const refunded = sum(lovelace.refunds.map((refund) => refund.amount))
393
+ const withdrawn = sum(effects.withdrawals.filter((withdrawal) => withdrawal.ours).map((w) => w.amount))
394
+ const gain = withdrawn - lovelace.fee
395
+
396
+ const base =
397
+ tokens.size > 0 || coin > 0n
398
+ ? {
399
+ label: "Leaves your wallet",
400
+ amount: [
401
+ ...[...tokens.values()].map((token) => tokenText(token.quantity, token.name)),
402
+ ...(coin > 0n ? [formatAda(coin)] : [])
403
+ ].join(" and "),
404
+ tone: "spent" as const,
405
+ fee: `plus ${formatAda(lovelace.fee)} in network fees`
406
+ }
407
+ : gain > 0n
408
+ ? { label: "You end up with", amount: `+${formatAda(gain)}`, tone: "gained" as const }
409
+ : { label: "You spend", amount: formatAda(-gain), tone: "spent" as const }
410
+
411
+ if (deposited > 0n) {
412
+ const promised = lovelace.deposits.every((deposit) => depositChip(deposit) === "refundable")
413
+ const verb = base.tone === "gained" ? "Locked" : "You lock"
414
+ return {
415
+ ...base,
416
+ held: {
417
+ label: promised ? `${verb}, refundable` : `${verb}, as stated`,
418
+ amount: formatAda(deposited),
419
+ tone: promised ? "held" : "stated"
420
+ }
421
+ }
422
+ }
423
+ if (refunded > 0n) {
424
+ return { ...base, held: { label: "Returned, as stated", amount: formatAda(refunded), tone: "stated" } }
425
+ }
426
+ return base
427
+ }
428
+
429
+ const spelled = [
430
+ "zero",
431
+ "one",
432
+ "two",
433
+ "three",
434
+ "four",
435
+ "five",
436
+ "six",
437
+ "seven",
438
+ "eight",
439
+ "nine",
440
+ "ten",
441
+ "eleven",
442
+ "twelve"
443
+ ]
444
+
445
+ const moves = (kind: CertificateEffect["kind"]): boolean => kind.includes("Delegation")
446
+
447
+ /** The one sentence a match gets, above the buttons. */
448
+ export const matchLine = (derived: Derived, rows: ReadonlyArray<Row>): string => {
449
+ const { effects } = derived
450
+ if (isGrouped(rows)) {
451
+ return `Checked against the link. All ${spelled[rows.length] ?? String(rows.length)} effects are described by it, and the transaction does nothing else.`
452
+ }
453
+ const paysNobody = effects.outputs.every((output) => output.mine) && effects.withdrawals.length === 0
454
+ if (paysNobody && effects.certificates.some((certificate) => moves(certificate.kind))) {
455
+ return "Checked against the link. Your ADA stays in your wallet — delegation moves no funds."
456
+ }
457
+ return "Checked against the link. Every effect above is in the transaction, and the transaction does nothing else."
458
+ }
459
+
460
+ /** The opening of the block: what differs, then the reassurance, before any detail. */
461
+ export const blockedLine = (reasons: ReadonlyArray<Reason>): string => {
462
+ const said = reasons.map((reason) => explainReason(reason).summary)
463
+ const what = said.length === 0 ? "The transaction could not be matched to the link." : said.join(" ")
464
+ return `${what} Nothing has been signed.`
465
+ }
466
+
467
+ /** For one reason: the figure the transaction carries, and where it goes or what the link asked instead. */
468
+ export const evidenceOf = (reason: Reason, derived: Derived): Evidence => {
469
+ const { actual, declared, summary, where } = explainReason(reason)
470
+ const marked = ledgerOf(derived, [reason])
471
+ .filter((row) => row.mark !== undefined)
472
+ .flatMap((row) => row.amounts.map((amount) => amount.text))
473
+ const figure = marked.length > 0 ? marked.join(", ") : actual
474
+
475
+ const lines = [
476
+ ...(where === undefined ? [] : [reason.code === "withdrawal.account" ? `from ${where}` : `to ${where}`]),
477
+ ...(declared === undefined ? [] : [`The link asks for ${declared}`])
478
+ ]
479
+ return figure === undefined && lines.length === 0
480
+ ? { lines: [summary] }
481
+ : { ...(figure === undefined ? {} : { figure }), lines }
482
+ }
483
+
484
+ /** The transaction as its outputs, fee and expiry, for the collapsed raw detail. Addresses in full. */
485
+ export const rawLines = (derived: Derived): ReadonlyArray<string> => {
486
+ const { effects } = derived
487
+ const valueText = (coin: bigint, assets: Derived["effects"]["outputs"][number]["value"]["assets"]): string =>
488
+ [
489
+ formatAda(coin),
490
+ ...assets.flatMap((policy) =>
491
+ policy.assets.map((asset) => `${tokenText(asset.quantity, asset.name)} (${policyLine(policy.policyId)})`)
492
+ )
493
+ ].join(" + ")
494
+
495
+ const until = effects.validity.validUntil
496
+ return [
497
+ ...effects.outputs.map(
498
+ (output) =>
499
+ `#${output.index} → ${addressText(output.address)} : ${valueText(output.value.coin, output.value.assets)}${output.mine ? " (change)" : ""}`
500
+ ),
501
+ `fee ${formatAda(effects.fee)}${until === null ? "" : ` · invalid from slot ${until.slot}`}`
502
+ ]
503
+ }