@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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What happens after the person presses sign — `10 · Signing and outcomes`.
|
|
5
|
+
* Every state but the receipt keeps the effects on screen, because waiting is
|
|
6
|
+
* not a modal: a spinner over a hidden ledger asks for trust the panel exists
|
|
7
|
+
* to avoid needing.
|
|
8
|
+
*/
|
|
9
|
+
import type { Network } from "@cardano-slips/core"
|
|
10
|
+
import { useId } from "react"
|
|
11
|
+
|
|
12
|
+
import type { Derived } from "./derived.js"
|
|
13
|
+
import { Ledger } from "./effects-panel.js"
|
|
14
|
+
import { ledgerOf } from "./ledger.js"
|
|
15
|
+
|
|
16
|
+
export type Outcome =
|
|
17
|
+
/** The wallet is open over the page, asked for a signature. */
|
|
18
|
+
| { readonly _tag: "Waiting"; readonly wallet: string }
|
|
19
|
+
/** The person said no in the wallet. Not a failure: the offer stands. */
|
|
20
|
+
| { readonly _tag: "Declined" }
|
|
21
|
+
/** Signed, and on its way to the network. */
|
|
22
|
+
| { readonly _tag: "Submitting"; readonly transactionId: string }
|
|
23
|
+
/** The funds moved; a fresh transaction is being built and will be shown before it is signed. */
|
|
24
|
+
| { readonly _tag: "Rebuilding"; readonly number: number; readonly of: number }
|
|
25
|
+
/** The network refused it. `reason` is the node's own words, shown as they came. */
|
|
26
|
+
| { readonly _tag: "Refused"; readonly reason: string }
|
|
27
|
+
|
|
28
|
+
export type OutcomePanelProps = {
|
|
29
|
+
readonly claim: string
|
|
30
|
+
readonly derived: Derived
|
|
31
|
+
readonly outcome: Outcome
|
|
32
|
+
/** Cancel while waiting or rebuilding; Close once declined or refused. */
|
|
33
|
+
readonly onClose?: () => void
|
|
34
|
+
/** Review and sign again after a decline; start again after a refusal. */
|
|
35
|
+
readonly onAgain?: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Spinner = (): React.JSX.Element => <span className="slip-panel__spin" aria-hidden="true" />
|
|
39
|
+
|
|
40
|
+
const Head = ({
|
|
41
|
+
kicker,
|
|
42
|
+
lead,
|
|
43
|
+
title,
|
|
44
|
+
titleId,
|
|
45
|
+
tone
|
|
46
|
+
}: {
|
|
47
|
+
readonly kicker: string
|
|
48
|
+
readonly title: string
|
|
49
|
+
readonly titleId: string
|
|
50
|
+
readonly lead?: string
|
|
51
|
+
readonly tone?: "bad" | "warn"
|
|
52
|
+
}): React.JSX.Element => (
|
|
53
|
+
<div className="slip-panel__claim">
|
|
54
|
+
<p className="slip-panel__kicker" data-tone={tone}>
|
|
55
|
+
{kicker}
|
|
56
|
+
</p>
|
|
57
|
+
<h2 className="slip-panel__title" id={titleId}>
|
|
58
|
+
{title}
|
|
59
|
+
</h2>
|
|
60
|
+
{lead === undefined ? undefined : <p className="slip-panel__lead">{lead}</p>}
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
export const OutcomePanel = ({ claim, derived, onAgain, onClose, outcome }: OutcomePanelProps): React.JSX.Element => {
|
|
65
|
+
const title = useId()
|
|
66
|
+
const rows = ledgerOf(derived)
|
|
67
|
+
|
|
68
|
+
switch (outcome._tag) {
|
|
69
|
+
case "Waiting":
|
|
70
|
+
return (
|
|
71
|
+
<section className="slip-root slip-panel" data-outcome="waiting" aria-labelledby={title} aria-busy="true">
|
|
72
|
+
<Head kicker="What you're signing" title={claim} titleId={title} />
|
|
73
|
+
<Ledger rows={rows} />
|
|
74
|
+
<div className="slip-panel__wallet" role="status">
|
|
75
|
+
<Spinner />
|
|
76
|
+
<div className="slip-panel__wallet-words">
|
|
77
|
+
<span>Approve in your wallet</span>
|
|
78
|
+
<span className="slip-panel__note">{outcome.wallet} is open. Nothing is sent until you do.</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<button type="button" className="slip-panel__button" data-kind="cancel" onClick={onClose}>
|
|
82
|
+
Cancel
|
|
83
|
+
</button>
|
|
84
|
+
</section>
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
case "Declined":
|
|
88
|
+
return (
|
|
89
|
+
<section className="slip-root slip-panel" data-outcome="declined" aria-labelledby={title}>
|
|
90
|
+
<Head
|
|
91
|
+
kicker="Not signed"
|
|
92
|
+
title="You declined in your wallet"
|
|
93
|
+
titleId={title}
|
|
94
|
+
lead="Nothing was sent and nothing left your wallet. The link still works if you want to look again."
|
|
95
|
+
/>
|
|
96
|
+
<div className="slip-panel__stale">
|
|
97
|
+
<Ledger rows={rows} />
|
|
98
|
+
</div>
|
|
99
|
+
<div className="slip-panel__buttons">
|
|
100
|
+
<button type="button" className="slip-panel__button" data-kind="close" onClick={onClose}>
|
|
101
|
+
Close
|
|
102
|
+
</button>
|
|
103
|
+
<button type="button" className="slip-panel__button" data-kind="primary" onClick={onAgain}>
|
|
104
|
+
Review and sign again
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</section>
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
case "Submitting":
|
|
111
|
+
return (
|
|
112
|
+
<section className="slip-root slip-panel" data-outcome="submitting" aria-labelledby={title} aria-busy="true">
|
|
113
|
+
<Head kicker="Submitting" title="Sending your signed transaction" titleId={title} />
|
|
114
|
+
<ol className="slip-panel__steps">
|
|
115
|
+
<li className="slip-panel__step">
|
|
116
|
+
<span className="slip-panel__tick" aria-hidden="true">
|
|
117
|
+
✓
|
|
118
|
+
</span>
|
|
119
|
+
<span>Signature checked against the transaction you read</span>
|
|
120
|
+
</li>
|
|
121
|
+
<li className="slip-panel__step" role="status">
|
|
122
|
+
<Spinner />
|
|
123
|
+
<span>Handing it to the network</span>
|
|
124
|
+
</li>
|
|
125
|
+
</ol>
|
|
126
|
+
{/* The id is what a person can carry away if the page dies mid-submit, so it is here in full. */}
|
|
127
|
+
<div className="slip-panel__transaction">
|
|
128
|
+
<span className="slip-panel__note">Transaction</span>
|
|
129
|
+
<span className="slip-panel__transaction-id">{outcome.transactionId}</span>
|
|
130
|
+
</div>
|
|
131
|
+
</section>
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
case "Rebuilding":
|
|
135
|
+
return (
|
|
136
|
+
<section className="slip-root slip-panel" data-outcome="rebuilding" aria-labelledby={title} aria-busy="true">
|
|
137
|
+
<Head
|
|
138
|
+
kicker="Rebuilding"
|
|
139
|
+
tone="warn"
|
|
140
|
+
title="Your funds moved while you were signing"
|
|
141
|
+
titleId={title}
|
|
142
|
+
lead="Something else spent the outputs this transaction used. Nothing was signed twice and nothing left your wallet."
|
|
143
|
+
/>
|
|
144
|
+
<div className="slip-panel__wallet" role="status">
|
|
145
|
+
<Spinner />
|
|
146
|
+
<div className="slip-panel__wallet-words">
|
|
147
|
+
<span>Building a fresh transaction</span>
|
|
148
|
+
<span className="slip-panel__note">
|
|
149
|
+
Attempt {outcome.number} of {outcome.of}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<p className="slip-panel__note">
|
|
154
|
+
It will have different amounts and a different id, so you'll see its effects checked before your wallet is
|
|
155
|
+
asked again.
|
|
156
|
+
</p>
|
|
157
|
+
<div className="slip-panel__stale">
|
|
158
|
+
<Ledger rows={rows} />
|
|
159
|
+
</div>
|
|
160
|
+
<button type="button" className="slip-panel__button" data-kind="cancel" onClick={onClose}>
|
|
161
|
+
Cancel
|
|
162
|
+
</button>
|
|
163
|
+
</section>
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
case "Refused":
|
|
167
|
+
return (
|
|
168
|
+
<section className="slip-root slip-panel" data-outcome="refused" aria-labelledby={title}>
|
|
169
|
+
<Head
|
|
170
|
+
kicker="Not submitted"
|
|
171
|
+
tone="bad"
|
|
172
|
+
title="The network wouldn't accept this transaction"
|
|
173
|
+
titleId={title}
|
|
174
|
+
lead="You signed it, but it never took effect and nothing left your wallet. Your signature cannot be reused."
|
|
175
|
+
/>
|
|
176
|
+
<pre className="slip-panel__reason">{outcome.reason}</pre>
|
|
177
|
+
<div className="slip-panel__buttons">
|
|
178
|
+
<button type="button" className="slip-panel__button" data-kind="close" onClick={onClose}>
|
|
179
|
+
Close
|
|
180
|
+
</button>
|
|
181
|
+
<button type="button" className="slip-panel__button" data-kind="primary" onClick={onAgain}>
|
|
182
|
+
Start again
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
</section>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type SlipReceiptProps = {
|
|
191
|
+
readonly claim: string
|
|
192
|
+
readonly derived: Derived
|
|
193
|
+
readonly transactionId: string
|
|
194
|
+
readonly network: Network
|
|
195
|
+
/** Where this transaction can be looked up: the only durable copy anyone keeps. */
|
|
196
|
+
readonly explorer: { readonly name: string; readonly url: string }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A transaction id is hex with no separator, so it is shortened from both ends. */
|
|
200
|
+
const shortId = (id: string): string => (id.length <= 12 ? id : `${id.slice(0, 4)}…${id.slice(-4)}`)
|
|
201
|
+
|
|
202
|
+
/** One spelling for every surface that names a network, so the receipt and the top bar never disagree. */
|
|
203
|
+
export const networkNames: Readonly<Record<Network, string>> = {
|
|
204
|
+
mainnet: "Mainnet",
|
|
205
|
+
preprod: "Preprod",
|
|
206
|
+
preview: "Preview"
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `10 · d`, back on the card surface. It says sent rather than confirmed: the
|
|
211
|
+
* node accepted the transaction, and nothing here watches the chain for it.
|
|
212
|
+
*/
|
|
213
|
+
export const SlipReceipt = ({
|
|
214
|
+
claim,
|
|
215
|
+
derived,
|
|
216
|
+
explorer,
|
|
217
|
+
network,
|
|
218
|
+
transactionId
|
|
219
|
+
}: SlipReceiptProps): React.JSX.Element => {
|
|
220
|
+
const title = useId()
|
|
221
|
+
return (
|
|
222
|
+
<section className="slip-root slip-receipt" aria-labelledby={title}>
|
|
223
|
+
<div className="slip-receipt__head">
|
|
224
|
+
<span className="slip-receipt__tick" aria-hidden="true">
|
|
225
|
+
✓
|
|
226
|
+
</span>
|
|
227
|
+
<div className="slip-receipt__identity">
|
|
228
|
+
<h2 className="slip-receipt__title" id={title}>
|
|
229
|
+
{claim}
|
|
230
|
+
</h2>
|
|
231
|
+
<span className="slip-receipt__note">Sent to {networkNames[network]}</span>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div className="slip-receipt__ledger">
|
|
235
|
+
{ledgerOf(derived).map((row) => (
|
|
236
|
+
<div key={row.key} className="slip-receipt__row">
|
|
237
|
+
<span className="slip-receipt__label">{row.label}</span>
|
|
238
|
+
<span className="slip-receipt__amounts">
|
|
239
|
+
{row.identifier ?? undefined}
|
|
240
|
+
{row.amounts.map((amount, index) => (
|
|
241
|
+
<span key={index} className="slip-receipt__amount" data-tone={amount.tone}>
|
|
242
|
+
{amount.text}
|
|
243
|
+
</span>
|
|
244
|
+
))}
|
|
245
|
+
</span>
|
|
246
|
+
</div>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
<div className="slip-receipt__transaction">
|
|
250
|
+
<span className="slip-receipt__kicker">Transaction</span>
|
|
251
|
+
<span className="slip-receipt__id" title={transactionId}>
|
|
252
|
+
{shortId(transactionId)}
|
|
253
|
+
</span>
|
|
254
|
+
<a className="slip-receipt__link" href={explorer.url} target="_blank" rel="noreferrer">
|
|
255
|
+
View on {explorer.name}
|
|
256
|
+
</a>
|
|
257
|
+
</div>
|
|
258
|
+
<p className="slip-receipt__note slip-receipt__foot">
|
|
259
|
+
No account was created and nothing was stored. This page keeps none of it.
|
|
260
|
+
</p>
|
|
261
|
+
</section>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The form a linked action's `parameters` describe. It holds no state: what a
|
|
5
|
+
* person typed, and which errors are ripe to show, belong to the card.
|
|
6
|
+
*/
|
|
7
|
+
import type { Parameter, ParameterValues } from "@cardano-slips/core"
|
|
8
|
+
|
|
9
|
+
import { valueOf } from "./card.js"
|
|
10
|
+
|
|
11
|
+
/** A sentence about one field. `code` is the endpoint's, and is for us rather than for the person. */
|
|
12
|
+
export type FieldError = {
|
|
13
|
+
readonly message: string
|
|
14
|
+
readonly code?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ParameterFormProps = {
|
|
18
|
+
readonly parameters: ReadonlyArray<Parameter>
|
|
19
|
+
readonly values: ParameterValues
|
|
20
|
+
/** By parameter name. What is here is shown; deciding when is the card's job. */
|
|
21
|
+
readonly errors?: Readonly<Record<string, FieldError>>
|
|
22
|
+
readonly onChange: (name: string, value: string) => void
|
|
23
|
+
/** Prefixes every generated id, so two cards on one page do not collide. */
|
|
24
|
+
readonly idPrefix?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The bounds line. The spec requires it beside the field rather than only on
|
|
29
|
+
* failure, so a person can tell what will be accepted before they type.
|
|
30
|
+
*/
|
|
31
|
+
export const boundsOf = (parameter: Parameter): string | undefined => {
|
|
32
|
+
if (parameter.type === "select") {
|
|
33
|
+
const labels = parameter.options.map((option) => option.label)
|
|
34
|
+
if (labels.length > 3) return `${labels.length} options`
|
|
35
|
+
if (labels.length === 1) return labels[0]
|
|
36
|
+
return `${labels.slice(0, -1).join(", ")} or ${labels.at(-1)}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// On `text` the bounds count characters; on `number` they bound the value.
|
|
40
|
+
const unit = parameter.type === "text" ? " characters" : ""
|
|
41
|
+
if (parameter.min !== undefined && parameter.max !== undefined) {
|
|
42
|
+
return `Between ${parameter.min} and ${parameter.max}${unit}`
|
|
43
|
+
}
|
|
44
|
+
// The same words `checkValues` uses, so the bounds and the sentence a broken
|
|
45
|
+
// value produces read as one voice rather than two.
|
|
46
|
+
if (parameter.min !== undefined) return `At least ${parameter.min}${unit}`
|
|
47
|
+
if (parameter.max !== undefined) return `At most ${parameter.max}${unit}`
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const errorOf = (errors: Readonly<Record<string, FieldError>> | undefined, name: string): FieldError | undefined =>
|
|
52
|
+
errors !== undefined && Object.hasOwn(errors, name) ? errors[name] : undefined
|
|
53
|
+
|
|
54
|
+
type FieldProps = {
|
|
55
|
+
readonly parameter: Parameter
|
|
56
|
+
readonly value: string
|
|
57
|
+
readonly error: FieldError | undefined
|
|
58
|
+
readonly id: string
|
|
59
|
+
readonly onChange: (name: string, value: string) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const Field = ({ parameter, value, error, id, onChange }: FieldProps): React.JSX.Element => {
|
|
63
|
+
const helpId = `${id}-help`
|
|
64
|
+
const invalid = error === undefined ? undefined : ""
|
|
65
|
+
const change = (next: string): void => onChange(parameter.name, next)
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="slip-field" data-invalid={invalid}>
|
|
69
|
+
<label className="slip-field__label" htmlFor={id}>
|
|
70
|
+
{parameter.label}
|
|
71
|
+
</label>
|
|
72
|
+
<div className="slip-field__control">
|
|
73
|
+
{parameter.type === "select" ? (
|
|
74
|
+
<select
|
|
75
|
+
id={id}
|
|
76
|
+
className="slip-field__input"
|
|
77
|
+
value={value}
|
|
78
|
+
aria-describedby={helpId}
|
|
79
|
+
aria-invalid={error === undefined ? undefined : true}
|
|
80
|
+
onChange={(event) => change(event.target.value)}
|
|
81
|
+
>
|
|
82
|
+
{parameter.required === true && value !== "" ? undefined : <option value="" />}
|
|
83
|
+
{parameter.options.map((option) => (
|
|
84
|
+
<option key={option.value} value={option.value}>
|
|
85
|
+
{option.label}
|
|
86
|
+
</option>
|
|
87
|
+
))}
|
|
88
|
+
</select>
|
|
89
|
+
) : (
|
|
90
|
+
<input
|
|
91
|
+
id={id}
|
|
92
|
+
className={
|
|
93
|
+
parameter.type === "number" ? "slip-field__input slip-field__input--numeral" : "slip-field__input"
|
|
94
|
+
}
|
|
95
|
+
type="text"
|
|
96
|
+
// Never `type="number"`: it hands back an empty string for anything
|
|
97
|
+
// the browser dislikes, which hides the very value `checkValues` is
|
|
98
|
+
// there to judge — and the person never learns what was wrong.
|
|
99
|
+
inputMode={parameter.type === "number" ? "decimal" : undefined}
|
|
100
|
+
value={value}
|
|
101
|
+
aria-describedby={helpId}
|
|
102
|
+
aria-invalid={error === undefined ? undefined : true}
|
|
103
|
+
onChange={(event) => change(event.target.value)}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
<div className="slip-field__help" id={helpId}>
|
|
108
|
+
<span className="slip-field__bounds">{error?.message ?? boundsOf(parameter)}</span>
|
|
109
|
+
{error?.code === undefined ? (
|
|
110
|
+
<span className="slip-field__note">{parameter.required === true ? "Required" : "Optional"}</span>
|
|
111
|
+
) : (
|
|
112
|
+
<span className="slip-field__code">{error.code}</span>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const ParameterForm = ({
|
|
120
|
+
parameters,
|
|
121
|
+
values,
|
|
122
|
+
errors,
|
|
123
|
+
onChange,
|
|
124
|
+
idPrefix = "slip"
|
|
125
|
+
}: ParameterFormProps): React.JSX.Element => (
|
|
126
|
+
<div className="slip-fields">
|
|
127
|
+
{parameters.map((parameter) => (
|
|
128
|
+
<Field
|
|
129
|
+
key={parameter.name}
|
|
130
|
+
parameter={parameter}
|
|
131
|
+
value={valueOf(values, parameter.name)}
|
|
132
|
+
error={errorOf(errors, parameter.name)}
|
|
133
|
+
id={`${idPrefix}-${parameter.name}`}
|
|
134
|
+
onChange={onChange}
|
|
135
|
+
/>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
)
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wallet's own unspent outputs, in the form the verifier's derivation takes
|
|
3
|
+
* them. The engine is handed the value of every input the body spends because
|
|
4
|
+
* it never looks one up (invariant 1) — this is where the client, which does
|
|
5
|
+
* know, says so.
|
|
6
|
+
*/
|
|
7
|
+
import type { MultiAsset, ResolvedInput } from "@cardano-slips/verifier"
|
|
8
|
+
import {
|
|
9
|
+
Address,
|
|
10
|
+
AssetName,
|
|
11
|
+
Assets,
|
|
12
|
+
MultiAsset as Multi,
|
|
13
|
+
PolicyId,
|
|
14
|
+
Schema,
|
|
15
|
+
TransactionHash
|
|
16
|
+
} from "@evolution-sdk/evolution"
|
|
17
|
+
import type { UTxO } from "@evolution-sdk/evolution"
|
|
18
|
+
|
|
19
|
+
const addressBytes = Schema.encodeSync(Address.FromBytes)
|
|
20
|
+
|
|
21
|
+
const multiAssetOf = (assets: Assets.Assets): MultiAsset => {
|
|
22
|
+
const held = Assets.getMultiAsset(assets)
|
|
23
|
+
if (held === undefined) return []
|
|
24
|
+
return Multi.getPolicyIds(held).map((policyId) => ({
|
|
25
|
+
policyId: PolicyId.toBytes(policyId),
|
|
26
|
+
assets: Multi.getAssetsByPolicy(held, policyId).map(([name, quantity]) => ({
|
|
27
|
+
name: AssetName.toBytes(name),
|
|
28
|
+
quantity
|
|
29
|
+
}))
|
|
30
|
+
}))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const asResolvedInput = (utxo: UTxO.UTxO): ResolvedInput => ({
|
|
34
|
+
input: { transactionId: TransactionHash.toBytes(utxo.transactionId), index: utxo.index },
|
|
35
|
+
address: addressBytes(utxo.address),
|
|
36
|
+
value: { coin: Assets.lovelaceOf(utxo.assets), assets: multiAssetOf(utxo.assets) }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const asResolvedInputs = (utxos: ReadonlyArray<UTxO.UTxO>): ReadonlyArray<ResolvedInput> =>
|
|
40
|
+
utxos.map(asResolvedInput)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a transaction was not signed, or not submitted. One error, a closed set
|
|
3
|
+
* of refusals, because the client renders a state per refusal and a state
|
|
4
|
+
* nothing can produce is a screen nobody sees.
|
|
5
|
+
*/
|
|
6
|
+
import { type ClientErrorCode } from "@cardano-slips/core"
|
|
7
|
+
import { Data } from "effect"
|
|
8
|
+
|
|
9
|
+
import { type Cip30ApiError } from "./cip30.js"
|
|
10
|
+
|
|
11
|
+
export type SignRefusal =
|
|
12
|
+
/** The bytes we hold are not a transaction these bytes can be read as. */
|
|
13
|
+
| "UnreadableTransaction"
|
|
14
|
+
/** This is not the body the effects were derived from, so nothing may be signed into it. */
|
|
15
|
+
| "WrongBody"
|
|
16
|
+
/** The person said no in the wallet. Not a fault. */
|
|
17
|
+
| "Declined"
|
|
18
|
+
/** The wallet tried to sign and could not, or rejected for a reason of its own. */
|
|
19
|
+
| "SignFailed"
|
|
20
|
+
/** What came back is not a witness set — a whole transaction included. */
|
|
21
|
+
| "UnreadableWitnesses"
|
|
22
|
+
/** The set carries script or datum material the body was never judged with. */
|
|
23
|
+
| "UnexpectedWitnessMaterial"
|
|
24
|
+
/** The set carries no signature, so assembling it would leave the transaction unsigned. */
|
|
25
|
+
| "NoWitnesses"
|
|
26
|
+
/** Submission failed because an input was spent between building and signing. */
|
|
27
|
+
| "InputsSpent"
|
|
28
|
+
/** The validity interval passed before the transaction reached the chain. */
|
|
29
|
+
| "IntervalPassed"
|
|
30
|
+
/** Submission failed for any other reason. */
|
|
31
|
+
| "SubmitFailed"
|
|
32
|
+
/** The wallet submitted, and named a transaction other than the one we assembled. */
|
|
33
|
+
| "WrongTransactionId"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Exhaustive by type: adding a refusal above without listing it here fails to
|
|
37
|
+
* compile, and `test/sign.test.ts` fails if any of them stops being reachable.
|
|
38
|
+
*/
|
|
39
|
+
export const signRefusals: Readonly<Record<SignRefusal, true>> = {
|
|
40
|
+
UnreadableTransaction: true,
|
|
41
|
+
WrongBody: true,
|
|
42
|
+
Declined: true,
|
|
43
|
+
SignFailed: true,
|
|
44
|
+
UnreadableWitnesses: true,
|
|
45
|
+
UnexpectedWitnessMaterial: true,
|
|
46
|
+
NoWitnesses: true,
|
|
47
|
+
InputsSpent: true,
|
|
48
|
+
IntervalPassed: true,
|
|
49
|
+
SubmitFailed: true,
|
|
50
|
+
WrongTransactionId: true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Only the passed interval has a spec code, because only it is a failure of the
|
|
55
|
+
* exchange the spec describes — the intent outlived its own `validUntil`. The
|
|
56
|
+
* rest are states of a wallet or of the chain, and a protocol code on one of
|
|
57
|
+
* those would say the endpoint answered when nothing was ever asked of it
|
|
58
|
+
* (`wallet-error.ts` draws the same line).
|
|
59
|
+
*/
|
|
60
|
+
export const slipErrorCodeFor = (refusal: SignRefusal): ClientErrorCode | undefined =>
|
|
61
|
+
refusal === "IntervalPassed" ? "INTENT_EXPIRED" : undefined
|
|
62
|
+
|
|
63
|
+
/** `cip30` is the wallet's own `{ code, info }`, kept so a decline stays distinguishable from a crash. */
|
|
64
|
+
export class SigningError extends Data.TaggedError("SigningError")<{
|
|
65
|
+
readonly refusal: SignRefusal
|
|
66
|
+
readonly detail: string
|
|
67
|
+
readonly cip30?: Cip30ApiError
|
|
68
|
+
readonly cause?: unknown
|
|
69
|
+
}> {
|
|
70
|
+
override get message(): string {
|
|
71
|
+
return this.detail
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get code(): ClientErrorCode | undefined {
|
|
75
|
+
return slipErrorCodeFor(this.refusal)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const refuse = (
|
|
80
|
+
refusal: SignRefusal,
|
|
81
|
+
detail: string,
|
|
82
|
+
extra: { readonly cip30?: Cip30ApiError; readonly cause?: unknown } = {}
|
|
83
|
+
): SigningError =>
|
|
84
|
+
new SigningError({
|
|
85
|
+
refusal,
|
|
86
|
+
detail,
|
|
87
|
+
...(extra.cip30 === undefined ? {} : { cip30: extra.cip30 }),
|
|
88
|
+
...(extra.cause === undefined ? {} : { cause: extra.cause })
|
|
89
|
+
})
|
package/src/sign.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `signTx` → witness assembly → `submitTx`. CIP-30 returns a **witness set**,
|
|
3
|
+
* never a signed transaction (invariant 4), so the body a person was shown and
|
|
4
|
+
* the body that reaches the chain are joined here or nowhere.
|
|
5
|
+
*/
|
|
6
|
+
import { Effect } from "effect"
|
|
7
|
+
|
|
8
|
+
import { type CborHex, type Cip30Api, readApiError, REFUSED, USER_DECLINED } from "./cip30.js"
|
|
9
|
+
import { refuse, type SigningError } from "./sign-error.js"
|
|
10
|
+
import { assembleWitnesses, requireSameBody } from "./witness.js"
|
|
11
|
+
|
|
12
|
+
export type SignRequest = {
|
|
13
|
+
readonly api: Cip30Api
|
|
14
|
+
/** The complete unsigned transaction. */
|
|
15
|
+
readonly transaction: CborHex
|
|
16
|
+
/**
|
|
17
|
+
* The id of the body the effects were derived from, from `transactionIdOf`.
|
|
18
|
+
* Required, not derived here: a signature is bound to the body a person was
|
|
19
|
+
* shown, and a function that recomputed the id from the bytes in front of it
|
|
20
|
+
* would bind it to whatever it was handed.
|
|
21
|
+
*/
|
|
22
|
+
readonly transactionId: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SignedTransaction = {
|
|
26
|
+
/** The body with the witnesses in it: what `submitTx` takes. */
|
|
27
|
+
readonly cbor: CborHex
|
|
28
|
+
/** Unchanged by signing, because witnesses live outside the body. */
|
|
29
|
+
readonly transactionId: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What the wallet said, kept whole so a message names its own cause. */
|
|
33
|
+
const said = (cause: unknown): string => {
|
|
34
|
+
const error = readApiError(cause)
|
|
35
|
+
if (error === undefined) return String(cause)
|
|
36
|
+
return error.info === "" ? `code ${error.code}` : error.info
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Signs, or says why not. Nothing is asked of the wallet until the transaction
|
|
41
|
+
* is known to be the one the effects were derived from.
|
|
42
|
+
*/
|
|
43
|
+
export const signTransaction = ({
|
|
44
|
+
api,
|
|
45
|
+
transaction,
|
|
46
|
+
transactionId
|
|
47
|
+
}: SignRequest): Effect.Effect<SignedTransaction, SigningError> =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
yield* requireSameBody(transaction, transactionId)
|
|
50
|
+
|
|
51
|
+
const witnesses = yield* Effect.tryPromise({
|
|
52
|
+
// `partialSign: true`: the wallet signs what it holds keys for and leaves
|
|
53
|
+
// the rest, rather than refusing a transaction a co-signer completes.
|
|
54
|
+
try: () => api.signTx(transaction, true),
|
|
55
|
+
catch: (cause) => {
|
|
56
|
+
const error = readApiError(cause)
|
|
57
|
+
// `-3` is `APIError`'s refusal, which wallets send from `signTx` in
|
|
58
|
+
// practice even though CIP-30 numbers a decline `2` here.
|
|
59
|
+
if (error?.code === USER_DECLINED || error?.code === REFUSED) {
|
|
60
|
+
return refuse("Declined", "The signature was declined in the wallet.", { cip30: error, cause })
|
|
61
|
+
}
|
|
62
|
+
return refuse("SignFailed", `The wallet could not sign this transaction: ${said(cause)}`, {
|
|
63
|
+
...(error === undefined ? {} : { cip30: error }),
|
|
64
|
+
cause
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const cbor = yield* assembleWitnesses(transaction, witnesses, transactionId)
|
|
70
|
+
return { cbor, transactionId }
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/** The ledger's own name for an input that is no longer there — what the rebuild path watches for. */
|
|
74
|
+
const spentInputs = /BadInputsUTxO/i
|
|
75
|
+
|
|
76
|
+
/** The ledger's name for a transaction that arrived outside its validity interval. */
|
|
77
|
+
const outsideInterval = /OutsideValidityInterval/i
|
|
78
|
+
|
|
79
|
+
const submissionFailure = (cause: unknown): SigningError => {
|
|
80
|
+
const error = readApiError(cause)
|
|
81
|
+
const detail = said(cause)
|
|
82
|
+
const extra = { ...(error === undefined ? {} : { cip30: error }), cause }
|
|
83
|
+
|
|
84
|
+
if (spentInputs.test(detail)) {
|
|
85
|
+
return refuse("InputsSpent", "The wallet's funds moved before this transaction reached the chain.", extra)
|
|
86
|
+
}
|
|
87
|
+
if (outsideInterval.test(detail)) {
|
|
88
|
+
return refuse("IntervalPassed", "This transaction expired before it reached the chain.", extra)
|
|
89
|
+
}
|
|
90
|
+
return refuse("SubmitFailed", `This transaction was not accepted: ${detail}`, extra)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Submits, and holds the wallet to the transaction it was given: an id other
|
|
95
|
+
* than the one we assembled is a receipt for something else, and showing it
|
|
96
|
+
* would tell a person their transaction is on the chain when another one is.
|
|
97
|
+
*/
|
|
98
|
+
export const submitTransaction = (api: Cip30Api, signed: SignedTransaction): Effect.Effect<string, SigningError> =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const returned = yield* Effect.tryPromise({
|
|
101
|
+
try: () => api.submitTx(signed.cbor),
|
|
102
|
+
catch: submissionFailure
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Typed to answer with the id and under no obligation to: reading a method
|
|
106
|
+
// off whatever it did answer would reach a person as a stack trace.
|
|
107
|
+
if (typeof returned !== "string") {
|
|
108
|
+
return yield* Effect.fail(refuse("WrongTransactionId", "The wallet did not name the transaction it submitted."))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (returned.toLowerCase() !== signed.transactionId.toLowerCase()) {
|
|
112
|
+
return yield* Effect.fail(
|
|
113
|
+
refuse("WrongTransactionId", "The wallet submitted a different transaction from the one it was given.")
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return signed.transactionId
|
|
118
|
+
})
|