Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/evolution/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export * from "./sdk/client/Client.js"
export { createClient } from "./sdk/client/ClientImpl.js"
export * from "./sdk/client/Providers.js"
export * from "./sdk/client/Wallets.js"
export { ProviderError } from "./sdk/provider/Provider.js"
export * as SingleHostAddr from "./SingleHostAddr.js"
export * as SingleHostName from "./SingleHostName.js"
export * as StakeReference from "./StakeReference.js"
Expand Down
18 changes: 5 additions & 13 deletions packages/evolution/src/sdk/client/Client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Data, type Effect } from "effect"
import { type Effect } from "effect"

import type * as CoreUTxO from "../../UTxO.js"
import type { ReadOnlyTransactionBuilder, SigningTransactionBuilder } from "../builders/TransactionBuilder.js"
Expand All @@ -8,17 +8,6 @@ import type * as WalletNew from "../wallet/WalletNew.js"
import type { Chain } from "./Chain.js"
import type { AnyWallet } from "./Wallets.js"

/**
* Error class for provider-related operations.
*
* @since 2.0.0
* @category errors
*/
export class ProviderError extends Data.TaggedError("ProviderError")<{
message?: string
cause?: unknown
}> {}

/**
* MinimalClient Effect - holds chain context.
*
Expand Down Expand Up @@ -47,7 +36,10 @@ export interface ReadOnlyClientEffect extends Provider.ProviderEffect, WalletNew
* @category model
*/
export interface SigningClientEffect extends Provider.ProviderEffect, WalletNew.SigningWalletEffect {
readonly getWalletUtxos: () => Effect.Effect<ReadonlyArray<CoreUTxO.UTxO>, WalletNew.WalletError | Provider.ProviderError>
readonly getWalletUtxos: () => Effect.Effect<
ReadonlyArray<CoreUTxO.UTxO>,
WalletNew.WalletError | Provider.ProviderError
>
readonly getWalletDelegation: () => Effect.Effect<Provider.Delegation, WalletNew.WalletError | Provider.ProviderError>
}

Expand Down
25 changes: 20 additions & 5 deletions packages/evolution/src/sdk/client/ClientImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ReadOnlyClient,
type ReadOnlyWalletClient,
type SigningClient,
type SigningClientEffect,
type SigningWalletClient
} from "./Client.js"
import { type AnyWallet, type WalletFactory } from "./Wallets.js"
Expand Down Expand Up @@ -152,9 +153,23 @@ const createSigningClient = (
return yield* wallet.Effect.signTx(txOrHex, { ...context, referenceUtxos })
})

