@cardano-slips/flow 0.1.0 → 0.2.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 (58) hide show
  1. package/dist/balance-error.d.ts +48 -0
  2. package/dist/balance-error.d.ts.map +1 -0
  3. package/dist/balance-error.js +45 -0
  4. package/dist/balance-error.js.map +1 -0
  5. package/dist/balance.d.ts +54 -0
  6. package/dist/balance.d.ts.map +1 -0
  7. package/dist/balance.js +175 -0
  8. package/dist/balance.js.map +1 -0
  9. package/dist/cip30.d.ts +10 -0
  10. package/dist/cip30.d.ts.map +1 -1
  11. package/dist/cip30.js +16 -0
  12. package/dist/cip30.js.map +1 -1
  13. package/dist/complete-error.d.ts +50 -0
  14. package/dist/complete-error.d.ts.map +1 -0
  15. package/dist/complete-error.js +47 -0
  16. package/dist/complete-error.js.map +1 -0
  17. package/dist/complete.d.ts +58 -0
  18. package/dist/complete.d.ts.map +1 -0
  19. package/dist/complete.js +108 -0
  20. package/dist/complete.js.map +1 -0
  21. package/dist/index.d.ts +16 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +10 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/resolve.d.ts +11 -0
  26. package/dist/resolve.d.ts.map +1 -0
  27. package/dist/resolve.js +21 -0
  28. package/dist/resolve.js.map +1 -0
  29. package/dist/sign-error.d.ts +62 -0
  30. package/dist/sign-error.d.ts.map +1 -0
  31. package/dist/sign-error.js +42 -0
  32. package/dist/sign-error.js.map +1 -0
  33. package/dist/sign.d.ts +38 -0
  34. package/dist/sign.d.ts.map +1 -0
  35. package/dist/sign.js +79 -0
  36. package/dist/sign.js.map +1 -0
  37. package/dist/tokens.css +48 -99
  38. package/dist/utxo.d.ts +16 -0
  39. package/dist/utxo.d.ts.map +1 -0
  40. package/dist/utxo.js +51 -0
  41. package/dist/utxo.js.map +1 -0
  42. package/dist/witness.d.ts +23 -0
  43. package/dist/witness.d.ts.map +1 -0
  44. package/dist/witness.js +101 -0
  45. package/dist/witness.js.map +1 -0
  46. package/package.json +7 -4
  47. package/src/balance-error.ts +77 -0
  48. package/src/balance.ts +285 -0
  49. package/src/cip30.ts +19 -0
  50. package/src/complete-error.ts +83 -0
  51. package/src/complete.ts +198 -0
  52. package/src/index.ts +26 -1
  53. package/src/resolve.ts +40 -0
  54. package/src/sign-error.ts +89 -0
  55. package/src/sign.ts +118 -0
  56. package/src/tokens.css +48 -99
  57. package/src/utxo.ts +70 -0
  58. package/src/witness.ts +132 -0
@@ -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
+ })
package/src/tokens.css CHANGED
@@ -2,8 +2,8 @@
2
2
  * Every colour, type role, step of spacing and radius the client uses, taken
3
3
  * from `1 · Tokens` on the design sheet. This is the only file in the package
4
4
  * that writes a colour — `test/no-hard-coded-colours.test.ts` fails if another
5
- * one does, and `test/tokens.test.ts` re-runs the sheet's contrast audit
6
- * against these values.
5
+ * one does, and `test/tokens.test.ts` re-runs the contrast audit against these
6
+ * values rather than trusting the numbers printed on the sheet.
7
7
  *
8
8
  * Scoped to a class, never `:root`: this ships into pages we do not own, and a
9
9
  * package that writes tokens onto the document redefines whatever the host
@@ -13,33 +13,38 @@
13
13
  * font-family on anything — a component reads `var(--font-text)` — so a host
14
14
  * page's own stack stays where it is until something of ours asks for a token.
15
15
  *
16
- * Three families of surface, and the difference is the point:
16
+ * One theme. The sheet collapsed an earlier light/dark pair into a single
17
+ * ground that leans light, so there is no `data-theme` and no role that means
18
+ * two things. What carries the weight instead is that the three surface
19
+ * families are genuinely different surfaces:
20
+ *
17
21
  * page / card the ordinary ground, and what lifts off it
18
- * vault-* the transaction preview, which separates itself by surface
19
- * and border rather than by being dark
20
- * dark-* code blocks and the Open Graph card, dark in both themes
22
+ * vault-* the transaction preview, on slate — the one grave surface,
23
+ * and the reason the rest can stay bright
24
+ * dark-* code blocks and the Open Graph card, fixed wherever they
25
+ * appear because neither sits on a ground we control
21
26
  */
