@cardano-slips/flow 0.0.3 → 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 (84) hide show
  1. package/README.md +27 -0
  2. package/dist/address.d.ts +13 -0
  3. package/dist/address.d.ts.map +1 -0
  4. package/dist/address.js +54 -0
  5. package/dist/address.js.map +1 -0
  6. package/dist/balance-error.d.ts +48 -0
  7. package/dist/balance-error.d.ts.map +1 -0
  8. package/dist/balance-error.js +45 -0
  9. package/dist/balance-error.js.map +1 -0
  10. package/dist/balance.d.ts +54 -0
  11. package/dist/balance.d.ts.map +1 -0
  12. package/dist/balance.js +175 -0
  13. package/dist/balance.js.map +1 -0
  14. package/dist/cip30.d.ts +76 -0
  15. package/dist/cip30.d.ts.map +1 -0
  16. package/dist/cip30.js +69 -0
  17. package/dist/cip30.js.map +1 -0
  18. package/dist/complete-error.d.ts +50 -0
  19. package/dist/complete-error.d.ts.map +1 -0
  20. package/dist/complete-error.js +47 -0
  21. package/dist/complete-error.js.map +1 -0
  22. package/dist/complete.d.ts +58 -0
  23. package/dist/complete.d.ts.map +1 -0
  24. package/dist/complete.js +108 -0
  25. package/dist/complete.js.map +1 -0
  26. package/dist/connect.d.ts +45 -0
  27. package/dist/connect.d.ts.map +1 -0
  28. package/dist/connect.js +73 -0
  29. package/dist/connect.js.map +1 -0
  30. package/dist/discovery.d.ts +37 -0
  31. package/dist/discovery.d.ts.map +1 -0
  32. package/dist/discovery.js +71 -0
  33. package/dist/discovery.js.map +1 -0
  34. package/dist/index.d.ts +27 -7
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +15 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/registry.d.ts +18 -0
  39. package/dist/registry.d.ts.map +1 -0
  40. package/dist/registry.js +15 -0
  41. package/dist/registry.js.map +1 -0
  42. package/dist/resolve.d.ts +11 -0
  43. package/dist/resolve.d.ts.map +1 -0
  44. package/dist/resolve.js +21 -0
  45. package/dist/resolve.js.map +1 -0
  46. package/dist/sign-error.d.ts +62 -0
  47. package/dist/sign-error.d.ts.map +1 -0
  48. package/dist/sign-error.js +42 -0
  49. package/dist/sign-error.js.map +1 -0
  50. package/dist/sign.d.ts +38 -0
  51. package/dist/sign.d.ts.map +1 -0
  52. package/dist/sign.js +79 -0
  53. package/dist/sign.js.map +1 -0
  54. package/dist/tokens.css +133 -0
  55. package/dist/utxo.d.ts +16 -0
  56. package/dist/utxo.d.ts.map +1 -0
  57. package/dist/utxo.js +51 -0
  58. package/dist/utxo.js.map +1 -0
  59. package/dist/wallet-error.d.ts +48 -0
  60. package/dist/wallet-error.d.ts.map +1 -0
  61. package/dist/wallet-error.js +31 -0
  62. package/dist/wallet-error.js.map +1 -0
  63. package/dist/witness.d.ts +23 -0
  64. package/dist/witness.d.ts.map +1 -0
  65. package/dist/witness.js +101 -0
  66. package/dist/witness.js.map +1 -0
  67. package/package.json +10 -5
  68. package/src/address.ts +69 -0
  69. package/src/balance-error.ts +77 -0
  70. package/src/balance.ts +285 -0
  71. package/src/cip30.ts +113 -0
  72. package/src/complete-error.ts +83 -0
  73. package/src/complete.ts +198 -0
  74. package/src/connect.ts +136 -0
  75. package/src/discovery.ts +92 -0
  76. package/src/index.ts +37 -7
  77. package/src/registry.ts +32 -0
  78. package/src/resolve.ts +40 -0
  79. package/src/sign-error.ts +89 -0
  80. package/src/sign.ts +118 -0
  81. package/src/tokens.css +133 -0
  82. package/src/utxo.ts +70 -0
  83. package/src/wallet-error.ts +68 -0
  84. package/src/witness.ts +132 -0