const effectInterface = {
...wallet.Effect,
...provider.Effect,
const effectInterface: SigningClientEffect = {
// Provider methods — explicit to avoid silent overrides if provider gains new fields
getProtocolParameters: provider.Effect.getProtocolParameters.bind(provider.Effect),
getUtxos: provider.Effect.getUtxos.bind(provider.Effect),
getUtxosWithUnit: provider.Effect.getUtxosWithUnit.bind(provider.Effect),
getUtxoByUnit: provider.Effect.getUtxoByUnit.bind(provider.Effect),
getUtxosByOutRef: provider.Effect.getUtxosByOutRef.bind(provider.Effect),
getDelegation: provider.Effect.getDelegation.bind(provider.Effect),
getDatum: provider.Effect.getDatum.bind(provider.Effect),
awaitTx: provider.Effect.awaitTx.bind(provider.Effect),
submitTx: provider.Effect.submitTx.bind(provider.Effect),
evaluateTx: provider.Effect.evaluateTx.bind(provider.Effect),
// Wallet methods
address: wallet.Effect.address.bind(wallet.Effect),
rewardAddress: wallet.Effect.rewardAddress.bind(wallet.Effect),
signMessage: wallet.Effect.signMessage.bind(wallet.Effect),
// Composite methods
signTx: signTxWithAutoFetch,
getWalletUtxos: () => Effect.flatMap(wallet.Effect.address(), (addr) => provider.Effect.getUtxos(addr)),
getWalletDelegation: () =>
Expand All @@ -166,8 +181,8 @@ const createSigningClient = (
}

return {
...provider,
...wallet,
...provider,
signTx: (txOrHex, context) => Effect.runPromise(signTxWithAutoFetch(txOrHex, context)),
getWalletUtxos: () => Effect.runPromise(effectInterface.getWalletUtxos()),
getWalletDelegation: () => Effect.runPromise(effectInterface.getWalletDelegation()),
Expand Down Expand Up @@ -242,7 +257,7 @@ export function createClient(config: { chain?: Chain; wallet: WalletNew.ApiWalle
// Signing Wallet or Factory only → SigningWalletClient
export function createClient(config: {
chain?: Chain
wallet: WalletNew.SigningWallet | AnyWallet
wallet: WalletNew.SigningWallet | WalletFactory
}): SigningWalletClient

// Chain only or minimal → MinimalClient
Expand Down
166 changes: 109 additions & 57 deletions packages/evolution/src/sdk/client/Wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as Bytes from "../../Bytes.js"
import * as Ed25519Signature from "../../Ed25519Signature.js"
import * as KeyHash from "../../KeyHash.js"
import type * as NativeScripts from "../../NativeScripts.js"
import * as PoolKeyHash from "../../PoolKeyHash.js"
import * as PrivateKey from "../../PrivateKey.js"
import * as CoreRewardAccount from "../../RewardAccount.js"
import * as CoreRewardAddress from "../../RewardAddress.js"
Expand Down Expand Up @@ -104,23 +105,59 @@ const computeRequiredKeyHashesSync = (params: {
}
}

if (params.tx.body.certificates && params.stakeKhHex) {
if (params.tx.body.certificates && (params.stakeKhHex || params.paymentKhHex)) {
for (const cert of params.tx.body.certificates) {
const cred =
cert._tag === "StakeRegistration" || cert._tag === "StakeDeregistration" || cert._tag === "StakeDelegation"
? cert.stakeCredential
: cert._tag === "RegCert" || cert._tag === "UnregCert"
// Stake credential certs — require stake key
if (params.stakeKhHex) {
const stakeCred =
cert._tag === "StakeRegistration" ||
cert._tag === "StakeDeregistration" ||
cert._tag === "StakeDelegation" ||
cert._tag === "RegCert" ||
cert._tag === "UnregCert" ||
cert._tag === "StakeVoteDelegCert" ||
cert._tag === "StakeRegDelegCert" ||
cert._tag === "StakeVoteRegDelegCert" ||
cert._tag === "VoteDelegCert" ||
cert._tag === "VoteRegDelegCert"
? cert.stakeCredential
: cert._tag === "StakeVoteDelegCert" ||
cert._tag === "StakeRegDelegCert" ||
cert._tag === "StakeVoteRegDelegCert" ||
cert._tag === "VoteDelegCert" ||
cert._tag === "VoteRegDelegCert"
? cert.stakeCredential
: undefined
if (cred && cred._tag === "KeyHash") {
const khHex = KeyHash.toHex(cred)
if (khHex === params.stakeKhHex) required.add(params.stakeKhHex)
: undefined
if (stakeCred && stakeCred._tag === "KeyHash") {
const khHex = KeyHash.toHex(stakeCred)
if (khHex === params.stakeKhHex) required.add(params.stakeKhHex)
}

// DRep credential certs — DRep key is typically the stake key
const drepCred =
cert._tag === "RegDrepCert" || cert._tag === "UnregDrepCert" || cert._tag === "UpdateDrepCert"
? cert.drepCredential
: undefined
if (drepCred && drepCred._tag === "KeyHash") {
const khHex = KeyHash.toHex(drepCred)
if (khHex === params.stakeKhHex) required.add(params.stakeKhHex)
}

// Committee cold credential certs — cold key is typically the stake key
const committeeColdCred =
cert._tag === "AuthCommitteeHotCert" || cert._tag === "ResignCommitteeColdCert"
? cert.committeeColdCredential
: undefined
if (committeeColdCred && committeeColdCred._tag === "KeyHash") {
const khHex = KeyHash.toHex(committeeColdCred)
if (khHex === params.stakeKhHex) required.add(params.stakeKhHex)
}
}

// Pool certs — pool operator key is typically the payment key
if (params.paymentKhHex) {
if (cert._tag === "PoolRegistration") {
const operatorHex = PoolKeyHash.toHex(cert.poolParams.operator)
if (operatorHex === params.paymentKhHex) required.add(params.paymentKhHex)
}
if (cert._tag === "PoolRetirement") {
const poolKhHex = PoolKeyHash.toHex(cert.poolKeyHash)
if (poolKhHex === params.paymentKhHex) required.add(params.paymentKhHex)
}
}
}
}
Expand Down Expand Up @@ -177,11 +214,19 @@ const makeSigningWalletEffect = (
}
return witnesses.length > 0 ? TransactionWitnessSet.fromVKeyWitnesses(witnesses) : TransactionWitnessSet.empty()
}),
signMessage: (_address: CoreAddress.Address | CoreRewardAddress.RewardAddress, payload: WalletNew.Payload) =>
Effect.map(derivationEffect, (derivation) => {
const paymentSk = PrivateKey.fromBech32(derivation.paymentKey)
signMessage: (address: CoreAddress.Address | CoreRewardAddress.RewardAddress, payload: WalletNew.Payload) =>
Effect.gen(function* () {
const derivation = yield* derivationEffect
// Use stake key when signing for a reward address, payment key otherwise.
// Reward addresses require the stake credential key per CIP-0008.
const useStakeKey =
typeof address === "string" && // RewardAddress is a branded string
derivation.stakeKey !== undefined
const sk = useStakeKey
Comment on lines +222 to +225
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signMessage decides to use the stake key whenever address is a string and derivation.stakeKey is defined. If a caller passes a reward address but the wallet was derived without a stake key (e.g. enterprise/private-key wallet without stakeKey), this currently falls back to signing with the payment key, producing a signature that won’t verify for the reward address per CIP-0008. Consider explicitly failing with WalletError when address is a reward address but derivation.stakeKey is missing.

Suggested change
const useStakeKey =
typeof address === "string" && // RewardAddress is a branded string
derivation.stakeKey !== undefined
const sk = useStakeKey
const isRewardAddress = typeof address === "string" // RewardAddress is a branded string
if (isRewardAddress && derivation.stakeKey === undefined) {
throw new WalletNew.WalletError({
message: "Cannot sign message for reward address: wallet does not have a stake key"
})
}
const sk = isRewardAddress

Copilot uses AI. Check for mistakes.
? PrivateKey.fromBech32(derivation.stakeKey!)
: PrivateKey.fromBech32(derivation.paymentKey)
const bytes = typeof payload === "string" ? new TextEncoder().encode(payload) : payload
const sig = PrivateKey.sign(paymentSk, bytes)
const sig = PrivateKey.sign(sk, bytes)
return { payload, signature: Ed25519Signature.toHex(sig) }
})
}
Expand Down Expand Up @@ -335,48 +380,55 @@ export const privateKeyWallet =
* @category constructors
*/
export const cip30Wallet = (api: WalletNew.WalletApi): WalletNew.ApiWallet => {
let cachedAddress: CoreAddress.Address | null = null
let cachedReward: CoreRewardAddress.RewardAddress | null = null

const getPrimaryAddress = Effect.gen(function* () {
if (cachedAddress) return cachedAddress
const used = yield* Effect.tryPromise({
try: () => api.getUsedAddresses(),
catch: (cause) => new WalletNew.WalletError({ message: (cause as Error).message, cause: cause as Error })
})
const unused = yield* Effect.tryPromise({
try: () => api.getUnusedAddresses(),
catch: (cause) => new WalletNew.WalletError({ message: (cause as Error).message, cause: cause as Error })
})
const addrStr = used[0] ?? unused[0]
if (!addrStr) {
return yield* Effect.fail(new WalletNew.WalletError({ message: "Wallet API returned no addresses", cause: null }))
// Cache the address fetch as a single Promise so concurrent callers share the
// same in-flight request and subsequent calls reuse the settled result.
let addressPromise: Promise<CoreAddress.Address> | null = null
let rewardAddressPromise: Promise<CoreRewardAddress.RewardAddress | null> | null = null

const fetchPrimaryAddress = (): Promise<CoreAddress.Address> => {
if (!addressPromise) {
addressPromise = (async () => {
const used = await api.getUsedAddresses()
const unused = await api.getUnusedAddresses()
const addrStr = used[0] ?? unused[0]
if (!addrStr) throw new WalletNew.WalletError({ message: "Wallet API returned no addresses", cause: null })
try {
return CoreAddress.fromBech32(addrStr)
} catch {
try {
return CoreAddress.fromHex(addrStr)
} catch (error) {
throw new WalletNew.WalletError({
message: `Invalid address format from wallet: ${addrStr}`,
cause: error as Error
})
}
}
})()
}
try {
cachedAddress = CoreAddress.fromBech32(addrStr)
} catch {
try {
cachedAddress = CoreAddress.fromHex(addrStr)
} catch (error) {
return yield* Effect.fail(
new WalletNew.WalletError({
message: `Invalid address format from wallet: ${addrStr}`,
cause: error as Error
})
)
}
return addressPromise
}

const fetchPrimaryRewardAddress = (): Promise<CoreRewardAddress.RewardAddress | null> => {
if (!rewardAddressPromise) {
rewardAddressPromise = api
.getRewardAddresses()
.then((rewards) => (rewards[0] ? Schema.decodeSync(CoreRewardAddress.RewardAddress)(rewards[0]) : null))
}
return cachedAddress
return rewardAddressPromise
}
Comment on lines +388 to +419
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Promise-based caches (addressPromise / rewardAddressPromise) become permanently “poisoned” on the first rejection (e.g. transient CIP-30 error, user not connected yet). Subsequent calls will reuse the rejected Promise and never retry. Consider resetting the cached promise back to null on rejection (e.g. addressPromise = fetch().catch(e => { addressPromise = null; throw e })) so callers can recover from transient failures.

Copilot uses AI. Check for mistakes.

const getPrimaryAddress = Effect.tryPromise({
try: fetchPrimaryAddress,
catch: (cause) =>
cause instanceof WalletNew.WalletError
? cause
: new WalletNew.WalletError({ message: (cause as Error).message, cause: cause as Error })
})

const getPrimaryRewardAddress = Effect.gen(function* () {
if (cachedReward !== null) return cachedReward
const rewards = yield* Effect.tryPromise({
try: () => api.getRewardAddresses(),
catch: (cause) => new WalletNew.WalletError({ message: (cause as Error).message, cause: cause as Error })
})
cachedReward = rewards[0] ? Schema.decodeSync(CoreRewardAddress.RewardAddress)(rewards[0]) : null
return cachedReward
const getPrimaryRewardAddress = Effect.tryPromise({
try: fetchPrimaryRewardAddress,
catch: (cause) => new WalletNew.WalletError({ message: (cause as Error).message, cause: cause as Error })
})

const effectInterface: WalletNew.ApiWalletEffect = {
Expand Down
2 changes: 1 addition & 1 deletion packages/evolution/src/sdk/wallet/WalletNew.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export interface WalletApi {
export interface ApiWalletEffect extends ReadOnlyWalletEffect {
readonly signTx: (
tx: Transaction.Transaction | string,
context?: { utxos?: ReadonlyArray<CoreUTxO.UTxO>; referenceUtxos?: ReadonlyArray<CoreUTxO.UTxO> }
context?: { utxos?: ReadonlyArray<CoreUTxO.UTxO> }
) => Effect.Effect<TransactionWitnessSet.TransactionWitnessSet, WalletError>
readonly signMessage: (
address: CoreAddress.Address | RewardAddress.RewardAddress,
Expand Down
Loading