22
27
 
23
28
  .slip-root {
24
29
  color-scheme: light;
25
30
 
26
31
  /* Surfaces. Depth is a border, never a shadow: there is no shadow token. */
27
- --page: #f6f8fe; /* never pure white */
28
- --card: #ffffff;
29
- --subtle: rgba(114, 142, 243, 0.08); /* hover, chips */
30
- --skeleton: rgba(114, 142, 243, 0.14);
31
- --border: rgba(18, 23, 33, 0.12);
32
- --divider: rgba(18, 23, 33, 0.1);
32
+ --page: #ebeef8; /* never pure white */
33
+ --card: #fcfdff;
34
+ --subtle: rgba(90, 110, 205, 0.09); /* hover, chips */
35
+ --skeleton: rgba(90, 110, 205, 0.14);
36
+ --border: rgba(20, 26, 40, 0.13);
37
+ --divider: rgba(20, 26, 40, 0.1);
33
38
 
34
39
  /* Ink */
35
- --ink: #121721;
36
- --muted: #5c6a9c;
40
+ --ink: #141a26;
41
+ --muted: #56628a;
37
42
 
38
43
  /*
39
- * The split rule: --accent-fill reaches 3.05:1 on the card and is a fill —
40
- * it never carries text below 24px. An accent-coloured word uses
41
- * --accent-text. It stays a fill in the dark theme too, where it would
42
- * otherwise clear AA, because the rule is about what the colour is for.
44
+ * The split rule: --accent-fill is a fill and never carries text below 24px.
45
+ * An accent-coloured word uses --accent-text. It clears AA on the vault and
46
+ * is still held to fills there, because the rule is about what the colour is
47
+ * for and not about the ratio it happens to reach.
43
48
  */
44
49
  --accent-fill: #728ef3;
45
50
  --accent-text: #2f4bc4; /* links */
@@ -59,36 +64,37 @@
59
64
  --warn-line: rgba(233, 180, 76, 0.5);
60
65
 
61
66
  /* The alternating rows of the effects list, on and off. */
62
- --stripe-a: rgba(114, 142, 243, 0.16);
63
- --stripe-b: rgba(114, 142, 243, 0.05);
64
- --stripe-off-a: rgba(92, 106, 156, 0.12);
65
- --stripe-off-b: rgba(92, 106, 156, 0.04);
67
+ --stripe-a: rgba(90, 110, 205, 0.16);
68
+ --stripe-b: rgba(90, 110, 205, 0.05);
69
+ --stripe-off-a: rgba(86, 98, 138, 0.12);
70
+ --stripe-off-b: rgba(86, 98, 138, 0.04);
66
71
 
67
72
  /*
68
73
  * The transaction preview. Its own family because it is the surface that
69
- * judges the publisher, and `10 · Chrome rules` fixes it whoever hosts the
70
- * page. It follows the theme like everything else.
74
+ * judges the publisher, and `13 · Chrome rules` fixes it whoever hosts the
75
+ * page. Slate rather than black: it has to read as a distinct surface, not
76
+ * as a hole in the page.
71
77
  */
72
- --vault: #ffffff;
73
- --vault-ink: #121721;
74
- --vault-muted: #5c6a9c;
75
- --vault-surface: rgba(114, 142, 243, 0.08);
76
- --vault-border: rgba(18, 23, 33, 0.12);
77
- --vault-chip: rgba(114, 142, 243, 0.16);
78
- --vault-rule: rgba(114, 142, 243, 0.8);
79
- --vault-pos: #1f7a5c;
80
- --vault-warn: #8c5a12;
81
- --vault-bad: #b3261e;
82
- --vault-bad-tint: rgba(179, 38, 30, 0.06);
83
- --vault-bad-line: rgba(179, 38, 30, 0.32);
84
- --vault-bad-chip: rgba(179, 38, 30, 0.1);
85
- --vault-warn-tint: rgba(233, 180, 76, 0.14);
86
- --vault-warn-line: rgba(233, 180, 76, 0.55);
78
+ --vault: #222b3d;
79
+ --vault-ink: #f5f7ff;
80
+ --vault-muted: #c3cdec;
81
+ --vault-surface: rgba(255, 255, 255, 0.09);
82
+ --vault-border: rgba(255, 255, 255, 0.16);
83
+ --vault-chip: rgba(168, 188, 255, 0.22);
84
+ --vault-rule: rgba(168, 188, 255, 0.7);
85
+ --vault-pos: #5fd1a6;
86
+ --vault-warn: #e9b44c;
87
+ --vault-bad: #ffa39b;
88
+ --vault-bad-tint: rgba(255, 163, 155, 0.1);
89
+ --vault-bad-line: rgba(255, 163, 155, 0.45);
90
+ --vault-bad-chip: rgba(255, 163, 155, 0.18);
91
+ --vault-warn-tint: rgba(233, 180, 76, 0.12);
92
+ --vault-warn-line: rgba(233, 180, 76, 0.5);
87
93
 
88
94
  /*
89
- * Dark in both themes, and nowhere else: code blocks in the docs and the
90
- * Open Graph card. Deliberately absent from the dark theme below — a value
91
- * that is fixed cannot also follow.
95
+ * Code blocks in the docs and the Open Graph card. Both land on a ground we
96
+ * do not control — a reader's docs theme, someone else's timeline — so they
97
+ * carry their own and do not follow anything.
92
98
  */
93
99
  --dark-bg: #121721;
94
100
  --dark-surface: rgba(255, 255, 255, 0.08);
@@ -125,60 +131,3 @@
125
131
  --radius-control: 8px;
126
132
  --radius-card: 12px;
127
133
  }