@@ -0,0 +1,23 @@
1
+ import { Effect } from "effect";
2
+ import type { CborHex } from "./cip30.js";
3
+ import { type SigningError } from "./sign-error.js";
4
+ /**
5
+ * BLAKE2b-256 over the body's original bytes, never a re-encode: this is the id
6
+ * the chain computes, and it is CIP-0186's commit — what binds a returned
7
+ * witness set to the body whose effects a person was shown.
8
+ */
9
+ export declare const transactionIdOf: (transaction: CborHex) => Effect.Effect<string, SigningError>;
10
+ /**
11
+ * Refuses unless this is the body the effects were derived from. Asked twice —
12
+ * before the wallet is given anything, and again as assembly begins — because
13
+ * either moment is one where the transaction can stop being the one on screen.
14
+ */
15
+ export declare const requireSameBody: (transaction: CborHex, transactionId: string) => Effect.Effect<void, SigningError>;
16
+ /**
17
+ * Appends a returned witness set to the body it was asked for, or says why not.
18
+ * `transactionId` is the id the effects were derived from: witnesses go into
19
+ * that body or into none, which is what makes what a person was shown the thing
20
+ * they signed.
21
+ */
22
+ export declare const assembleWitnesses: (transaction: CborHex, witnesses: CborHex, transactionId: string) => Effect.Effect<CborHex, SigningError>;
23
+ //# sourceMappingURL=witness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"witness.d.ts","sourceRoot":"","sources":["../src/witness.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAA;AAEvC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AACzC,OAAO,EAAU,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAa3D;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,aAAa,OAAO,KAAG,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,YAAY,CAYxF,CAAA;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,aAAa,OAAO,EAAE,eAAe,MAAM,KAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,YAAY,CAO3G,CAAA;AAEH;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,GAC5B,aAAa,OAAO,EACpB,WAAW,OAAO,EAClB,eAAe,MAAM,KACpB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,YAAY,CAwClC,CAAA"}
@@ -0,0 +1,101 @@
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
+ import { refuse } from "./sign-error.js";
11
+ const hex = /^[0-9a-fA-F]*$/;
12
+ const bytesOf = (value) => {
13
+ if (value.length % 2 !== 0 || !hex.test(value))
14
+ return undefined;
15
+ return Uint8Array.from({ length: value.length / 2 }, (_, index) => Number.parseInt(value.slice(index * 2, index * 2 + 2), 16));
16
+ };
17
+ const toHex = (bytes) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
18
+ /**
19
+ * BLAKE2b-256 over the body's original bytes, never a re-encode: this is the id
20
+ * the chain computes, and it is CIP-0186's commit — what binds a returned
21
+ * witness set to the body whose effects a person was shown.
22
+ */
23
+ export const transactionIdOf = (transaction) => {
24
+ const bytes = bytesOf(transaction);
25
+ if (bytes === undefined) {
26
+ return Effect.fail(refuse("UnreadableTransaction", "This transaction is not readable as CBOR."));
27
+ }
28
+ const body = extractTransactionBody(bytes);
29
+ if (Either.isLeft(body)) {
30
+ return Effect.fail(refuse("UnreadableTransaction", `This transaction is not readable: ${body.left.message}`, { cause: body.left }));
31
+ }
32
+ return Effect.succeed(toHex(blake2b(body.right.bodyBytes, { dkLen: 32 })));
33
+ };
34
+ /**
35
+ * What a version 1 transaction is never signed with. We build nothing that
36
+ * spends from a script, so any of these in a returned set is material the body
37
+ * was not judged with, and merging it would let a wallet add to a transaction
38
+ * after the moment it was shown.
39
+ */
40
+ const unexpected = (set) => {
41
+ if ((set.nativeScripts ?? []).length > 0)
42
+ return "native scripts";
43
+ if ((set.bootstrapWitnesses ?? []).length > 0)
44
+ return "bootstrap witnesses";
45
+ if ((set.plutusV1Scripts ?? []).length > 0)
46
+ return "Plutus v1 scripts";
47
+ if ((set.plutusV2Scripts ?? []).length > 0)
48
+ return "Plutus v2 scripts";
49
+ if ((set.plutusV3Scripts ?? []).length > 0)
50
+ return "Plutus v3 scripts";
51
+ if ((set.plutusData ?? []).length > 0)
52
+ return "datums";
53
+ if (set.redeemers !== undefined)
54
+ return "redeemers";
55
+ return undefined;
56
+ };
57
+ const readWitnessSet = (witnesses) => Effect.try({
58
+ try: () => TransactionWitnessSet.fromCBORHex(witnesses),
59
+ catch: (cause) => refuse("UnreadableWitnesses", "The wallet answered with something that is not a witness set.", { cause })
60
+ });
61
+ /**
62
+ * Refuses unless this is the body the effects were derived from. Asked twice —
63
+ * before the wallet is given anything, and again as assembly begins — because
64
+ * either moment is one where the transaction can stop being the one on screen.
65
+ */
66
+ export const requireSameBody = (transaction, transactionId) => Effect.flatMap(transactionIdOf(transaction), (held) => held === transactionId
67
+ ? Effect.void
68
+ : Effect.fail(refuse("WrongBody", "This is not the transaction the effects were derived from, so it will not be signed.")));
69
+ /**
70
+ * Appends a returned witness set to the body it was asked for, or says why not.
71
+ * `transactionId` is the id the effects were derived from: witnesses go into
72
+ * that body or into none, which is what makes what a person was shown the thing
73
+ * they signed.
74
+ */
75
+ export const assembleWitnesses = (transaction, witnesses, transactionId) => Effect.gen(function* () {
76
+ yield* requireSameBody(transaction, transactionId);
77
+ const set = yield* readWitnessSet(witnesses);
78
+ const material = unexpected(set);
79
+ if (material !== undefined) {
80
+ return yield* Effect.fail(refuse("UnexpectedWitnessMaterial", `The wallet answered with ${material}, which this transaction never asked for.`));
81
+ }
82
+ // evolution-sdk returns the transaction untouched for an empty set, so
83
+ // without this an unsigned transaction would go to `submitTx` looking signed.
84
+ if ((set.vkeyWitnesses ?? []).length === 0) {
85
+ return yield* Effect.fail(refuse("NoWitnesses", "The wallet answered without a signature."));
86
+ }
87
+ // Appends to `vkeyWitnesses` and re-encodes from the format the transaction
88
+ // arrived in, so a co-signer's witness survives and the body keeps its bytes.
89
+ const assembled = yield* Effect.try({
90
+ try: () => Transaction.addVKeyWitnessesHex(transaction, witnesses),
91
+ catch: (cause) => refuse("UnreadableWitnesses", "The wallet's witnesses could not be assembled into the transaction.", { cause })
92
+ });
93
+ // The body is what the id is taken over, so an id that moved means the
94
+ // bytes moved — and the person would be signing something else.
95
+ const after = yield* transactionIdOf(assembled);
96
+ if (after !== transactionId) {
97
+ return yield* Effect.fail(refuse("WrongBody", "Assembling the signature changed the transaction, so it will not be submitted."));
98
+ }
99
+ return assembled;
100
+ });
101
+ //# sourceMappingURL=witness.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"witness.js","sourceRoot":"","sources":["../src/witness.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardano-slips/flow",
3
- "version": "0.0.3",
3
+ "version": "0.2.0",
4
4
  "description": "The client side of Cardano Slips: CIP-30 orchestration, local balancing, and the React components that render a Slip and block a signature when its effects disagree",
