@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.
- package/README.md +27 -0
- package/dist/address.d.ts +13 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +54 -0
- package/dist/address.js.map +1 -0
- 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 +76 -0
- package/dist/cip30.d.ts.map +1 -0
- package/dist/cip30.js +69 -0
- package/dist/cip30.js.map +1 -0
- 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/connect.d.ts +45 -0
- package/dist/connect.d.ts.map +1 -0
- package/dist/connect.js +73 -0
- package/dist/connect.js.map +1 -0
- package/dist/discovery.d.ts +37 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +71 -0
- package/dist/discovery.js.map +1 -0
- package/dist/index.d.ts +27 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +18 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +15 -0
- package/dist/registry.js.map +1 -0
- package/dist/resolve.d.ts +11 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +21 -0
- package/dist/resolve.js.map +1 -0
- package/dist/sign-error.d.ts +62 -0
- package/dist/sign-error.d.ts.map +1 -0
- package/dist/sign-error.js +42 -0
- package/dist/sign-error.js.map +1 -0
- package/dist/sign.d.ts +38 -0
- package/dist/sign.d.ts.map +1 -0
- package/dist/sign.js +79 -0
- package/dist/sign.js.map +1 -0
- package/dist/tokens.css +133 -0
- 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/wallet-error.d.ts +48 -0
- package/dist/wallet-error.d.ts.map +1 -0
- package/dist/wallet-error.js +31 -0
- package/dist/wallet-error.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 +10 -5
- package/src/address.ts +69 -0
- package/src/balance-error.ts +77 -0
- package/src/balance.ts +285 -0
- package/src/cip30.ts +113 -0
- package/src/complete-error.ts +83 -0
- package/src/complete.ts +198 -0
- package/src/connect.ts +136 -0
- package/src/discovery.ts +92 -0
- package/src/index.ts +37 -7
- package/src/registry.ts +32 -0
- package/src/resolve.ts +40 -0
- package/src/sign-error.ts +89 -0
- package/src/sign.ts +118 -0
- package/src/tokens.css +133 -0
- package/src/utxo.ts +70 -0
- package/src/wallet-error.ts +68 -0
- package/src/witness.ts +132 -0
package/src/cip30.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CIP-30 surface this client calls, declared here because evolution-sdk's
|
|
3
|
+
* `WalletApi` omits `getChangeAddress`, `getCollateral` and `getNetworkId`
|
|
4
|
+
* while the object a wallet actually hands back carries all three (ADR-0004).
|
|
5
|
+
* A typing we assert rather than one the SDK guarantees, so it is proved
|
|
6
|
+
* against a stubbed provider rather than trusted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Hex-encoded CBOR: the only form CIP-30 speaks, and the form `verifier` decodes. */
|
|
10
|
+
export type CborHex = string
|
|
11
|
+
|
|
12
|
+
export type Paginate = { readonly page: number; readonly limit: number }
|
|
13
|
+
|
|
14
|
+
export type SignedData = { readonly signature: CborHex; readonly key: CborHex }
|
|
15
|
+
|
|
16
|
+
export interface Cip30Api {
|
|
17
|
+
readonly getNetworkId: () => Promise<number>
|
|
18
|
+
readonly getChangeAddress: () => Promise<CborHex>
|
|
19
|
+
readonly getUsedAddresses: (paginate?: Paginate) => Promise<ReadonlyArray<CborHex>>
|
|
20
|
+
readonly getUnusedAddresses: () => Promise<ReadonlyArray<CborHex>>
|
|
21
|
+
readonly getRewardAddresses: () => Promise<ReadonlyArray<CborHex>>
|
|
22
|
+
readonly getBalance: () => Promise<CborHex>
|
|
23
|
+
/** `null` where the wallet has nothing to give, per CIP-30. */
|
|
24
|
+
readonly getUtxos: (amount?: CborHex, paginate?: Paginate) => Promise<ReadonlyArray<CborHex> | null>
|
|
25
|
+
readonly getCollateral: (params?: { readonly amount: CborHex }) => Promise<ReadonlyArray<CborHex> | null>
|
|
26
|
+
/** Returns a **witness set**, never a signed transaction — invariant 4 is CIP-30's own shape. */
|
|
27
|
+
readonly signTx: (tx: CborHex, partialSign?: boolean) => Promise<CborHex>
|
|
28
|
+
readonly signData: (address: CborHex, payload: CborHex) => Promise<SignedData>
|
|
29
|
+
readonly submitTx: (tx: CborHex) => Promise<string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What an extension injects at `window.cardano[key]`, before anyone calls `enable()`. */
|
|
33
|
+
export interface Cip30Provider {
|
|
34
|
+
readonly apiVersion: string
|
|
35
|
+
readonly name: string
|
|
36
|
+
readonly icon: string
|
|
37
|
+
readonly supportedExtensions?: ReadonlyArray<{ readonly cip: number }>
|
|
38
|
+
readonly enable: (extensions?: ReadonlyArray<{ readonly cip: number }>) => Promise<Cip30Api>
|
|
39
|
+
readonly isEnabled: () => Promise<boolean>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isFunction = (value: unknown): boolean => typeof value === "function"
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shape, not name: `window.cardano` is a namespace anything may write to, and a
|
|
46
|
+
* key that does not answer these four is not a connector whatever it is called.
|
|
47
|
+
*/
|
|
48
|
+
export const isCip30Provider = (value: unknown): value is Cip30Provider => {
|
|
49
|
+
if (typeof value !== "object" || value === null) return false
|
|
50
|
+
const candidate = value as Record<string, unknown>
|
|
51
|
+
return (
|
|
52
|
+
isFunction(candidate.enable) &&
|
|
53
|
+
isFunction(candidate.isEnabled) &&
|
|
54
|
+
typeof candidate.name === "string" &&
|
|
55
|
+
typeof candidate.apiVersion === "string"
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The methods we call. A provider may enable and still not answer them. */
|
|
60
|
+
const apiMethods = ["getNetworkId", "getChangeAddress", "getUsedAddresses", "getUtxos", "signTx", "submitTx"] as const
|
|
61
|
+
|
|
62
|
+
export const isCip30Api = (value: unknown): value is Cip30Api => {
|
|
63
|
+
if (typeof value !== "object" || value === null) return false
|
|
64
|
+
const candidate = value as Record<string, unknown>
|
|
65
|
+
return apiMethods.every((method) => isFunction(candidate[method]))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** CIP-30's `APIError`, kept whole: the numeric code is what says a person declined. */
|
|
69
|
+
export type Cip30ApiError = { readonly code: number; readonly info: string }
|
|
70
|
+
|
|
71
|
+
/** The four `APIError` codes CIP-30 defines. Anything else is passed through as its number. */
|
|
72
|
+
export const apiErrorNames: Readonly<Record<number, string>> = {
|
|
73
|
+
[-1]: "InvalidRequest",
|
|
74
|
+
[-2]: "InternalError",
|
|
75
|
+
[-3]: "Refused",
|
|
76
|
+
[-4]: "AccountChange"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `-3` is the person saying no, which is not a fault and must not be shown as one. */
|
|
80
|
+
export const REFUSED = -3
|
|
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
|
+
|
|
101
|
+
/** A wallet rejects with a plain `{ code, info }` object, not an `Error`. */
|
|
102
|
+
export const readApiError = (cause: unknown): Cip30ApiError | undefined => {
|
|
103
|
+
if (typeof cause !== "object" || cause === null) return undefined
|
|
104
|
+
const candidate = cause as Record<string, unknown>
|
|
105
|
+
if (typeof candidate.code !== "number") return undefined
|
|
106
|
+
return { code: candidate.code, info: typeof candidate.info === "string" ? candidate.info : "" }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const describeApiError = (error: Cip30ApiError): string => {
|
|
110
|
+
const name = apiErrorNames[error.code]
|
|
111
|
+
const label = name === undefined ? `code ${error.code}` : name
|
|
112
|
+
return error.info === "" ? label : `${label}: ${error.info}`
|
|
113
|
+
}
|
|
@@ -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/connect.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `enable()` and the three answers a connection is not usable without: the API
|
|
3
|
+
* object, the network, and the change address. Everything downstream of here —
|
|
4
|
+
* UTxOs, balancing, signing — goes through evolution-sdk with this API object
|
|
5
|
+
* (ADR-0004).
|
|
6
|
+
*/
|
|
7
|
+
import { type Network } from "@cardano-slips/core"
|
|
8
|
+
import { Effect } from "effect"
|
|
9
|
+
|
|
10
|
+
import { type NetworkId, readWalletAddress } from "./address.js"
|
|
11
|
+
import { type Cip30Api, describeApiError, isCip30Api, readApiError, REFUSED } from "./cip30.js"
|
|
12
|
+
import { type DiscoveredWallet, findWallet, injectedUnder } from "./discovery.js"
|
|
13
|
+
import { refuse, type WalletConnectError } from "./wallet-error.js"
|
|
14
|
+
|
|
15
|
+
export type ConnectOptions = {
|
|
16
|
+
/** The network the Slip declares. The wallet is held to it, not asked for it. */
|
|
17
|
+
readonly network: Network
|
|
18
|
+
/** Where `cardano` lives. Defaults to the global object, so a test never needs one. */
|
|
19
|
+
readonly host?: unknown
|
|
20
|
+
/** CIP extensions to ask for. A wallet may grant fewer, and none of ours are required. */
|
|
21
|
+
readonly extensions?: ReadonlyArray<{ readonly cip: number }>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ConnectedWallet = {
|
|
25
|
+
readonly wallet: DiscoveredWallet
|
|
26
|
+
readonly api: Cip30Api
|
|
27
|
+
/** The Slip's network, now known to be the wallet's as well. */
|
|
28
|
+
readonly network: Network
|
|
29
|
+
readonly networkId: NetworkId
|
|
30
|
+
/** Bech32, ready for a `BuildRequest`. */
|
|
31
|
+
readonly changeAddress: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A CIP-19 address separates mainnet from testnet and no further, so this is
|
|
36
|
+
* every network id there is — preprod and preview are one value here. Telling
|
|
37
|
+
* them apart is exactly why a Slip states its network by name (`core/types.ts`).
|
|
38
|
+
*/
|
|
39
|
+
export const networkIdFor = (network: Network): NetworkId => (network === "mainnet" ? 1 : 0)
|
|
40
|
+
|
|
41
|
+
const networkNames: Readonly<Record<NetworkId, string>> = { 0: "a test network", 1: "mainnet" }
|
|
42
|
+
|
|
43
|
+
const call = <A>(key: string, method: string, run: () => Promise<A>): Effect.Effect<A, WalletConnectError> =>
|
|
44
|
+
Effect.tryPromise({
|
|
45
|
+
try: run,
|
|
46
|
+
catch: (cause) => {
|
|
47
|
+
const cip30 = readApiError(cause)
|
|
48
|
+
return refuse(
|
|
49
|
+
"Unreadable",
|
|
50
|
+
key,
|
|
51
|
+
`${method} failed: ${cip30 === undefined ? String(cause) : describeApiError(cip30)}`,
|
|
52
|
+
cip30
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Connects, or says why not. The network check happens here rather than in a
|
|
59
|
+
* component because it decides whether there is a connection at all: a wallet
|
|
60
|
+
* on another chain cannot sign this Slip's transaction, and letting the flow
|
|
61
|
+
* continue only moves the failure to where it costs a person more.
|
|
62
|
+
*/
|
|
63
|
+
export const connectWallet = (
|
|
64
|
+
key: string,
|
|
65
|
+
options: ConnectOptions
|
|
66
|
+
): Effect.Effect<ConnectedWallet, WalletConnectError> =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const host = options.host ?? globalThis
|
|
69
|
+
const wallet = findWallet(key, host)
|
|
70
|
+
if (wallet === undefined) {
|
|
71
|
+
return yield* Effect.fail(
|
|
72
|
+
injectedUnder(key, host) === undefined
|
|
73
|
+
? refuse("NotInjected", key, "no wallet is installed under this name")
|
|
74
|
+
: refuse("NotCip30", key, "what is installed under this name does not implement CIP-30")
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const api = yield* Effect.tryPromise({
|
|
79
|
+
try: () => wallet.provider.enable(options.extensions),
|
|
80
|
+
catch: (cause) => {
|
|
81
|
+
const cip30 = readApiError(cause)
|
|
82
|
+
if (cip30?.code === REFUSED) {
|
|
83
|
+
return refuse("Refused", key, "the connection was declined in the wallet", cip30)
|
|
84
|
+
}
|
|
85
|
+
const detail = cip30 === undefined ? String(cause) : describeApiError(cip30)
|
|
86
|
+
return refuse("EnableFailed", key, `the wallet could not be connected: ${detail}`, cip30)
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// `enable()` is typed to return the API and is under no obligation to.
|
|
91
|
+
if (!isCip30Api(api)) {
|
|
92
|
+
return yield* Effect.fail(refuse("NotCip30", key, "the wallet connected without returning a CIP-30 API"))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const reported = yield* call(key, "getNetworkId", () => api.getNetworkId())
|
|
96
|
+
if (reported !== 0 && reported !== 1) {
|
|
97
|
+
return yield* Effect.fail(refuse("Unreadable", key, `getNetworkId answered ${String(reported)}`))
|
|
98
|
+
}
|
|
99
|
+
const networkId: NetworkId = reported === 1 ? 1 : 0
|
|
100
|
+
|
|
101
|
+
const hex = yield* call(key, "getChangeAddress", () => api.getChangeAddress())
|
|
102
|
+
const address = yield* readWalletAddress(hex).pipe(
|
|
103
|
+
Effect.mapError((detail) => refuse("Unreadable", key, `getChangeAddress answered ${detail}`))
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// Two answers about one wallet. A wallet that gives both cannot be taken at
|
|
107
|
+
// either word, and the disagreement is not the person's to resolve.
|
|
108
|
+
if (address.networkId !== networkId) {
|
|
109
|
+
return yield* Effect.fail(
|
|
110
|
+
refuse(
|
|
111
|
+
"Unreadable",
|
|
112
|
+
key,
|
|
113
|
+
`the wallet reports ${networkNames[networkId]} and its change address is on ${networkNames[address.networkId]}`
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const expected = networkIdFor(options.network)
|
|
119
|
+
if (networkId !== expected) {
|
|
120
|
+
return yield* Effect.fail(
|
|
121
|
+
refuse(
|
|
122
|
+
"WrongNetwork",
|
|
123
|
+
key,
|
|
124
|
+
`this Slip is on ${options.network} and the wallet is on ${networkNames[networkId]}`
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
wallet,
|
|
131
|
+
api,
|
|
132
|
+
network: options.network,
|
|
133
|
+
networkId,
|
|
134
|
+
changeAddress: address.bech32
|
|
135
|
+
}
|
|
136
|
+
})
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is injected at `window.cardano`, read on demand. Nothing here touches a
|
|
3
|
+
* browser global at module scope: this SDK is imported by server-rendered pages
|
|
4
|
+
* we do not control, and a module that reads `window` while loading breaks them
|
|
5
|
+
* before any of it runs (ADR-0004).
|
|
6
|
+
*/
|
|
7
|
+
import { type Cip30Provider, isCip30Provider } from "./cip30.js"
|
|
8
|
+
import { knownWallet } from "./registry.js"
|
|
9
|
+
|
|
10
|
+
export type DiscoveredWallet = {
|
|
11
|
+
/** The `window.cardano` key. What `connectWallet` is asked for. */
|
|
12
|
+
readonly key: string
|
|
13
|
+
readonly name: string
|
|
14
|
+
readonly icon: string | undefined
|
|
15
|
+
readonly apiVersion: string
|
|
16
|
+
/** CIP numbers the provider says it supports, before `enable()` negotiates any of them. */
|
|
17
|
+
readonly extensions: ReadonlyArray<number>
|
|
18
|
+
/** Present only for a wallet in the registry. */
|
|
19
|
+
readonly install: string | undefined
|
|
20
|
+
readonly provider: Cip30Provider
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Anything with a `cardano` namespace: a `Window`, or a stand-in in a test. */
|
|
24
|
+
export type WalletHost = { readonly cardano?: unknown }
|
|
25
|
+
|
|
26
|
+
const namespaceOf = (host: unknown): Record<string, unknown> | undefined => {
|
|
27
|
+
if (typeof host !== "object" || host === null) return undefined
|
|
28
|
+
const cardano = (host as Record<string, unknown>).cardano
|
|
29
|
+
if (typeof cardano !== "object" || cardano === null) return undefined
|
|
30
|
+
return cardano as Record<string, unknown>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const extensionsOf = (provider: Cip30Provider): ReadonlyArray<number> =>
|
|
34
|
+
(provider.supportedExtensions ?? [])
|
|
35
|
+
.filter((extension) => typeof extension?.cip === "number")
|
|
36
|
+
.map((extension) => extension.cip)
|
|
37
|
+
|
|
38
|
+
const describe = (key: string, provider: Cip30Provider): DiscoveredWallet => {
|
|
39
|
+
const known = knownWallet(key)
|
|
40
|
+
return {
|
|
41
|
+
key,
|
|
42
|
+
// The registry wins over the injected name so one extension reads the same
|
|
43
|
+
// way in every list; an unknown wallet still gets to name itself.
|
|
44
|
+
name: known?.name ?? (provider.name === "" ? key : provider.name),
|
|
45
|
+
icon: typeof provider.icon === "string" && provider.icon !== "" ? provider.icon : undefined,
|
|
46
|
+
apiVersion: provider.apiVersion,
|
|
47
|
+
extensions: extensionsOf(provider),
|
|
48
|
+
install: known?.install,
|
|
49
|
+
provider
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every CIP-30 provider in the namespace, by display name. Returns an empty
|
|
55
|
+
* list where there is no namespace at all — a page with no wallet and a page
|
|
56
|
+
* rendered on a server are the same case to a caller, and neither is an error.
|
|
57
|
+
*/
|
|
58
|
+
export const discoverWallets = (host: unknown = globalThis): ReadonlyArray<DiscoveredWallet> => {
|
|
59
|
+
const namespace = namespaceOf(host)
|
|
60
|
+
if (namespace === undefined) return []
|
|
61
|
+
|
|
62
|
+
const found = new Map<string, DiscoveredWallet>()
|
|
63
|
+
for (const key of Object.keys(namespace)) {
|
|
64
|
+
const provider = namespace[key]
|
|
65
|
+
if (isCip30Provider(provider)) found.set(key, describe(key, provider))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// An extension injecting two keys is one wallet: listing it twice asks the
|
|
69
|
+
// person to choose between a wallet and itself.
|
|
70
|
+
for (const key of [...found.keys()]) {
|
|
71
|
+
const alias = knownWallet(key)?.aliasOf
|
|
72
|
+
if (alias !== undefined && found.has(alias)) found.delete(key)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [...found.values()].sort(
|
|
76
|
+
(left, right) => left.name.localeCompare(right.name) || left.key.localeCompare(right.key)
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whatever sits under the key, connector or not. Lets a caller separate a
|
|
82
|
+
* wallet that is not installed from one that is and does not speak CIP-30.
|
|
83
|
+
*/
|
|
84
|
+
export const injectedUnder = (key: string, host: unknown = globalThis): unknown => namespaceOf(host)?.[key]
|
|
85
|
+
|
|
86
|
+
/** The provider under one key, or `undefined` where nothing usable is there. */
|
|
87
|
+
export const findWallet = (key: string, host: unknown = globalThis): DiscoveredWallet | undefined => {
|
|
88
|
+
const namespace = namespaceOf(host)
|
|
89
|
+
if (namespace === undefined) return undefined
|
|
90
|
+
const provider = namespace[key]
|
|
91
|
+
return isCip30Provider(provider) ? describe(key, provider) : undefined
|
|
92
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The public entry point of `@cardano-slips/flow`. The `exports` map exposes
|
|
3
3
|
* this module and nothing else, so moving a file is never a breaking change.
|
|
4
|
-
*
|
|
5
|
-
* Two properties hold as the rest of the package arrives. It refuses but does
|
|
6
|
-
* not decide — the comparison is `verifier`'s, and a mismatch renders as a
|
|
7
|
-
* block with no override path to write. And it does not own the page it is
|
|
8
|
-
* dropped into: no fixed positioning, no assumptions about the document, and
|
|
9
|
-
* colours only from the custom properties in `tokens.css`.
|
|
10
4
|
*/
|
|
11
|
-
export {}
|
|
5
|
+
export type { NetworkId, WalletAddress } from "./address.js"
|
|
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"
|
|
11
|
+
export type { CborHex, Cip30Api, Cip30ApiError, Cip30Provider, Paginate, SignedData } from "./cip30.js"
|
|
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"
|
|
27
|
+
export type { ConnectedWallet, ConnectOptions } from "./connect.js"
|
|
28
|
+
export { connectWallet, networkIdFor } from "./connect.js"
|
|
29
|
+
export type { DiscoveredWallet, WalletHost } from "./discovery.js"
|
|
30
|
+
export { discoverWallets, findWallet, injectedUnder } from "./discovery.js"
|
|
31
|
+
export type { KnownWallet } from "./registry.js"
|
|
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"
|
|
39
|
+
export type { ConnectRefusal } from "./wallet-error.js"
|
|
40
|
+
export { connectRefusals, slipErrorCodeFor, WalletConnectError } from "./wallet-error.js"
|
|
41
|
+
export { assembleWitnesses, requireSameBody, transactionIdOf } from "./witness.js"
|