128
-
129
- /*
130
- * The dark theme rebinds the roles rather than adding a second vocabulary. A
131
- * component writes var(--ink) once and is correct on both grounds; a component
132
- * choosing between a light token and a dark one is a component deciding the
133
- * rule, and two of them will decide it differently.
134
- *
135
- * Whether dark follows the operating system is not settled by the sheet, so it
136
- * is not decided here: the attribute is the only way in.
137
- */
138
- .slip-root[data-theme="dark"] {
139
- color-scheme: dark;
140
-
141
- --page: #121721; /* the brand dark */
142
- --card: #1b2231; /* lifted off the page */
143
- --subtle: rgba(114, 142, 243, 0.16);
144
- --skeleton: rgba(114, 142, 243, 0.18);
145
- --border: rgba(255, 255, 255, 0.14);
146
- --divider: rgba(255, 255, 255, 0.1);
147
-
148
- --ink: #ffffff;
149
- --muted: #ccd6ff;
150
-
151
- --accent-text: #a8bcff;
152
- --accent-hover: #5c79ee;
153
- --accent-deep: #a8bcff;
154
- --focus: #a8bcff;
155
-
156
- --pos: #5fd1a6;
157
- --warn: #e9b44c;
158
- --bad: #ffa39b;
159
- --bad-tint: rgba(255, 163, 155, 0.1);
160
- --bad-line: rgba(255, 163, 155, 0.4);
161
- --warn-tint: rgba(233, 180, 76, 0.14);
162
- --warn-line: rgba(233, 180, 76, 0.45);
163
-
164
- --stripe-a: rgba(114, 142, 243, 0.26);
165
- --stripe-b: rgba(114, 142, 243, 0.1);
166
- --stripe-off-a: rgba(204, 214, 255, 0.16);
167
- --stripe-off-b: rgba(204, 214, 255, 0.05);
168
-
169
- --vault: #080b12; /* still the gravest surface */
170
- --vault-ink: #ffffff;
171
- --vault-muted: #ccd6ff;
172
- --vault-surface: rgba(255, 255, 255, 0.08);
173
- --vault-border: rgba(255, 255, 255, 0.14);
174
- --vault-chip: rgba(114, 142, 243, 0.24);
175
- --vault-rule: rgba(114, 142, 243, 0.5);
176
- --vault-pos: #5fd1a6;
177
- --vault-warn: #e9b44c;
178
- --vault-bad: #ffa39b;
179
- --vault-bad-tint: rgba(255, 163, 155, 0.08);
180
- --vault-bad-line: rgba(255, 163, 155, 0.45);
181
- --vault-bad-chip: rgba(255, 163, 155, 0.2);
182
- --vault-warn-tint: rgba(233, 180, 76, 0.08);
183
- --vault-warn-line: rgba(233, 180, 76, 0.45);
184
- }
package/src/utxo.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * CIP-30 hands unspent outputs back as CBOR hex; evolution-sdk's builder takes
3
+ * them typed. The SDK has this conversion — `cip30UtxoFromCBORHex` — but only
4
+ * behind an export path its manifest blocks, so it is the sixth gap of ADR-0004
5
+ * and belongs upstream rather than here.
6
+ */
7
+ import {
8
+ Address,
9
+ AddressEras,
10
+ Assets,
11
+ CBOR,
12
+ Schema,
13
+ Script,
14
+ TransactionInput,
15
+ TransactionOutput,
16
+ UTxO
17
+ } from "@evolution-sdk/evolution"
18
+ import { Either } from "effect"
19
+
20
+ import type { CborHex } from "./cip30.js"
21
+
22
+ /** The output's address is era-tagged and a UTxO's is not; the bytes are the same. */
23
+ const toAddressStructure = (tagged: AddressEras.AddressEras): Address.Address =>
24
+ Schema.decodeSync(Address.FromBytes)(Schema.encodeSync(AddressEras.FromBytes)(tagged))
25
+
26
+ const readOne = (hex: CborHex): UTxO.UTxO => {
27
+ const decoded = CBOR.fromCBORHex(hex)
28
+ if (!Array.isArray(decoded) || decoded.length !== 2) {
29
+ throw new Error("a CIP-30 unspent output is an input and an output, and this is neither")
30
+ }
31
+ const input = TransactionInput.fromCBORBytes(CBOR.toCBORBytes(decoded[0]))
32
+ const output = TransactionOutput.fromCBORBytes(CBOR.toCBORBytes(decoded[1]))
33
+ const amount = output.amount
34
+
35
+ return new UTxO.UTxO({
36
+ transactionId: input.transactionId,
37
+ index: input.index,
38
+ address: toAddressStructure(output.address),
39
+ assets:
40
+ amount._tag === "WithAssets"
41
+ ? Assets.withMultiAsset(amount.coin, amount.assets)
42
+ : Assets.fromLovelace(amount.coin),
43
+ // A Babbage output carries a datum option and a script; a Shelley one carries
44
+ // a datum hash, which is itself a valid datum option.
45
+ datumOption: output._tag === "BabbageTransactionOutput" ? output.datumOption : output.datumHash,
46
+ scriptRef:
47
+ output._tag === "BabbageTransactionOutput" && output.scriptRef
48
+ ? Script.fromCBOR(output.scriptRef.bytes)
49
+ : undefined
50
+ })
51
+ }
52
+
53
+ /**
54
+ * Reads what `getUtxos` returned. `Either` and not a throw: this runs on
55
+ * whatever a browser extension chose to send, and one unreadable entry must
56
+ * not take the page down.
57
+ */
58
+ export const readWalletUtxos = (hexes: ReadonlyArray<CborHex>): Either.Either<ReadonlyArray<UTxO.UTxO>, string> => {
59
+ const utxos: Array<UTxO.UTxO> = []
60
+ for (const [index, hex] of hexes.entries()) {
61
+ try {
62
+ utxos.push(readOne(hex))
63
+ } catch (cause) {
64
+ return Either.left(
65
+ `unspent output ${index} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`
66
+ )
67
+ }
68
+ }
69
+ return Either.right(utxos)
70
+ }
package/src/witness.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The transaction id, and the witnesses that may go into the body it names.
3
+ * Pure: nothing here talks to a wallet, so what assembly accepts can be stated
4
+ * as a rule rather than observed against an extension.
5
+ */
6
+ import { extractTransactionBody } from "@cardano-slips/verifier"
7
+ import { Transaction, TransactionWitnessSet } from "@evolution-sdk/evolution"
8
+ import { blake2b } from "@noble/hashes/blake2.js"
9
+ import { Effect, Either } from "effect"
10
+
11
+ import type { CborHex } from "./cip30.js"
12
+ import { refuse, type SigningError } from "./sign-error.js"
13
+
14
+ const hex = /^[0-9a-fA-F]*$/
15
+
16
+ const bytesOf = (value: CborHex): Uint8Array | undefined => {
17
+ if (value.length % 2 !== 0 || !hex.test(value)) return undefined
18
+ return Uint8Array.from({ length: value.length / 2 }, (_, index) =>
19
+ Number.parseInt(value.slice(index * 2, index * 2 + 2), 16)
20
+ )
21
+ }
22
+
23
+ const toHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
24
+
25
+ /**
26
+ * BLAKE2b-256 over the body's original bytes, never a re-encode: this is the id
27
+ * the chain computes, and it is CIP-0186's commit — what binds a returned
28
+ * witness set to the body whose effects a person was shown.
29
+ */
30
+ export const transactionIdOf = (transaction: CborHex): Effect.Effect<string, SigningError> => {
31
+ const bytes = bytesOf(transaction)
32
+ if (bytes === undefined) {
33
+ return Effect.fail(refuse("UnreadableTransaction", "This transaction is not readable as CBOR."))
34
+ }
35
+ const body = extractTransactionBody(bytes)
36
+ if (Either.isLeft(body)) {
37
+ return Effect.fail(
38
+ refuse("UnreadableTransaction", `This transaction is not readable: ${body.left.message}`, { cause: body.left })
39
+ )
40
+ }
41
+ return Effect.succeed(toHex(blake2b(body.right.bodyBytes, { dkLen: 32 })))
42
+ }
43
+
44
+ /**
45
+ * What a version 1 transaction is never signed with. We build nothing that
46
+ * spends from a script, so any of these in a returned set is material the body
47
+ * was not judged with, and merging it would let a wallet add to a transaction
48
+ * after the moment it was shown.
49
+ */
50
+ const unexpected = (set: TransactionWitnessSet.TransactionWitnessSet): string | undefined => {
51
+ if ((set.nativeScripts ?? []).length > 0) return "native scripts"
52
+ if ((set.bootstrapWitnesses ?? []).length > 0) return "bootstrap witnesses"
53
+ if ((set.plutusV1Scripts ?? []).length > 0) return "Plutus v1 scripts"
54
+ if ((set.plutusV2Scripts ?? []).length > 0) return "Plutus v2 scripts"
55
+ if ((set.plutusV3Scripts ?? []).length > 0) return "Plutus v3 scripts"
56
+ if ((set.plutusData ?? []).length > 0) return "datums"
57
+ if (set.redeemers !== undefined) return "redeemers"
58
+ return undefined
59
+ }
60
+
61
+ const readWitnessSet = (witnesses: CborHex): Effect.Effect<TransactionWitnessSet.TransactionWitnessSet, SigningError> =>
62
+ Effect.try({
63
+ try: () => TransactionWitnessSet.fromCBORHex(witnesses),
64
+ catch: (cause) =>
65
+ refuse("UnreadableWitnesses", "The wallet answered with something that is not a witness set.", { cause })
66
+ })
67
+
68
+ /**
69
+ * Refuses unless this is the body the effects were derived from. Asked twice —
70
+ * before the wallet is given anything, and again as assembly begins — because
71
+ * either moment is one where the transaction can stop being the one on screen.
72
+ */
73
+ export const requireSameBody = (transaction: CborHex, transactionId: string): Effect.Effect<void, SigningError> =>
74
+ Effect.flatMap(transactionIdOf(transaction), (held) =>
75
+ held === transactionId
76
+ ? Effect.void
77
+ : Effect.fail(
78
+ refuse("WrongBody", "This is not the transaction the effects were derived from, so it will not be signed.")
79
+ )
80
+ )
81
+
82
+ /**
83
+ * Appends a returned witness set to the body it was asked for, or says why not.
84
+ * `transactionId` is the id the effects were derived from: witnesses go into
85
+ * that body or into none, which is what makes what a person was shown the thing
86
+ * they signed.
87
+ */
88
+ export const assembleWitnesses = (
89
+ transaction: CborHex,
90
+ witnesses: CborHex,
91
+ transactionId: string
92
+ ): Effect.Effect<CborHex, SigningError> =>
93
+ Effect.gen(function* () {
94
+ yield* requireSameBody(transaction, transactionId)
95
+
96
+ const set = yield* readWitnessSet(witnesses)
97
+
98
+ const material = unexpected(set)
99
+ if (material !== undefined) {
100
+ return yield* Effect.fail(
101
+ refuse(
102
+ "UnexpectedWitnessMaterial",
103
+ `The wallet answered with ${material}, which this transaction never asked for.`
104
+ )
105
+ )
106
+ }
107
+
108
+ // evolution-sdk returns the transaction untouched for an empty set, so
109
+ // without this an unsigned transaction would go to `submitTx` looking signed.
110
+ if ((set.vkeyWitnesses ?? []).length === 0) {
111
+ return yield* Effect.fail(refuse("NoWitnesses", "The wallet answered without a signature."))
112
+ }
113
+
114
+ // Appends to `vkeyWitnesses` and re-encodes from the format the transaction
115
+ // arrived in, so a co-signer's witness survives and the body keeps its bytes.
116
+ const assembled = yield* Effect.try({
117
+ try: () => Transaction.addVKeyWitnessesHex(transaction, witnesses),
118
+ catch: (cause) =>
119
+ refuse("UnreadableWitnesses", "The wallet's witnesses could not be assembled into the transaction.", { cause })
120
+ })
121
+
122
+ // The body is what the id is taken over, so an id that moved means the
123
+ // bytes moved — and the person would be signing something else.
124
+ const after = yield* transactionIdOf(assembled)
125
+ if (after !== transactionId) {
126
+ return yield* Effect.fail(
127
+ refuse("WrongBody", "Assembling the signature changed the transaction, so it will not be submitted.")
128
+ )
129
+ }
130
+
131
+ return assembled
132
+ })