5
5
  "keywords": [
6
6
  "cardano",
@@ -24,6 +24,7 @@
24
24
  "types": "./dist/index.d.ts",
25
25
  "default": "./dist/index.js"
26
26
  },
27
+ "./tokens.css": "./dist/tokens.css",
27
28
  "./package.json": "./package.json"
28
29
  },
29
30
  "types": "./dist/index.d.ts",
@@ -41,8 +42,11 @@
41
42
  "react": "^19.0.0"
42
43
  },
43
44
  "dependencies": {
44
- "@cardano-slips/core": "^0.2.0",
45
- "@cardano-slips/verifier": "^0.1.0"
45
+ "@evolution-sdk/evolution": "^0.5.13",
46
+ "@noble/hashes": "^2.0.1",
47
+ "effect": "^3.22.1",
48
+ "@cardano-slips/verifier": "^0.3.0",
49
+ "@cardano-slips/core": "^0.2.0"
46
50
  },
47
51
  "devDependencies": {
48
52
  "@testing-library/react": "^16.3.2",
@@ -50,10 +54,11 @@
50
54
  "happy-dom": "^20.11.6",
51
55
  "react": "^19.2.8",
52
56
  "react-dom": "^19.2.8",
53
- "typescript": "^6.0.3"
57
+ "typescript": "^6.0.3",
58
+ "@cardano-slips/example-slips": "^0.0.0"
54
59
  },
