@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.
- 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/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 +50 -0
- package/dist/complete-error.d.ts.map +1 -0
- package/dist/complete-error.js +47 -0
- package/dist/complete-error.js.map +1 -0
- package/dist/complete.d.ts +58 -0
- package/dist/complete.d.ts.map +1 -0
- package/dist/complete.js +108 -0
- package/dist/complete.js.map +1 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- 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/tokens.css +48 -99
- 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 +7 -4
- package/src/balance-error.ts +77 -0
- package/src/balance.ts +285 -0
- package/src/cip30.ts +19 -0
- package/src/complete-error.ts +83 -0
- package/src/complete.ts +198 -0
- package/src/index.ts +26 -1
- package/src/resolve.ts +40 -0
- package/src/sign-error.ts +89 -0
- package/src/sign.ts +118 -0
- package/src/tokens.css +48 -99
- package/src/utxo.ts +70 -0
- package/src/witness.ts +132 -0
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
|
+
})
|
package/src/cip30.ts
CHANGED
|
@@ -79,6 +79,25 @@ export const apiErrorNames: Readonly<Record<number, string>> = {
|
|
|
79
79
|
/** `-3` is the person saying no, which is not a fault and must not be shown as one. */
|
|
80
80
|
export const REFUSED = -3
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* `TxSignError` and `TxSendError` are `APIError`'s shape on their own
|
|
84
|
+
* numbering, and the numbering is the footgun: a declined signature is `2`
|
|
85
|
+
* here, where `2` in `APIError` is an internal error. They never share a call,
|
|
86
|
+
* so which table applies is decided by the method that rejected.
|
|
87
|
+
*/
|
|
88
|
+
export const txSignErrorNames: Readonly<Record<number, string>> = {
|
|
89
|
+
1: "ProofGeneration",
|
|
90
|
+
2: "UserDeclined"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `2` from `signTx`: the person declined in the wallet. */
|
|
94
|
+
export const USER_DECLINED = 2
|
|
95
|
+
|
|
96
|
+
export const txSendErrorNames: Readonly<Record<number, string>> = {
|
|
97
|
+
1: "Refused",
|
|
98
|
+
2: "Failure"
|
|
99
|
+
}
|
|
100
|
+
|
|
82
101
|
/** A wallet rejects with a plain `{ code, info }` object, not an `Error`. */
|
|
83
102
|
export const readApiError = (cause: unknown): Cip30ApiError | undefined => {
|
|
84
103
|
if (typeof cause !== "object" || cause === null) return undefined
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a Slip did not reach the chain, for the failures that belong to the whole
|
|
3
|
+
* path rather than to building, signing or submitting alone. `Blocked` is the
|
|
4
|
+
* one that matters: it is invariant 3 in the error channel, and there is no
|
|
5
|
+
* field on it that lets anyone past.
|
|
6
|
+
*/
|
|
7
|
+
import { type ClientErrorCode } from "@cardano-slips/core"
|
|
8
|
+
import type { Reason } from "@cardano-slips/verifier"
|
|
9
|
+
import { Data } from "effect"
|
|
10
|
+
|
|
11
|
+
export type CompletionRefusal =
|
|
12
|
+
/** The wallet reported no unspent outputs, so there is nothing to build from. */
|
|
13
|
+
| "NoUtxos"
|
|
14
|
+
/** What `getUtxos` answered is not something these bytes can be read as. */
|
|
15
|
+
| "UnreadableUtxos"
|
|
16
|
+
/** The effects could not be derived from the transaction, or compared with the declaration. */
|
|
17
|
+
| "CannotJudge"
|
|
18
|
+
/** The derived effects disagree with what the endpoint declared. Nothing signs after this. */
|
|
19
|
+
| "Blocked"
|
|
20
|
+
/** The caller could not put the effects in front of a person, so nothing may be signed. */
|
|
21
|
+
| "NotShown"
|
|
22
|
+
/** The funds kept moving, and the transaction has been rebuilt as often as it will be. */
|
|
23
|
+
| "OutOfAttempts"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Exhaustive by type: adding a refusal above without listing it here fails to
|
|
27
|
+
* compile, and `test/complete.test.ts` fails if any of them stops being reachable.
|
|
28
|
+
*/
|
|
29
|
+
export const completionRefusals: Readonly<Record<CompletionRefusal, true>> = {
|
|
30
|
+
NoUtxos: true,
|
|
31
|
+
UnreadableUtxos: true,
|
|
32
|
+
CannotJudge: true,
|
|
33
|
+
Blocked: true,
|
|
34
|
+
NotShown: true,
|
|
35
|
+
OutOfAttempts: true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const slipErrorCodeFor = (refusal: CompletionRefusal): ClientErrorCode | undefined => {
|
|
39
|
+
switch (refusal) {
|
|
40
|
+
case "Blocked":
|
|
41
|
+
return "EFFECTS_MISMATCH"
|
|
42
|
+
case "NoUtxos":
|
|
43
|
+
return "INSUFFICIENT_FUNDS"
|
|
44
|
+
case "CannotJudge":
|
|
45
|
+
return "CANNOT_BALANCE"
|
|
46
|
+
case "UnreadableUtxos":
|
|
47
|
+
case "NotShown":
|
|
48
|
+
case "OutOfAttempts":
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `reasons` is what the transaction actually does against what was declared,
|
|
55
|
+
* for the block to render. There is no way to carry on from one — a mismatch
|
|
56
|
+
* hard-blocks, with no override, no allowlist and no confirmation.
|
|
57
|
+
*/
|
|
58
|
+
export class CompletionError extends Data.TaggedError("CompletionError")<{
|
|
59
|
+
readonly refusal: CompletionRefusal
|
|
60
|
+
readonly detail: string
|
|
61
|
+
readonly reasons?: ReadonlyArray<Reason>
|
|
62
|
+
readonly cause?: unknown
|
|
63
|
+
}> {
|
|
64
|
+
override get message(): string {
|
|
65
|
+
return this.detail
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get code(): ClientErrorCode | undefined {
|
|
69
|
+
return slipErrorCodeFor(this.refusal)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const refuse = (
|
|
74
|
+
refusal: CompletionRefusal,
|
|
75
|
+
detail: string,
|
|
76
|
+
extra: { readonly reasons?: ReadonlyArray<Reason>; readonly cause?: unknown } = {}
|
|
77
|
+
): CompletionError =>
|
|
78
|
+
new CompletionError({
|
|
79
|
+
refusal,
|
|
80
|
+
detail,
|
|
81
|
+
...(extra.reasons === undefined ? {} : { reasons: extra.reasons }),
|
|
82
|
+
...(extra.cause === undefined ? {} : { cause: extra.cause })
|
|
83
|
+
})
|
package/src/complete.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole path: build against what the wallet holds, judge it with the
|
|
3
|
+
* verifier's own engine, sign, submit. When the funds move underneath, every
|
|
4
|
+
* one of those happens again — a rebuilt transaction is a different transaction,
|
|
5
|
+
* and asking for a signature on it without deriving its effects first would put
|
|
6
|
+
* a person's name on something nobody read.
|
|
7
|
+
*/
|
|
8
|
+
import type { Intent, Network } from "@cardano-slips/core"
|
|
9
|
+
import { compare, decodeTransaction, deriveEffects, type Effects } from "@cardano-slips/verifier"
|
|
10
|
+
import { Effect, Either } from "effect"
|
|
11
|
+
|
|
12
|
+
import { balanceIntent, type BalancingParameters } from "./balance.js"
|
|
13
|
+
import type { BalanceError } from "./balance-error.js"
|
|
14
|
+
import type { Cip30Api } from "./cip30.js"
|
|
15
|
+
import { type CompletionError, refuse } from "./complete-error.js"
|
|
16
|
+
import { asResolvedInputs } from "./resolve.js"
|
|
17
|
+
import { signTransaction, submitTransaction } from "./sign.js"
|
|
18
|
+
import type { SigningError } from "./sign-error.js"
|
|
19
|
+
import { readWalletUtxos } from "./utxo.js"
|
|
20
|
+
import { transactionIdOf } from "./witness.js"
|
|
21
|
+
|
|
22
|
+
/** Enough rebuilds to outlast an unlucky moment, few enough that nobody is asked forever. */
|
|
23
|
+
const defaultAttempts = 3
|
|
24
|
+
|
|
25
|
+
export type Attempt = {
|
|
26
|
+
/** Counting from one, so a rendered "attempt 2 of 3" reads as a person would say it. */
|
|
27
|
+
readonly number: number
|
|
28
|
+
readonly transactionId: string
|
|
29
|
+
/** What this transaction does, derived from its own bytes. */
|
|
30
|
+
readonly effects: Effects
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CompletionRequest = {
|
|
34
|
+
readonly api: Cip30Api
|
|
35
|
+
/** What the endpoint declared, and what the transaction is judged against. */
|
|
36
|
+
readonly intent: Intent
|
|
37
|
+
readonly network: Network
|
|
38
|
+
readonly changeAddress: string
|
|
39
|
+
/**
|
|
40
|
+
* Every address the wallet calls its own, its reward account included. An
|
|
41
|
+
* address missing from here is read as a stranger's, which turns the wallet's
|
|
42
|
+
* own change into an undeclared payment and blocks the Slip.
|
|
43
|
+
*/
|
|
44
|
+
readonly userAddresses: ReadonlyArray<Uint8Array>
|
|
45
|
+
readonly parameters: BalancingParameters
|
|
46
|
+
readonly rewardBalance?: bigint
|
|
47
|
+
/** Read once per attempt: a rebuilt transaction gets its own validity window. */
|
|
48
|
+
readonly now?: () => number
|
|
49
|
+
/** How many times the transaction may be built. Beyond this the funds are moving faster than we can follow. */
|
|
50
|
+
readonly attempts?: number
|
|
51
|
+
/** Called once per attempt, after the effects are derived and before the wallet is asked to sign. */
|
|
52
|
+
readonly onAttempt?: (attempt: Attempt) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type Receipt = {
|
|
56
|
+
readonly transactionId: string
|
|
57
|
+
readonly effects: Effects
|
|
58
|
+
/** How many transactions it took, which is how many times the funds moved plus one. */
|
|
59
|
+
readonly attempts: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Failure = BalanceError | CompletionError | SigningError
|
|
63
|
+
|
|
64
|
+
const fromHex = (hex: string): Uint8Array =>
|
|
65
|
+
Uint8Array.from({ length: hex.length / 2 }, (_, index) => Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16))
|
|
66
|
+
|
|
67
|
+
const walletUtxos = (api: Cip30Api) =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const answered = yield* Effect.tryPromise({
|
|
70
|
+
try: () => api.getUtxos(),
|
|
71
|
+
catch: (cause) => refuse("UnreadableUtxos", "The wallet could not say what it holds.", { cause })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// `null` is CIP-30's own answer for a wallet with nothing to give.
|
|
75
|
+
const hexes = answered ?? []
|
|
76
|
+
const read = readWalletUtxos(hexes)
|
|
77
|
+
if (Either.isLeft(read)) {
|
|
78
|
+
return yield* Effect.fail(
|
|
79
|
+
refuse("UnreadableUtxos", `The wallet's unspent outputs could not be read: ${read.left}`)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
if (read.right.length === 0) {
|
|
83
|
+
return yield* Effect.fail(refuse("NoUtxos", "This wallet holds nothing to pay with."))
|
|
84
|
+
}
|
|
85
|
+
return read.right
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One transaction, from the wallet's outputs to a verdict. The judging happens
|
|
90
|
+
* here rather than at the caller so that no path reaches `signTransaction`
|
|
91
|
+
* without having passed it.
|
|
92
|
+
*/
|
|
93
|
+
const buildAndJudge = (request: CompletionRequest, number: number) =>
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const utxos = yield* walletUtxos(request.api)
|
|
96
|
+
const now = (request.now ?? Date.now)()
|
|
97
|
+
|
|
98
|
+
const built = yield* balanceIntent({
|
|
99
|
+
intent: request.intent,
|
|
100
|
+
network: request.network,
|
|
101
|
+
changeAddress: request.changeAddress,
|
|
102
|
+
utxos,
|
|
103
|
+
parameters: request.parameters,
|
|
104
|
+
...(request.rewardBalance === undefined ? {} : { rewardBalance: request.rewardBalance }),
|
|
105
|
+
now
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const transaction = decodeTransaction(fromHex(built.cbor))
|
|
109
|
+
if (Either.isLeft(transaction)) {
|
|
110
|
+
return yield* Effect.fail(
|
|
111
|
+
refuse("CannotJudge", `This transaction could not be read back: ${transaction.left.message}`)
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const effects = deriveEffects({
|
|
116
|
+
transaction: transaction.right,
|
|
117
|
+
userAddresses: request.userAddresses,
|
|
118
|
+
resolvedInputs: asResolvedInputs(utxos),
|
|
119
|
+
protocolParameters: request.parameters
|
|
120
|
+
})
|
|
121
|
+
if (Either.isLeft(effects)) {
|
|
122
|
+
return yield* Effect.fail(
|
|
123
|
+
refuse("CannotJudge", `What this transaction does could not be worked out: ${effects.left.message}`)
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const verdict = compare({
|
|
128
|
+
effects: effects.right,
|
|
129
|
+
declared: request.intent,
|
|
130
|
+
changeAddress: request.changeAddress,
|
|
131
|
+
now: BigInt(now),
|
|
132
|
+
protocolParameters: request.parameters
|
|
133
|
+
})
|
|
134
|
+
if (Either.isLeft(verdict)) {
|
|
135
|
+
return yield* Effect.fail(
|
|
136
|
+
refuse(
|
|
137
|
+
"CannotJudge",
|
|
138
|
+
`This transaction could not be checked against what was declared: ${verdict.left.message}`
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (verdict.right._tag === "mismatch") {
|
|
144
|
+
return yield* Effect.fail(
|
|
145
|
+
refuse("Blocked", "This transaction does not do what the Slip said it would, so it will not be signed.", {
|
|
146
|
+
reasons: verdict.right.reasons
|
|
147
|
+
})
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const transactionId = yield* transactionIdOf(built.cbor)
|
|
152
|
+
const attempt: Attempt = { number, transactionId, effects: effects.right }
|
|
153
|
+
|
|
154
|
+
// The caller is what puts these effects in front of a person. If that
|
|
155
|
+
// throws, the person has not seen them, so this fails closed rather than
|
|
156
|
+
// carrying on to a signature — and as a refusal rather than a stack trace.
|
|
157
|
+
yield* Effect.try({
|
|
158
|
+
try: () => request.onAttempt?.(attempt),
|
|
159
|
+
catch: (cause) => refuse("NotShown", "This transaction could not be shown, so it will not be signed.", { cause })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
return { cbor: built.cbor, attempt }
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Completes the Slip, or says why not. A submission that failed because an
|
|
167
|
+
* input was spent is the one failure worth repeating: everything else either
|
|
168
|
+
* cannot be helped by a rebuild or is a person having said no.
|
|
169
|
+
*/
|
|
170
|
+
export const completeIntent = (request: CompletionRequest): Effect.Effect<Receipt, Failure> =>
|
|
171
|
+
Effect.gen(function* () {
|
|
172
|
+
const allowed = Math.max(1, request.attempts ?? defaultAttempts)
|
|
173
|
+
|
|
174
|
+
for (let number = 1; number <= allowed; number += 1) {
|
|
175
|
+
const { attempt, cbor } = yield* buildAndJudge(request, number)
|
|
176
|
+
|
|
177
|
+
const signed = yield* signTransaction({
|
|
178
|
+
api: request.api,
|
|
179
|
+
transaction: cbor,
|
|
180
|
+
transactionId: attempt.transactionId
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const submitted = yield* Effect.either(submitTransaction(request.api, signed))
|
|
184
|
+
if (Either.isRight(submitted)) {
|
|
185
|
+
return { transactionId: submitted.right, effects: attempt.effects, attempts: number }
|
|
186
|
+
}
|
|
187
|
+
if (submitted.left.refusal !== "InputsSpent") {
|
|
188
|
+
return yield* Effect.fail(submitted.left)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return yield* Effect.fail(
|
|
193
|
+
refuse(
|
|
194
|
+
"OutOfAttempts",
|
|
195
|
+
`The wallet's funds moved every time this was built, ${allowed} times over. Try again in a moment.`
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -4,13 +4,38 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export type { NetworkId, WalletAddress } from "./address.js"
|
|
6
6
|
export { readWalletAddress } from "./address.js"
|
|
7
|
+
export type { BalancedTransaction, BalanceInputs, BalancingParameters } from "./balance.js"
|
|
8
|
+
export { balanceIntent } from "./balance.js"
|
|
9
|
+
export type { BalanceRefusal } from "./balance-error.js"
|
|
10
|
+
export { BalanceError, balanceRefusals, slipErrorCodeFor as balanceErrorCodeFor } from "./balance-error.js"
|
|
7
11
|
export type { CborHex, Cip30Api, Cip30ApiError, Cip30Provider, Paginate, SignedData } from "./cip30.js"
|
|
8
|
-
export {
|
|
12
|
+
export {
|
|
13
|
+
apiErrorNames,
|
|
14
|
+
describeApiError,
|
|
15
|
+
isCip30Api,
|
|
16
|
+
isCip30Provider,
|
|
17
|
+
readApiError,
|
|
18
|
+
REFUSED,
|
|
19
|
+
txSendErrorNames,
|
|
20
|
+
txSignErrorNames,
|
|
21
|
+
USER_DECLINED
|
|
22
|
+
} from "./cip30.js"
|
|
23
|
+
export type { Attempt, CompletionRequest, Receipt } from "./complete.js"
|
|
24
|
+
export { completeIntent } from "./complete.js"
|
|
25
|
+
export type { CompletionRefusal } from "./complete-error.js"
|
|
26
|
+
export { CompletionError, completionRefusals, slipErrorCodeFor as completionErrorCodeFor } from "./complete-error.js"
|
|
9
27
|
export type { ConnectedWallet, ConnectOptions } from "./connect.js"
|
|
10
28
|
export { connectWallet, networkIdFor } from "./connect.js"
|
|
11
29
|
export type { DiscoveredWallet, WalletHost } from "./discovery.js"
|
|
12
30
|
export { discoverWallets, findWallet, injectedUnder } from "./discovery.js"
|
|
13
31
|
export type { KnownWallet } from "./registry.js"
|
|
14
32
|
export { knownWallet, knownWallets } from "./registry.js"
|
|
33
|
+
export { asResolvedInput, asResolvedInputs } from "./resolve.js"
|
|
34
|
+
export type { SignedTransaction, SignRequest } from "./sign.js"
|
|
35
|
+
export { signTransaction, submitTransaction } from "./sign.js"
|
|
36
|
+
export type { SignRefusal } from "./sign-error.js"
|
|
37
|
+
export { signRefusals, SigningError, slipErrorCodeFor as signErrorCodeFor } from "./sign-error.js"
|
|
38
|
+
export { readWalletUtxos } from "./utxo.js"
|
|
15
39
|
export type { ConnectRefusal } from "./wallet-error.js"
|
|
16
40
|
export { connectRefusals, slipErrorCodeFor, WalletConnectError } from "./wallet-error.js"
|
|
41
|
+
export { assembleWitnesses, requireSameBody, transactionIdOf } from "./witness.js"
|
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)
|