55
60
  "scripts": {
56
- "build": "tsc -b tsconfig.build.json",
61
+ "build": "tsc -b tsconfig.build.json && mkdir -p dist && cp src/tokens.css dist/tokens.css",
57
62
  "typecheck": "tsc -b tsconfig.src.json && tsc -p tsconfig.test.json",
58
63
  "test": "vitest run",
59
64
  "lint": "eslint .",
package/src/address.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * CIP-30 hands addresses back as hex; the endpoint is sent bech32, because that
3
+ * is what a `BuildRequest` carries and what a person can recognise. The
4
+ * conversion is also the second statement of network — the header byte says
5
+ * which chain the address is for, and a wallet whose `getNetworkId` disagrees
6
+ * with its own address has answered two ways.
7
+ */
8
+ import { PaymentAddress } from "@cardano-slips/core"
9
+ import { encodeBech32 } from "@cardano-slips/verifier"
10
+ import { Either, Schema } from "effect"
11
+
12
+ /** CIP-30's `getNetworkId`: 1 is mainnet, 0 is every test network. */
13
+ export type NetworkId = 0 | 1
14
+
15
+ export type WalletAddress = {
16
+ readonly bech32: string
17
+ readonly networkId: NetworkId
18
+ }
19
+
20
+ const decodePaymentAddress = Schema.decodeUnknownEither(PaymentAddress)
21
+
22
+ const refuse = (detail: string): Either.Either<never, string> => Either.left(detail)
23
+
24
+ const fromHex = (hex: string): Uint8Array | undefined => {
25
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) return undefined
26
+ const bytes = new Uint8Array(hex.length / 2)
27
+ for (let index = 0; index < bytes.length; index++)
28
+ bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16)
29
+ return bytes
30
+ }
31
+
32
+ /**
33
+ * CIP-30 calls the value "the CBOR of the address" and wallets ship the address
34
+ * bytes bare. Both are accepted, and they cannot be confused: `0x58` as a first
35
+ * byte is a CBOR byte string of up to 255 bytes, and as an address header it
36
+ * would claim network 8, which does not exist.
37
+ */
38
+ const unwrapCbor = (bytes: Uint8Array): Uint8Array =>
39
+ bytes.length > 2 && bytes[0] === 0x58 && bytes[1] === bytes.length - 2 ? bytes.subarray(2) : bytes
40
+
41
+ /** CIP-19: the header's high nibble is the address type, the low nibble the network. Types 0-7 can hold a payment. */
42
+ const LAST_PAYMENT_TYPE = 7
43
+
44
+ /**
45
+ * Reads what CIP-30 returned. `Either` and not a throw: this runs on whatever a
46
+ * browser extension chose to send, and every refusal reaches a person as words.
47
+ */
48
+ export const readWalletAddress = (hex: string): Either.Either<WalletAddress, string> => {
49
+ const raw = fromHex(hex.trim())
50
+ if (raw === undefined) return refuse(`${hex.slice(0, 16)}… is not hex`)
51
+
52
+ const bytes = unwrapCbor(raw)
53
+ const header = bytes[0]
54
+
55
+ const networkId = header & 0x0f
56
+ if (networkId !== 0 && networkId !== 1) return refuse(`the address header names network ${networkId}`)
57
+
58
+ const type = header >> 4
59
+ if (type > LAST_PAYMENT_TYPE) {
60
+ // A reward address (14, 15) or a Byron address (8) cannot receive change.
61
+ return refuse(`address type ${type} cannot hold a payment`)
62
+ }
63
+
64
+ const bech32 = encodeBech32(networkId === 1 ? "addr" : "addr_test", bytes)
65
+ return Either.match(decodePaymentAddress(bech32), {
66
+ onLeft: () => refuse(`${bech32.slice(0, 16)}… is not a payment address`),
67
+ onRight: () => Either.right({ bech32, networkId: networkId as NetworkId })
68
+ })
69
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Why an intent did not become a transaction. Every refusal carries a spec
3
+ * error code, because each one is a failure of the exchange the spec describes
4
+ * rather than a state of a browser extension (`wallet-error.ts` is the other
5
+ * half, and deliberately has refusals that carry no code).
6
+ */
7
+ import { type ClientErrorCode } from "@cardano-slips/core"
8
+ import { Data } from "effect"
9
+
10
+ export type BalanceRefusal =
11
+ /** `validUntil` has already passed. A fresh POST returns a fresh intent. */
12
+ | "IntentExpired"
13
+ /** An address in the intent encodes a different network from the Slip's. */
14
+ | "ForeignAddress"
15
+ /** A certificate or withdrawal needs the wallet's stake credential and the address carries none. */
16
+ | "NoStakeCredential"
17
+ /** The change address the wallet gave is not an address these bytes can be read as. */
18
+ | "UnreadableChangeAddress"
19
+ /** The intent withdraws rewards and nobody said what the reward balance is. */
20
+ | "RewardBalanceUnknown"
21
+ /** The wallet cannot cover the intent, the minimum ADA its outputs require, and the fee. */
22
+ | "InsufficientFunds"
23
+ /** The intent cannot be built into a valid transaction for any other reason. */
24
+ | "CannotBalance"
25
+
26
+ /**
27
+ * Exhaustive by type: adding a refusal above without listing it here fails to
28
+ * compile, and `test/balance.test.ts` fails if any of them stops being reachable.
29
+ */
30
+ export const balanceRefusals: Readonly<Record<BalanceRefusal, true>> = {
31
+ IntentExpired: true,
32
+ ForeignAddress: true,
33
+ NoStakeCredential: true,
34
+ UnreadableChangeAddress: true,
35
+ RewardBalanceUnknown: true,
36
+ InsufficientFunds: true,
37
+ CannotBalance: true
38
+ }
39
+
40
+ /**
41
+ * A wrong-network address is `MALFORMED_RESPONSE` and not `WRONG_NETWORK`:
42
+ * the latter is a disagreement the person can act on by switching wallets, and
43
+ * an endpoint that sent an address on another chain is not one of those.
44
+ */
45
+ export const slipErrorCodeFor = (refusal: BalanceRefusal): ClientErrorCode => {
46
+ switch (refusal) {
47
+ case "IntentExpired":
48
+ return "INTENT_EXPIRED"
49
+ case "ForeignAddress":
50
+ return "MALFORMED_RESPONSE"
51
+ case "InsufficientFunds":
52
+ return "INSUFFICIENT_FUNDS"
53
+ case "NoStakeCredential":
54
+ case "UnreadableChangeAddress":
55
+ case "RewardBalanceUnknown":
56
+ case "CannotBalance":
57
+ return "CANNOT_BALANCE"
58
+ }
59
+ }
60
+
61
+ /** `cause` keeps what evolution-sdk said, so a build failure stays diagnosable. */
62
+ export class BalanceError extends Data.TaggedError("BalanceError")<{
63
+ readonly refusal: BalanceRefusal
64
+ readonly detail: string
65
+ readonly cause?: unknown
66
+ }> {
67
+ override get message(): string {
68
+ return this.detail
69
+ }
70
+
71
+ get code(): ClientErrorCode {
72
+ return slipErrorCodeFor(this.refusal)
73
+ }
74
+ }
75
+
76
+ export const refuse = (refusal: BalanceRefusal, detail: string, cause?: unknown): BalanceError =>
77
+ new BalanceError({ refusal, detail, ...(cause === undefined ? {} : { cause }) })
package/src/balance.ts ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The privacy-preserving heart of Mode A: the endpoint's half-transaction
3
+ * becomes a whole one here, against unspent outputs it never sees (ADR-0002).
4
+ *
5
+ * Every term arrives as an argument — unspent outputs, protocol parameters, the
6
+ * reward balance, the clock — for the same reason the verifier takes its five:
7
+ * a balancer that fetched them could be made slow, made to fail, or made to
8
+ * answer wrongly by whoever answered.
9
+ */
10
+ import { addressIsOnNetwork, type Certificate, type Intent, type Network, type Output } from "@cardano-slips/core"
11
+ import type { ProtocolParameters } from "@cardano-slips/verifier"
12
+ import {
13
+ Address,
14
+ Assets,
15
+ DRep,
16
+ mainnet,
17
+ PoolKeyHash,
18
+ preprod,
19
+ preview,
20
+ Schema,
21
+ type SlotConfig,
22
+ Transaction,
23
+ type UTxO
24
+ } from "@evolution-sdk/evolution"
25
+ import {
26
+ makeTxBuilder,
27
+ type ProtocolParameters as BuilderParameters,
28
+ type ReadOnlyTransactionBuilder
29
+ } from "@evolution-sdk/evolution/sdk/builders/TransactionBuilder"
30
+ import { Effect } from "effect"
31
+
32
+ import { type BalanceError, refuse } from "./balance-error.js"
33
+ import type { CborHex } from "./cip30.js"
34
+
35
+ /**
36
+ * The verifier's parameters plus the one bound the builder needs and the gate
37
+ * does not. One vocabulary across both halves: the figures that balance a
38
+ * transaction are the figures it will be judged against.
39
+ */
40
+ export type BalancingParameters = ProtocolParameters & {
41
+ /** `maxTxSize`: the byte ceiling a built transaction must fit under. */
42
+ readonly maxTxSize: number
43
+ }
44
+
45
+ export type BalanceInputs = {
46
+ readonly intent: Intent
47
+ /** The Slip's network. Every address in the intent is held to it. */
48
+ readonly network: Network
49
+ /** Bech32, from the connected wallet. Change returns here. */
50
+ readonly changeAddress: string
51
+ readonly utxos: ReadonlyArray<UTxO.UTxO>
52
+ readonly parameters: BalancingParameters
53
+ /**
54
+ * The whole balance of the wallet's own reward account, required when the
55
+ * intent withdraws rewards. The ledger accepts one figure and only one, and
56
+ * no CIP-30 method answers it.
57
+ */
58
+ readonly rewardBalance?: bigint
59
+ /** Unix milliseconds. Handed in, so an expiry is decided by one clock and not two. */
60
+ readonly now: number
61
+ }
62
+
63
+ export type BalancedTransaction = {
64
+ /** The complete unsigned transaction: what `verifier` decodes and the wallet signs. */
65
+ readonly cbor: CborHex
66
+ readonly fee: bigint
67
+ /** The slot the body expires at, for the rebuild path to watch. */
68
+ readonly ttl: bigint
69
+ }
70
+
71
+ const chains = { mainnet, preprod, preview }
72
+
73
+ /**
74
+ * The slot mapping comes from the parameters rather than from the chain preset:
75
+ * the client converts `validUntil` to a slot and the gate converts that slot
76
+ * back to a time, and two mappings would put an honest transaction outside its
77
+ * own declared window.
78
+ */
79
+ const slotConfigOf = ({ slots }: BalancingParameters): SlotConfig.SlotConfig => ({
80
+ zeroTime: slots.time,
81
+ zeroSlot: slots.slot,
82
+ slotLength: Number(slots.slotLength)
83
+ })
84
+
85
+ const builderParameters = (parameters: BalancingParameters): BuilderParameters => ({
86
+ minFeeCoefficient: parameters.minFeeCoefficient,
87
+ minFeeConstant: parameters.minFeeConstant,
88
+ coinsPerUtxoByte: parameters.coinsPerUtxoByte,
89
+ maxTxSize: parameters.maxTxSize
90
+ })
91
+
92
+ /**
93
+ * What the builder reads for a certificate's deposit. The script fields are
94
+ * zeroed rather than guessed: version 1 builds no transaction that spends from
95
+ * a script, so a cost model here would be a figure nothing computes from.
96
+ */
97
+ const depositParameters = (parameters: BalancingParameters) => ({
98
+ minFeeA: Number(parameters.minFeeCoefficient),
99
+ minFeeB: Number(parameters.minFeeConstant),
100
+ maxTxSize: parameters.maxTxSize,
101
+ maxValSize: 0,
102
+ keyDeposit: parameters.stakeDeposit,
103
+ poolDeposit: parameters.poolDeposit,
104
+ drepDeposit: parameters.drepDeposit,
105
+ govActionDeposit: parameters.governanceActionDeposit,
106
+ priceMem: 0,
107
+ priceStep: 0,
108
+ maxTxExMem: 0n,
109
+ maxTxExSteps: 0n,
110
+ coinsPerUtxoByte: parameters.coinsPerUtxoByte,
111
+ collateralPercentage: 0,
112
+ maxCollateralInputs: 0,
113
+ minFeeRefScriptCostPerByte: 0,
114
+ costModels: { PlutusV1: {}, PlutusV2: {}, PlutusV3: {} }
115
+ })
116
+
117
+ const assetsOf = (output: Output): Assets.Assets =>
118
+ (output.assets ?? []).reduce(
119
+ (assets, asset) => Assets.addByHex(assets, asset.policyId, asset.assetName, BigInt(asset.quantity)),
120
+ Assets.fromLovelace(BigInt(output.lovelace))
121
+ )
122
+
123
+ /**
124
+ * `Schema.decodeSync(FromBech32)` and not `DRep.fromBech32`: the SDK's
125
+ * declarations carry that function and the shipped module does not export it,
126
+ * so calling it compiles and throws. The seventh gap of ADR-0004.
127
+ */
128
+ const drepFromBech32 = Schema.decodeSync(DRep.FromBech32)
129
+
130
+ const drepOf = (declared: Certificate & { readonly type: "voteDelegation" }): DRep.DRep => {
131
+ if (declared.drep === "abstain") return DRep.alwaysAbstain()
132
+ if (declared.drep === "noConfidence") return DRep.alwaysNoConfidence()
133
+ return drepFromBech32(declared.drep)
134
+ }
135
+
136
+ /**
137
+ * The Conway forms throughout. The legacy pair encodes no deposit and no
138
+ * refund, and evolution-sdk's builder therefore leaves both out of the balance —
139
+ * a transaction the ledger rejects, because consumed and produced no longer
140
+ * agree. The stated deposit is the protocol parameter exactly, which is what
141
+ * the ledger charges and what the gate holds it to (ADR-0013).
142
+ */
143
+ const applyCertificate = (
144
+ builder: ReadOnlyTransactionBuilder,
145
+ declared: Certificate,
146
+ stakeCredential: StakeCredential
147
+ ): ReadOnlyTransactionBuilder => {
148
+ switch (declared.type) {
149
+ case "stakeRegistration":
150
+ return builder.registerStake({ stakeCredential })
151
+ case "stakeDeregistration":
152
+ return builder.deregisterStake({ stakeCredential })
153
+ case "stakeDelegation":
154
+ return builder.delegateToPool({ stakeCredential, poolKeyHash: PoolKeyHash.fromBech32(declared.poolId) })
155
+ case "voteDelegation":
156
+ return builder.delegateToDRep({ stakeCredential, drep: drepOf(declared) })
157
+ }
158
+ }
159
+
160
+ /** The staking half of a base address — what every certificate and withdrawal here acts on. */
161
+ type StakeCredential = NonNullable<Address.Address["stakingCredential"]>
162
+
163
+ /**
164
+ * What the transaction does to the wallet's own stake credential, gathered
165
+ * once so the credential is proved present before the builder runs rather than
166
+ * asserted inside it.
167
+ */
168
+ type StakeActions = {
169
+ readonly credential: StakeCredential
170
+ readonly certificates: ReadonlyArray<Certificate>
171
+ /** The whole reward balance, or `null` where the intent withdraws nothing. */
172
+ readonly withdrawal: bigint | null
173
+ }
174
+
175
+ const actsOnStake = (intent: Intent): boolean =>
176
+ (intent.certificates ?? []).length > 0 || intent.withdrawRewards === true
177
+
178
+ /** The two sites evolution-sdk reports a shortfall from, neither of which carries a structured cause. */
179
+ const isShortfall = (message: string): boolean =>
180
+ message.startsWith("Coin selection failed") || message.includes("Insufficient funds to cover")
181
+
182
+ const readFailure = (cause: unknown): BalanceError => {
183
+ const message = cause instanceof Error ? cause.message : String(cause)
184
+ return isShortfall(message)
185
+ ? refuse("InsufficientFunds", "This wallet does not hold enough to cover the transaction and its fee.", cause)
186
+ : refuse("CannotBalance", `This intent could not be built into a transaction: ${message}`, cause)
187
+ }
188
+
189
+ /**
190
+ * Builds the transaction, or says why not. Nothing here asks the network
191
+ * anything, and nothing describing what the wallet holds leaves this function.
192
+ */
193
+ export const balanceIntent = (inputs: BalanceInputs): Effect.Effect<BalancedTransaction, BalanceError> =>
194
+ Effect.gen(function* () {
195
+ const { changeAddress, intent, network, parameters, utxos } = inputs
196
+
197
+ const validUntil = Date.parse(intent.validUntil)
198
+ if (validUntil <= inputs.now) {
199
+ return yield* Effect.fail(refuse("IntentExpired", "This request expired while it was open. Ask for a fresh one."))
200
+ }
201
+
202
+ for (const output of intent.outputs ?? []) {
203
+ if (!addressIsOnNetwork(output.address, network)) {
204
+ return yield* Effect.fail(
205
+ refuse("ForeignAddress", `This Slip is on ${network} and it asks to pay an address on another network.`)
206
+ )
207
+ }
208
+ }
209
+
210
+ // Inside the error channel, not thrown past it: the signature promises a
211
+ // `BalanceError` and a defect here would reach a person as a stack trace.
212
+ const change = yield* Effect.try({
213
+ try: () => Address.fromBech32(changeAddress),
214
+ catch: (cause) =>
215
+ refuse("UnreadableChangeAddress", "This wallet's change address could not be read as an address.", cause)
216
+ })
217
+ const credential = change.stakingCredential
218
+ if (actsOnStake(intent) && credential === undefined) {
219
+ return yield* Effect.fail(
220
+ refuse("NoStakeCredential", "This action acts on a stake credential and this wallet's address carries none.")
221
+ )
222
+ }
223
+
224
+ if (intent.withdrawRewards === true && inputs.rewardBalance === undefined) {
225
+ return yield* Effect.fail(
226
+ refuse("RewardBalanceUnknown", "The reward balance to withdraw is not known, so this cannot be built.")
227
+ )
228
+ }
229
+
230
+ const stake: StakeActions | null =
231
+ credential === undefined
232
+ ? null
233
+ : {
234
+ credential,
235
+ certificates: intent.certificates ?? [],
236
+ withdrawal: intent.withdrawRewards === true ? (inputs.rewardBalance ?? 0n) : null
237
+ }
238
+
239
+ const built = yield* Effect.tryPromise({
240
+ try: async () => {
241
+ let builder: ReadOnlyTransactionBuilder = makeTxBuilder({ chain: chains[network] })
242
+
243
+ for (const output of intent.outputs ?? []) {
244
+ // Declared lovelace is a floor: `autoMinUtxo` raises an output that
245
+ // cannot pay for its own bytes, and the difference is shown to the
246
+ // person as the client's own adjustment rather than folded in.
247
+ builder = builder.payToAddress({ address: Address.fromBech32(output.address), assets: assetsOf(output) })
248
+ }
249
+
250
+ if (stake !== null) {
251
+ for (const declared of stake.certificates) builder = applyCertificate(builder, declared, stake.credential)
252
+ if (stake.withdrawal !== null) {
253
+ builder = builder.withdraw({ stakeCredential: stake.credential, amount: stake.withdrawal })
254
+ }
255
+ }
256
+
257
+ // Never after `validUntil`, which is the whole of what the spec fixes here.
258
+ builder = builder.setValidity({ to: BigInt(validUntil) })
259
+
260
+ return await builder.build({
261
+ changeAddress: change,
262
+ availableUtxos: utxos,
263
+ protocolParameters: builderParameters(parameters),
264
+ fullProtocolParameters: depositParameters(parameters),
265
+ slotConfig: slotConfigOf(parameters),
266
+ autoMinUtxo: true,
267
+ // Leftover too small to become change would otherwise be paid as fee —
268
+ // lovelace leaving the wallet that nothing on screen accounted for.
269
+ onInsufficientChange: "error"
270
+ })
271
+ },
272
+ catch: readFailure
273
+ })
274
+
275
+ const transaction = yield* Effect.tryPromise({ try: () => built.toTransaction(), catch: readFailure })
276
+
277
+ const ttl = transaction.body.ttl
278
+ if (ttl === undefined) {
279
+ // We asked for an interval; a body without one could be submitted after
280
+ // `validUntil`, which is the one thing the spec forbids outright here.
281
+ return yield* Effect.fail(refuse("CannotBalance", "The built transaction carries no expiry, and it must."))
282
+ }
283
+
284
+ return { cbor: Transaction.toCBORHex(transaction), fee: transaction.body.fee, ttl }
285
+ })