diff --git a/.changeset/quorum-act-as-quorum.md b/.changeset/quorum-act-as-quorum.md new file mode 100644 index 000000000..5cb4d6577 --- /dev/null +++ b/.changeset/quorum-act-as-quorum.md @@ -0,0 +1,5 @@ +--- +"@crossmint/wallets-sdk": minor +--- + +A key that is only a quorum member acts as its quorum. With no `signer` option, `send`, `sendTransaction` (every chain), `signMessage` and `signTypedData` create the request as that key's quorum, and throw `QuorumSelectionAmbiguousError` when the key is a member of several quorums. `signMessage` and `signTypedData` take a `signer` option, like `send`, so `signer: "quorum:"` requests an ERC-1271 signature as that quorum. A request made as a quorum, named or inferred, is created unsigned and returned as with `prepareOnly: true`: `transactionId` with `hash` and `explorerLink` undefined, or `signatureId` with `signature` undefined. Members sign it later with `approve`. Pass `prepareOnly: true` when a quorum may act, so the return type matches. Other keys, and Stellar `upgrade` / `migrate`, send their signer as before. diff --git a/packages/wallets/src/utils/recovery.test.ts b/packages/wallets/src/utils/recovery.test.ts index 097d8d171..f680a8b41 100644 --- a/packages/wallets/src/utils/recovery.test.ts +++ b/packages/wallets/src/utils/recovery.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { hasRecoveryMethodList, recoveryMethodsFromCreateArgs } from "./recovery"; +import { hasRecoveryMethodList, recoveryMethodsFromCreateArgs, recoveryMethodsFromWalletConfig } from "./recovery"; import { InvalidRecoveryConfigError } from "./errors"; import { walletsLogger } from "../logger"; import type { WalletCreateArgs } from "../wallets/types"; @@ -93,3 +93,22 @@ describe("hasRecoveryMethodList", () => { expect(hasRecoveryMethodList({})).toBe(false); }); }); + +describe("recoveryMethodsFromWalletConfig", () => { + test("returns the config's recoveryMethods list when the API sends one", () => { + expect( + recoveryMethodsFromWalletConfig({ + adminSigner: API_KEY_METHOD, + recoveryMethods: [API_KEY_METHOD, EMAIL_METHOD], + }) + ).toEqual([API_KEY_METHOD, EMAIL_METHOD]); + }); + + test("falls back to the deprecated adminSigner alone", () => { + expect(recoveryMethodsFromWalletConfig({ adminSigner: EMAIL_METHOD })).toEqual([EMAIL_METHOD]); + }); + + test("returns no recovery method when the config has neither", () => { + expect(recoveryMethodsFromWalletConfig({})).toEqual([]); + }); +}); diff --git a/packages/wallets/src/utils/recovery.ts b/packages/wallets/src/utils/recovery.ts index 78dec298f..0143ac8f7 100644 --- a/packages/wallets/src/utils/recovery.ts +++ b/packages/wallets/src/utils/recovery.ts @@ -1,4 +1,5 @@ import type { Chain } from "../chains/chains"; +import type { RecoverySignerConfigForChain } from "../signers/types"; import { walletsLogger } from "../logger"; import type { RecoverySignerConfigFor } from "../wallets/types"; import type { WalletCreateArgs } from "../wallets/types"; @@ -37,6 +38,15 @@ export function recoveryMethodsFromCreateArgs( return [args.recovery]; } +/** The recovery methods the wallets API lists in a wallet's config: `recoveryMethods`, else the deprecated `adminSigner`. */ +export function recoveryMethodsFromWalletConfig(config: { + adminSigner?: unknown; + recoveryMethods?: unknown[]; +}): Array> { + const recoveryMethods = config.recoveryMethods ?? (config.adminSigner != null ? [config.adminSigner] : []); + return recoveryMethods as Array>; +} + /** True when the caller supplied a list of recovery methods, through `recoveryMethods` or a list under the deprecated `recovery`. */ export function hasRecoveryMethodList( args: Pick, "recovery" | "recoveryMethods"> diff --git a/packages/wallets/src/utils/signer-mapping.test.ts b/packages/wallets/src/utils/signer-mapping.test.ts index ffcc4c1db..d700bbb1b 100644 --- a/packages/wallets/src/utils/signer-mapping.test.ts +++ b/packages/wallets/src/utils/signer-mapping.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Signer as APISigner } from "../api"; -import { getPendingSignerOperation, mapApiSignerToSigner } from "./signer-mapping"; +import { getPendingSignerOperation, isSignerOnChain, mapApiSignerToSigner } from "./signer-mapping"; import { createQuorumApiEntry } from "../wallets/__tests__/test-helpers"; describe("getPendingSignerOperation", () => { @@ -142,3 +142,37 @@ describe("mapApiSignerToSigner", () => { }); }); }); + +describe("isSignerOnChain", () => { + const signer = { type: "external-wallet", address: "0x123", locator: "external-wallet:0x123" }; + + it.each([ + { + name: "an EVM signer that lists this chain", + listed: { ...signer, chains: { "base-sepolia": {} } }, + chain: "base-sepolia", + expected: true, + }, + { + name: "an EVM signer that lists other chains only", + listed: { ...signer, chains: { polygon: {} } }, + chain: "base-sepolia", + expected: false, + }, + { + name: "an EVM signer created with the wallet (no chains)", + listed: signer, + chain: "base-sepolia", + expected: true, + }, + { + name: "an EVM signer with an empty chains map", + listed: { ...signer, chains: {} }, + chain: "base-sepolia", + expected: true, + }, + { name: "a Stellar signer", listed: { ...signer, chains: { polygon: {} } }, chain: "stellar", expected: true }, + ] as const)("is $expected for $name", ({ listed, chain, expected }) => { + expect(isSignerOnChain(listed, chain)).toBe(expected); + }); +}); diff --git a/packages/wallets/src/utils/signer-mapping.ts b/packages/wallets/src/utils/signer-mapping.ts index a4ca2934d..24d0f348b 100644 --- a/packages/wallets/src/utils/signer-mapping.ts +++ b/packages/wallets/src/utils/signer-mapping.ts @@ -87,17 +87,23 @@ export function mapApiSignerToSigner(apiSigner: APISigner, chain: Chain): Signer return { ...base, status, ...(scopes != null && { scopes }) } as Signer; } - // For EVM, status comes from the chains field - if ("chains" in apiSigner && apiSigner.chains != null && Object.keys(apiSigner.chains).length > 0) { - const chainEntry = apiSigner.chains[chain]; - if (chainEntry == null) { - return null; // No approval for this chain - } - return { ...base, status: chainEntry.status, ...(scopes != null && { scopes }) } as Signer; + if (!isSignerOnChain(apiSigner, chain)) { + return null; // No approval for this chain } + // For EVM, status comes from the chains field; with none, the signer was created during wallet creation. + const chainEntry = "chains" in apiSigner ? apiSigner.chains?.[chain] : undefined; + return { ...base, status: chainEntry?.status ?? "success", ...(scopes != null && { scopes }) } as Signer; +} - // If chains field is empty, the signer was created during wallet creation. - return { ...base, status: "success", ...(scopes != null && { scopes }) } as Signer; +/** Whether a listed signer is registered on `chain`. An EVM signer lists its chains; with none, it is on every chain. */ +export function isSignerOnChain( + apiSigner: { locator: string; chains?: Record | null }, + chain: Chain +): boolean { + if (chain === "solana" || chain === "stellar" || apiSigner.chains == null) { + return true; + } + return Object.keys(apiSigner.chains).length === 0 || apiSigner.chains[chain] != null; } export function getPendingSignerOperation(apiSigner: APISigner, chain: Chain): PendingSignerOperation | null { diff --git a/packages/wallets/src/wallets/__tests__/test-helpers.ts b/packages/wallets/src/wallets/__tests__/test-helpers.ts index 745c832ed..5d22cd956 100644 --- a/packages/wallets/src/wallets/__tests__/test-helpers.ts +++ b/packages/wallets/src/wallets/__tests__/test-helpers.ts @@ -189,6 +189,12 @@ export const createQuorumApiEntry = ({ threshold = 2, memberCount = 3 } = {}) => } satisfies QuorumSigner; }; +// A quorum entry whose first members are `members`; the rest are random email members. +export const createQuorumApiEntryWith = (members: QuorumMember[], { threshold = 2, memberCount = 3 } = {}) => { + const quorum = createQuorumApiEntry({ threshold, memberCount }); + return { ...quorum, members: [...members, ...quorum.members.slice(members.length)] } satisfies QuorumSigner; +}; + // A quorum entry of `approvals.pending` as the wallets API returns it. Each member gets its own random message. export const createQuorumPendingApproval = ({ quorum, @@ -220,6 +226,18 @@ export const createQuorumPendingApproval = ({ const randomHexMessage = () => `0x${crypto.randomUUID().replace(/-/g, "")}`; +// What a call returns for a request made as a quorum, or while only quorum members can still approve it. +export const UNSIGNED_TRANSACTION = { hash: undefined, explorerLink: undefined, transactionId: "txn-1" }; +export const UNSIGNED_SIGNATURE = { signature: undefined, signatureId: "sig-1" }; + +// A transaction or signature request as the wallets API returns it while `pending` approvals are outstanding. +export const createAwaitingRequest = (id: string, pending: object[], chainType = "evm") => ({ + id, + status: "awaiting-approval", + chainType, + approvals: { pending, submitted: [] }, +}); + // A Stellar member message: the base64 of a 32-byte auth entry preimage hash, as the wallets API sends it. export const randomStellarMessage = () => Base64.fromBytes(crypto.getRandomValues(new Uint8Array(32))); @@ -244,3 +262,17 @@ export const createExpiredRequest = (id: string) => ({ export const createMockSolanaSerializedTransaction = (): string => { return "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAgEDBQrKxEIIPWsDwcGCzLQ7FGIHQ38p0dZq6bG2v2wUAUqMx3jV1jZ0"; }; + +// EIP-712 typed data for EVM `signTypedData` calls. +export const EVM_TYPED_DATA = { + domain: { + name: "App", + version: "1", + chainId: 84532, + verifyingContract: "0x1111111111111111111111111111111111111111", + }, + types: { Mail: [{ name: "contents", type: "string" }] }, + primaryType: "Mail", + message: { contents: "hello" }, + chain: "base-sepolia", +} as const; diff --git a/packages/wallets/src/wallets/evm.ts b/packages/wallets/src/wallets/evm.ts index 78086e8ef..1b1fa4b78 100644 --- a/packages/wallets/src/wallets/evm.ts +++ b/packages/wallets/src/wallets/evm.ts @@ -35,6 +35,7 @@ export class EVMWallet extends Wallet { apiDelegatedServerSignerAddresses: Wallet.getApiDelegatedServerSignerAddresses(wallet), signer: wallet.signer, signers: Wallet.getInitialSigners(wallet), + quorumMemberLocator: Wallet.getQuorumMemberLocator(wallet), }, Wallet.getApiClient(wallet) ); @@ -51,8 +52,8 @@ export class EVMWallet extends Wallet { /** * Send a raw EVM transaction. * @param params - The transaction parameters (raw transaction, ABI call, or encoded data) - * When the signer is a quorum (`quorum:…`), the call returns the created transaction unsigned and without a - * hash, as with `prepareOnly: true`. Pass `prepareOnly: true` for that type. + * When the signer is a quorum, named (`quorum:…`) or inferred from a key that is only a member, the call returns + * the transaction unsigned and without a hash, as with `prepareOnly: true`; pass `prepareOnly: true` for that type. * @returns The transaction result */ @WithLoggerContext({ @@ -69,7 +70,7 @@ export class EVMWallet extends Wallet { await this.preAuthIfNeeded(); const builtTransaction = this.buildTransaction(params); - const signerParams = this.resolveSignerParams(params.options); + const signerParams = await this.resolveSignerParams(params.options); const createdTransaction = await this.createTransaction(builtTransaction, signerParams); if (this.isPrepareOnly(params.options, signerParams.signer)) { @@ -93,6 +94,8 @@ export class EVMWallet extends Wallet { /** * Sign a message with the wallet's private key. + * When the signer is a quorum, named in `signer` or inferred from a key that is only a member, the call returns the + * request unsigned and without a signature, as with `prepareOnly: true`; pass `prepareOnly: true` for that type. * @param params - The message to sign * @returns The signature */ @@ -109,12 +112,12 @@ export class EVMWallet extends Wallet { walletsLogger.info("evmWallet.signMessage.start"); await this.preAuthIfNeeded(); - const signer = this.signerManager.require(); + const signerParams = await this.resolveSignerParams(params.options); const signatureCreationResponse = await this.apiClient.createSignature(this.walletLocator, { type: "message", params: { message: params.message, - signer: signer.locator(), + ...signerParams, chain: this.chain, }, }); @@ -124,7 +127,7 @@ export class EVMWallet extends Wallet { throw new SignatureNotCreatedError(JSON.stringify(signatureCreationResponse)); } - if (params.options?.prepareOnly) { + if (this.isPrepareOnly(params.options, signerParams.signer)) { walletsLogger.info("evmWallet.signMessage.prepared", { signatureId: signatureCreationResponse.id, }); @@ -141,6 +144,8 @@ export class EVMWallet extends Wallet { /** * Sign EIP-712 typed data. + * When the signer is a quorum, the call returns the request unsigned, as {@link signMessage} does; pass + * `prepareOnly: true` for that type. * @param params - The typed data parameters (domain, types, message, primaryType, chain) * @returns The signature */ @@ -157,7 +162,6 @@ export class EVMWallet extends Wallet { walletsLogger.info("evmWallet.signTypedData.start"); await this.preAuthIfNeeded(); - const signer = this.signerManager.require(); const { domain, message, primaryType, types, chain } = params; if (!domain || !message || !types || !chain) { walletsLogger.error("evmWallet.signTypedData.error", { error: "Invalid typed data" }); @@ -170,6 +174,7 @@ export class EVMWallet extends Wallet { throw new InvalidTypedDataError("Invalid typed data domain"); } + const signerParams = await this.resolveSignerParams(params.options, chain); const signatureCreationResponse = await this.apiClient.createSignature(this.walletLocator, { type: "typed-data", params: { @@ -185,7 +190,7 @@ export class EVMWallet extends Wallet { primaryType, types: types as unknown as Record>, }, - signer: signer.locator(), + ...signerParams, chain, }, }); @@ -195,7 +200,7 @@ export class EVMWallet extends Wallet { throw new SignatureNotCreatedError(JSON.stringify(signatureCreationResponse)); } - if (params.options?.prepareOnly) { + if (this.isPrepareOnly(params.options, signerParams.signer)) { walletsLogger.info("evmWallet.signTypedData.prepared", { signatureId: signatureCreationResponse.id, }); diff --git a/packages/wallets/src/wallets/solana.ts b/packages/wallets/src/wallets/solana.ts index 5528d91ea..f3e6ef859 100644 --- a/packages/wallets/src/wallets/solana.ts +++ b/packages/wallets/src/wallets/solana.ts @@ -29,6 +29,7 @@ export class SolanaWallet extends Wallet { apiDelegatedServerSignerAddresses: Wallet.getApiDelegatedServerSignerAddresses(wallet), signer: wallet.signer, signers: Wallet.getInitialSigners(wallet), + quorumMemberLocator: Wallet.getQuorumMemberLocator(wallet), }, Wallet.getApiClient(wallet) ); @@ -60,7 +61,7 @@ export class SolanaWallet extends Wallet { walletsLogger.info("solanaWallet.sendTransaction.start"); await this.preAuthIfNeeded(); - const signerParams = this.resolveSignerParams(params.options); + const signerParams = await this.resolveSignerParams(params.options); const createdTransaction = await this.createTransaction(params, signerParams); if (this.isPrepareOnly(params.options, signerParams.signer)) { diff --git a/packages/wallets/src/wallets/stellar.ts b/packages/wallets/src/wallets/stellar.ts index 2cdccf90f..34927144a 100644 --- a/packages/wallets/src/wallets/stellar.ts +++ b/packages/wallets/src/wallets/stellar.ts @@ -31,6 +31,7 @@ export class StellarWallet extends Wallet { apiDelegatedServerSignerAddresses: Wallet.getApiDelegatedServerSignerAddresses(wallet), signer: wallet.signer, signers: Wallet.getInitialSigners(wallet), + quorumMemberLocator: Wallet.getQuorumMemberLocator(wallet), }, Wallet.getApiClient(wallet) ); @@ -47,8 +48,8 @@ export class StellarWallet extends Wallet { /** * Send a raw Stellar transaction (serialized transaction or contract call). * @param params - The transaction parameters - * When the signer is a quorum (`quorum:…`), the call returns the created transaction unsigned and without a - * hash, as with `prepareOnly: true`. Pass `prepareOnly: true` for that type. + * When the signer is a quorum, named (`quorum:…`) or inferred from a key that is only a member, the call returns + * the transaction unsigned and without a hash, as with `prepareOnly: true`; pass `prepareOnly: true` for that type. * @returns The transaction result */ @WithLoggerContext({ @@ -64,7 +65,7 @@ export class StellarWallet extends Wallet { walletsLogger.info("stellarWallet.sendTransaction.start"); await this.preAuthIfNeeded(); - const signerParams = this.resolveSignerParams(params.options); + const signerParams = await this.resolveSignerParams(params.options); const createdTransaction = await this.createTransaction(params, signerParams); if (this.isPrepareOnly(params.options, signerParams.signer)) { diff --git a/packages/wallets/src/wallets/types.ts b/packages/wallets/src/wallets/types.ts index 3fa828b3d..e844d33b1 100644 --- a/packages/wallets/src/wallets/types.ts +++ b/packages/wallets/src/wallets/types.ts @@ -44,7 +44,7 @@ export type SendTokenTransactionOptions = TransactionInputOptions & { transactionType?: SendTokenTransactionType; }; -export type SignatureInputOptions = PrepareOnly; +export type SignatureInputOptions = PrepareOnly & { signer?: SignerOption }; export type AddSignerOptions = PrepareOnly & { scopes?: Scope[]; diff --git a/packages/wallets/src/wallets/wallet-factory.ts b/packages/wallets/src/wallets/wallet-factory.ts index a7445076c..7d9dfa875 100644 --- a/packages/wallets/src/wallets/wallet-factory.ts +++ b/packages/wallets/src/wallets/wallet-factory.ts @@ -31,7 +31,11 @@ import type { } from "../signers/types"; import { Wallet } from "./wallet"; import type { RecoverySignerConfigFor, WalletArgsFor, WalletCreateArgs } from "./types"; -import { hasRecoveryMethodList, recoveryMethodsFromCreateArgs } from "../utils/recovery"; +import { + hasRecoveryMethodList, + recoveryMethodsFromCreateArgs, + recoveryMethodsFromWalletConfig, +} from "../utils/recovery"; import { compareSignerConfigs, normalizeValueForComparison } from "../utils/signer-validation"; import { getSignerLocator } from "../utils/signer-locator"; import { deriveServerSignerDetails, deriveServerSignerCandidates } from "../signers/server"; @@ -344,9 +348,7 @@ export class WalletFactory { const walletConfig = walletResponse.config as SmartWalletConfig; // `recoveryMethods` holds every admin signer; older responses (and wallets created with a single // signer) only carry the deprecated singular `adminSigner`. - const apiRecoverySigners = (walletConfig.recoveryMethods ?? [walletConfig.adminSigner]) as Array< - RecoverySignerConfigForChain - >; + const apiRecoverySigners = recoveryMethodsFromWalletConfig(walletConfig); const recoverySigners = this.mergeRecoverySigners( apiRecoverySigners, recoveryMethodsFromCreateArgs(createArgs), diff --git a/packages/wallets/src/wallets/wallet.quorum-acting-role.test.ts b/packages/wallets/src/wallets/wallet.quorum-acting-role.test.ts new file mode 100644 index 000000000..56bd4024f --- /dev/null +++ b/packages/wallets/src/wallets/wallet.quorum-acting-role.test.ts @@ -0,0 +1,728 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ApiClient } from "../api"; +import type { Chain } from "../chains/chains"; +import type { QuorumMember, RecoverySignerConfigForChain, SignerConfigForChain } from "../signers/types"; +import { QuorumSelectionAmbiguousError, WalletNotAvailableError } from "../utils/errors"; +import { EVMWallet } from "./evm"; +import { SolanaWallet } from "./solana"; +import { StellarWallet } from "./stellar"; +import { Wallet } from "./wallet"; +import { + EVM_TYPED_DATA, + createMockApiClient, + createMockSolanaSerializedTransaction, + createMockWallet, + createQuorumApiEntry, + createAwaitingRequest, + createQuorumApiEntryWith, + createQuorumPendingApproval, + settle, + signatureOver, + UNSIGNED_SIGNATURE, + UNSIGNED_TRANSACTION, + type MockedApiClient, +} from "./__tests__/test-helpers"; + +const EVM_ADDRESS = "0x1234567890123456789012345678901234567890"; +const STELLAR_ADDRESS = "GCKFBEIYTKP6RCZX6LRQW2JVAVLMGGVSNESWKN7L2YGQNI2DCOHVHJVY"; +const RECIPIENT = "0x1111111111111111111111111111111111111111"; +// Bob holds only his own key; it is a member of the wallet's quorums and nothing else. +const bobConfig = (address: string) => + ({ + type: "external-wallet", + address, + onSign: vi.fn(async (payload: string) => signatureOver(payload)), + }) as unknown as SignerConfigForChain; +const bobMember = (address: string) => + ({ type: "external-wallet", address, locator: `external-wallet:${address}` }) as QuorumMember; +const BOB_EVM = "0x4444444444444444444444444444444444444444"; +const BOB_STELLAR = "GBOBMEMBERKEY"; + +// The wallet as `getWallet` returns it, listing its recovery methods and delegated signers in its config. +const walletConfigResponse = ( + chain: Chain, + delegatedSigners: object[], + recoveryMethods: object[] = [{ type: "api-key" }] +) => ({ + type: "smart", + chainType: chain === "stellar" ? "stellar" : "evm", + address: chain === "stellar" ? STELLAR_ADDRESS : EVM_ADDRESS, + config: { adminSigner: recoveryMethods[0], recoveryMethods, delegatedSigners }, +}); + +// A wallet whose recovery methods and delegated signers are given, with Bob's member key selected. +async function walletWithBob( + api: MockedApiClient, + { recovery = [{ type: "api-key" }], signers = [] }: { recovery?: object[]; signers?: object[] }, + chain: Chain = "base-sepolia" +) { + const wallet = new Wallet( + { + chain, + address: chain === "stellar" ? STELLAR_ADDRESS : EVM_ADDRESS, + recovery: recovery as unknown as RecoverySignerConfigForChain[], + }, + api as unknown as ApiClient + ); + await wallet.waitForInit(); + // Chain wallets are built with `from()`, a new object, so the spy goes on the shared prototype. + const signersSpy = vi.spyOn(Wallet.prototype, "signers").mockResolvedValue(signers as never); + api.getWallet.mockResolvedValue(walletConfigResponse(chain, signers, recovery) as never); + await wallet.useSigner(bobConfig(chain === "stellar" ? BOB_STELLAR : BOB_EVM)); + return { wallet, signersSpy }; +} + +const STELLAR_CONTRACT_CALL = { + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + method: "transfer", + args: {}, +}; + +const sentSendSigner = (api: MockedApiClient) => api.send.mock.calls[0][2].signer; +const createdTransactionSigner = (api: MockedApiClient) => + (api.createTransaction.mock.calls[0][1].params as { signer?: string }).signer; + +describe("a creating call with a key that is only a quorum member", () => { + let api: MockedApiClient; + const bob = bobMember(BOB_EVM); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + api = createMockApiClient(); + api.send.mockResolvedValue({ id: "txn-1" } as never); + api.createTransaction.mockResolvedValue({ id: "txn-1" } as never); + api.createSignature.mockResolvedValue({ id: "sig-1" } as never); + api.approveTransaction.mockResolvedValue({ id: "txn-1", status: "pending" } as never); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe("when the key is in one delegated quorum", () => { + it("creates the send as that quorum and returns it unsigned, as with prepareOnly", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + + const { value } = await settle(wallet.send(RECIPIENT, "usdc", "1")); + + expect(sentSendSigner(api)).toBe(treasury.locator); + expect(api.approveTransaction).not.toHaveBeenCalled(); + expect(value).toStrictEqual(UNSIGNED_TRANSACTION); + }); + + it("creates an EVM sendTransaction as that quorum and returns it unsigned", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + + const { value } = await settle(EVMWallet.from(wallet).sendTransaction({ to: RECIPIENT, value: BigInt(1) })); + + expect(createdTransactionSigner(api)).toBe(treasury.locator); + expect(api.approveTransaction).not.toHaveBeenCalled(); + expect(value).toStrictEqual(UNSIGNED_TRANSACTION); + }); + + it("creates the request as that quorum and signs nothing with prepareOnly", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + + const prepared = await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + expect(sentSendSigner(api)).toBe(treasury.locator); + expect(prepared.transactionId).toBe("txn-1"); + expect(api.approveTransaction).not.toHaveBeenCalled(); + }); + + it("surfaces the API's rejection when the quorum is not installed yet", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "awaiting-approval" }] }); + api.send.mockResolvedValue({ error: true, message: "Quorum is not installed on base-sepolia" } as never); + + const { error } = await settle(wallet.send(RECIPIENT, "usdc", "1")); + + expect(sentSendSigner(api)).toBe(treasury.locator); + expect((error as Error).message).toContain("Quorum is not installed on base-sepolia"); + }); + }); + + describe("when the key is in one recovery quorum", () => { + it("creates the send as the recovery quorum and returns it unsigned", async () => { + const recoveryQuorum = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { recovery: [{ type: "api-key" }, recoveryQuorum] }); + + const { value } = await settle(wallet.send(RECIPIENT, "usdc", "1")); + + expect(sentSendSigner(api)).toBe(recoveryQuorum.locator); + expect(api.approveTransaction).not.toHaveBeenCalled(); + expect(value).toStrictEqual(UNSIGNED_TRANSACTION); + }); + }); + + describe("when the key is in a recovery quorum and a delegated quorum", () => { + it("throws QuorumSelectionAmbiguousError naming both, and creates nothing, on every try", async () => { + const recoveryQuorum = createQuorumApiEntryWith([bob]); + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { + recovery: [{ type: "api-key" }, recoveryQuorum], + signers: [{ ...treasury, status: "success" }], + }); + + for (let attempt = 0; attempt < 2; attempt++) { + const sending = wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + await expect(sending).rejects.toBeInstanceOf(QuorumSelectionAmbiguousError); + await expect(sending).rejects.toMatchObject({ + quorumLocators: [recoveryQuorum.locator, treasury.locator], + }); + } + expect(api.send).not.toHaveBeenCalled(); + }); + }); + + describe("when an explicit signer is passed", () => { + it("sends it as given, even when the key is in several quorums", async () => { + const recoveryQuorum = createQuorumApiEntryWith([bob]); + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { + recovery: [{ type: "api-key" }, recoveryQuorum], + signers: [{ ...treasury, status: "success" }], + }); + + await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true, signer: treasury.locator }); + + expect(sentSendSigner(api)).toBe(treasury.locator); + }); + }); + + describe("when the key was registered as a signer after it was selected", () => { + it("acts as itself", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + api.getWallet.mockResolvedValue(walletConfigResponse("base-sepolia", [treasury, bob]) as never); + + await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + expect(sentSendSigner(api)).toBe(bob.locator); + }); + + it("acts as itself when its registration lists this chain", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + const registered = { ...bob, chains: { "base-sepolia": { id: "sig-9", status: "success" } } }; + api.getWallet.mockResolvedValue(walletConfigResponse("base-sepolia", [treasury, registered]) as never); + + await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + expect(sentSendSigner(api)).toBe(bob.locator); + }); + }); + + describe("when the key is a registered signer on another chain only", () => { + it("creates the send as its quorum", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + const elsewhere = { ...bob, chains: { "polygon-amoy": { id: "sig-9", status: "success" } } }; + api.getWallet.mockResolvedValue(walletConfigResponse("base-sepolia", [treasury, elsewhere]) as never); + + await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + expect(sentSendSigner(api)).toBe(treasury.locator); + }); + }); + + describe("when another client made the key a recovery method after it was selected", () => { + it("acts as itself", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + api.getWallet.mockResolvedValue( + walletConfigResponse( + "base-sepolia", + [treasury], + [{ type: "api-key" }, { type: "external-wallet", address: BOB_EVM }] + ) as never + ); + + await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + expect(sentSendSigner(api)).toBe(bob.locator); + }); + }); + + describe("when another client added the key to a recovery quorum after it was selected", () => { + it("throws QuorumSelectionAmbiguousError naming both quorums", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const recoveryQuorum = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + api.getWallet.mockResolvedValue( + walletConfigResponse("base-sepolia", [treasury], [{ type: "api-key" }, recoveryQuorum]) as never + ); + + const sending = wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + await expect(sending).rejects.toBeInstanceOf(QuorumSelectionAmbiguousError); + await expect(sending).rejects.toMatchObject({ + quorumLocators: [recoveryQuorum.locator, treasury.locator], + }); + }); + }); + + describe("when the key is in two delegated quorums", () => { + it("throws QuorumSelectionAmbiguousError naming both, and creates nothing", async () => { + const first = createQuorumApiEntryWith([bob]); + const second = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { + signers: [ + { ...first, status: "success" }, + { ...second, status: "success" }, + ], + }); + + const sending = wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + await expect(sending).rejects.toMatchObject({ quorumLocators: [first.locator, second.locator] }); + expect(api.send).not.toHaveBeenCalled(); + }); + }); + + describe("when the key is a registered signer listed with a different address case", () => { + it("acts as itself", async () => { + const lowercase = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + const checksummed = "0xAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCdEfAbCd"; + const member = bobMember(lowercase); + const treasury = createQuorumApiEntryWith([member]); + const registered = { + type: "external-wallet", + address: checksummed, + locator: `external-wallet:${checksummed}`, + status: "success", + }; + const wallet = new Wallet( + { + chain: "base-sepolia", + address: EVM_ADDRESS, + recovery: [{ type: "api-key" }] as unknown as RecoverySignerConfigForChain[], + }, + api as unknown as ApiClient + ); + await wallet.waitForInit(); + vi.spyOn(Wallet.prototype, "signers").mockResolvedValue([ + { ...treasury, status: "success" }, + registered, + ] as never); + api.getWallet.mockResolvedValue(walletConfigResponse("base-sepolia", [treasury, registered]) as never); + await wallet.useSigner(bobConfig(lowercase)); + + await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + expect(sentSendSigner(api)).toBe(member.locator); + }); + }); + + describe("when the key is no longer in any quorum", () => { + it("acts as itself", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + api.getWallet.mockResolvedValue(walletConfigResponse("base-sepolia", []) as never); + + await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + expect(sentSendSigner(api)).toBe(bob.locator); + }); + }); + + describe("when reading the wallet fails", () => { + it("throws WalletNotAvailableError and creates nothing", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + api.getWallet.mockResolvedValue({ error: true, message: "wallet unavailable" } as never); + + await expect(wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true })).rejects.toBeInstanceOf( + WalletNotAvailableError + ); + expect(api.send).not.toHaveBeenCalled(); + }); + }); + + describe("when the wallet read returns no config", () => { + it("throws WalletNotAvailableError and creates nothing", async () => { + const recoveryQuorum = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { recovery: [{ type: "api-key" }, recoveryQuorum] }); + api.getWallet.mockResolvedValue({ type: "smart", chainType: "evm", address: EVM_ADDRESS } as never); + + await expect(wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true })).rejects.toBeInstanceOf( + WalletNotAvailableError + ); + expect(api.send).not.toHaveBeenCalled(); + }); + }); + + describe("when a delegated quorum's own signer lookup fails", () => { + it("still counts that quorum, so a key in two quorums gets the ambiguous error", async () => { + const recoveryQuorum = createQuorumApiEntryWith([bob]); + const treasury = createQuorumApiEntryWith([bob]); + const wallet = new Wallet( + { + chain: "base-sepolia", + address: EVM_ADDRESS, + recovery: [{ type: "api-key" }, recoveryQuorum] as unknown as RecoverySignerConfigForChain[], + }, + api as unknown as ApiClient + ); + await wallet.waitForInit(); + api.getWallet.mockResolvedValue( + walletConfigResponse("base-sepolia", [treasury], [{ type: "api-key" }, recoveryQuorum]) as never + ); + api.getSigner.mockRejectedValue(new Error("signer lookup failed")); + await wallet.useSigner(bobConfig(BOB_EVM)); + + const sending = wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + await expect(sending).rejects.toBeInstanceOf(QuorumSelectionAmbiguousError); + expect(api.send).not.toHaveBeenCalled(); + }); + }); + + describe("when another key is selected after the member key", () => { + it("sends that key, without reading the wallet's signers", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const alice = { type: "external-wallet", address: "0x5555555555555555555555555555555555555555" }; + const aliceLocator = `external-wallet:${alice.address}`; + const { wallet, signersSpy } = await walletWithBob(api, { + signers: [ + { ...treasury, status: "success" }, + { ...alice, locator: aliceLocator, status: "success" }, + ], + }); + api.getSigner.mockResolvedValue({ + type: "external-wallet", + locator: aliceLocator, + chains: { "base-sepolia": { status: "success", id: "s-1" } }, + } as never); + await wallet.useSigner({ ...alice, onSign: vi.fn() } as unknown as SignerConfigForChain); + signersSpy.mockClear(); + api.getWallet.mockClear(); + + await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + expect(sentSendSigner(api)).toBe(aliceLocator); + expect(signersSpy).not.toHaveBeenCalled(); + expect(api.getWallet).not.toHaveBeenCalled(); + }); + }); + + describe("when the member key is selected again after it was registered as a signer", () => { + it("sends it as itself, without reading the wallet", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet, signersSpy } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + signersSpy.mockResolvedValue([ + { ...treasury, status: "success" }, + { ...bob, status: "success" }, + ] as never); + api.getSigner.mockResolvedValue({ + type: "external-wallet", + locator: bob.locator, + chains: { "base-sepolia": { status: "success", id: "s-1" } }, + } as never); + await wallet.useSigner(bobConfig(BOB_EVM)); + api.getWallet.mockClear(); + + await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + + expect(sentSendSigner(api)).toBe(bob.locator); + expect(api.getWallet).not.toHaveBeenCalled(); + }); + }); + + describe("when a second wallet read would find the key registered", () => { + it.each([ + { + name: "send", + chain: "base-sepolia" as const, + create: (wallet: Wallet) => wallet.send(RECIPIENT, "usdc", "1"), + unsigned: UNSIGNED_TRANSACTION, + }, + { + name: "EVM sendTransaction", + chain: "base-sepolia" as const, + create: (wallet: Wallet) => + EVMWallet.from(wallet).sendTransaction({ to: RECIPIENT, value: BigInt(1) }), + unsigned: UNSIGNED_TRANSACTION, + }, + { + name: "signMessage", + chain: "base-sepolia" as const, + create: (wallet: Wallet) => EVMWallet.from(wallet).signMessage({ message: "hello" }), + unsigned: UNSIGNED_SIGNATURE, + }, + { + name: "signTypedData", + chain: "base-sepolia" as const, + create: (wallet: Wallet) => EVMWallet.from(wallet).signTypedData(EVM_TYPED_DATA as never), + unsigned: UNSIGNED_SIGNATURE, + }, + { + name: "Stellar sendTransaction", + chain: "stellar" as const, + create: (wallet: Wallet) => StellarWallet.from(wallet).sendTransaction(STELLAR_CONTRACT_CALL), + unsigned: UNSIGNED_TRANSACTION, + }, + ])( + "$name decides the request's signer and the early return from one read", + async ({ chain, create, unsigned }) => { + const member = bobMember(chain === "stellar" ? BOB_STELLAR : BOB_EVM); + const treasury = createQuorumApiEntryWith([member]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }, chain); + // Only the first read finds the key a member; a second read would find it registered and sign as itself. + api.getWallet.mockReset(); + api.getWallet.mockResolvedValueOnce(walletConfigResponse(chain, [treasury]) as never); + api.getWallet.mockResolvedValue(walletConfigResponse(chain, [treasury, member]) as never); + + const { value } = await settle(create(wallet)); + + expect(api.getWallet).toHaveBeenCalledTimes(1); + expect(api.approveTransaction).not.toHaveBeenCalled(); + expect(api.approveSignature).not.toHaveBeenCalled(); + expect(value).toStrictEqual(unsigned); + } + ); + }); + + describe("when the key signs the request later with approve()", () => { + const approvalsSent = (approve: MockedApiClient["approveTransaction"] | MockedApiClient["approveSignature"]) => + approve.mock.calls.map((call) => call[2].approvals); + + it("signs its member entry of a send created as its quorum", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + const entry = createQuorumPendingApproval({ quorum: treasury }); + api.getTransaction.mockResolvedValueOnce(createAwaitingRequest("txn-1", [entry]) as never); + api.getTransaction.mockResolvedValue({ + id: "txn-1", + status: "success", + onChain: { txId: "0xhash", explorerLink: "https://explorer.example/tx/0xhash" }, + } as never); + + const { transactionId } = await wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }); + const { value } = await settle(wallet.approve({ transactionId })); + + expect(approvalsSent(api.approveTransaction)).toEqual([ + [{ signer: bob.locator, signature: signatureOver(entry.quorumApprovals.pending[0].message) }], + ]); + expect(value).toMatchObject({ transactionId: "txn-1", hash: "0xhash" }); + }); + + it("signs its member entry of a signature requested as its quorum", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + const entry = createQuorumPendingApproval({ quorum: treasury }); + api.getSignature.mockResolvedValue(createAwaitingRequest("sig-1", [entry]) as never); + api.approveSignature.mockResolvedValue({ + id: "sig-1", + status: "success", + outputSignature: "0x1271", + } as never); + const evmWallet = EVMWallet.from(wallet); + + const { signatureId } = await evmWallet.signMessage({ message: "hello", options: { prepareOnly: true } }); + const { value } = await settle(evmWallet.approve({ signatureId })); + + expect(approvalsSent(api.approveSignature)).toEqual([ + [{ signer: bob.locator, signature: signatureOver(entry.quorumApprovals.pending[0].message) }], + ]); + expect(value).toEqual({ signature: "0x1271", signatureId: "sig-1" }); + }); + }); +}); + +describe("EVM signatures with a key that is only a quorum member", () => { + let api: MockedApiClient; + const bob = bobMember(BOB_EVM); + const signatureSigner = () => api.createSignature.mock.calls[0][1].params.signer; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + api = createMockApiClient(); + api.createSignature.mockResolvedValue({ id: "sig-1" } as never); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe.each([ + { name: "signMessage", sign: (wallet: EVMWallet) => wallet.signMessage({ message: "hello" }) }, + { name: "signTypedData", sign: (wallet: EVMWallet) => wallet.signTypedData(EVM_TYPED_DATA as never) }, + ])("$name", ({ sign }) => { + it("requests the signature as the key's quorum and returns it unsigned, as with prepareOnly", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + + const { value, error } = await settle(sign(EVMWallet.from(wallet))); + + expect(error).toBeUndefined(); + expect(signatureSigner()).toBe(treasury.locator); + expect(api.getSignature).not.toHaveBeenCalled(); + expect(api.approveSignature).not.toHaveBeenCalled(); + expect(value).toStrictEqual(UNSIGNED_SIGNATURE); + }); + + it("throws QuorumSelectionAmbiguousError when the key is in two quorums, and requests nothing", async () => { + const recoveryQuorum = createQuorumApiEntryWith([bob]); + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { + recovery: [{ type: "api-key" }, recoveryQuorum], + signers: [{ ...treasury, status: "success" }], + }); + + const { error } = await settle(sign(EVMWallet.from(wallet))); + + expect(error).toBeInstanceOf(QuorumSelectionAmbiguousError); + expect(api.createSignature).not.toHaveBeenCalled(); + }); + }); + + describe("when signTypedData names a chain other than the wallet's", () => { + const signTypedDataOn = (wallet: Wallet, chain: Chain) => + EVMWallet.from(wallet).signTypedData({ + ...EVM_TYPED_DATA, + chain, + options: { prepareOnly: true }, + } as never); + + it("acts as itself when the key is registered on the request's chain only", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + const onRequestChain = { ...bob, chains: { "polygon-amoy": { id: "sig-9", status: "success" } } }; + api.getWallet.mockResolvedValue(walletConfigResponse("base-sepolia", [treasury, onRequestChain]) as never); + + await signTypedDataOn(wallet, "polygon-amoy"); + + expect(signatureSigner()).toBe(bob.locator); + }); + + it("acts as its quorum when the key is registered on the wallet's chain only", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }); + const onWalletChain = { ...bob, chains: { "base-sepolia": { id: "sig-9", status: "success" } } }; + api.getWallet.mockResolvedValue(walletConfigResponse("base-sepolia", [treasury, onWalletChain]) as never); + + await signTypedDataOn(wallet, "polygon-amoy"); + + expect(signatureSigner()).toBe(treasury.locator); + }); + }); + + describe("when signMessage names a quorum in its signer option", () => { + it.each([false, true])( + "requests the signature as that quorum and returns it unsigned with prepareOnly %s", + async (prepareOnly) => { + const treasury = createQuorumApiEntry(); + const registered = { + type: "external-wallet", + address: BOB_EVM, + locator: bob.locator, + status: "success", + }; + const { wallet } = await walletWithBob(api, { + signers: [{ ...treasury, status: "success" }, registered], + }); + + const { value } = await settle( + EVMWallet.from(wallet).signMessage({ + message: "hello", + options: { prepareOnly, signer: treasury.locator }, + }) + ); + + expect(signatureSigner()).toBe(treasury.locator); + expect(api.approveSignature).not.toHaveBeenCalled(); + expect(value).toStrictEqual(UNSIGNED_SIGNATURE); + } + ); + }); +}); + +describe("Stellar with a key that is only a quorum member", () => { + let api: MockedApiClient; + const bob = bobMember(BOB_STELLAR); + + beforeEach(() => { + vi.clearAllMocks(); + api = createMockApiClient(); + api.createTransaction.mockResolvedValue({ id: "txn-1" } as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates sendTransaction as the key's quorum and returns it unsigned", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }, "stellar"); + + const result = await StellarWallet.from(wallet).sendTransaction(STELLAR_CONTRACT_CALL); + + expect(createdTransactionSigner(api)).toBe(treasury.locator); + expect(api.approveTransaction).not.toHaveBeenCalled(); + expect(result).toStrictEqual(UNSIGNED_TRANSACTION); + }); + + it("sends no signer on upgrade, so the API uses the admin signer, as before", async () => { + const treasury = createQuorumApiEntryWith([bob]); + const { wallet } = await walletWithBob(api, { signers: [{ ...treasury, status: "success" }] }, "stellar"); + + await StellarWallet.from(wallet).upgrade({ prepareOnly: true }); + + expect(api.createTransaction.mock.calls[0][1].params).not.toHaveProperty("signer"); + }); +}); + +describe("a creating call with a key that is not a quorum member", () => { + describe.each([ + { + name: "send", + chain: "base-sepolia" as const, + create: (wallet: Wallet) => wallet.send(RECIPIENT, "usdc", "1", { prepareOnly: true }), + }, + { + name: "EVM signMessage", + chain: "base-sepolia" as const, + create: (wallet: Wallet) => + EVMWallet.from(wallet).signMessage({ message: "hello", options: { prepareOnly: true } }), + }, + { + name: "Solana sendTransaction", + chain: "solana" as const, + create: (wallet: Wallet) => + SolanaWallet.from(wallet).sendTransaction({ + serializedTransaction: createMockSolanaSerializedTransaction(), + options: { prepareOnly: true }, + }), + }, + { + name: "Stellar sendTransaction", + chain: "stellar" as const, + create: (wallet: Wallet) => + StellarWallet.from(wallet).sendTransaction({ + ...STELLAR_CONTRACT_CALL, + options: { prepareOnly: true }, + }), + }, + ])("$name", ({ chain, create }) => { + it("makes no wallet read", async () => { + const api = createMockApiClient(); + api.send.mockResolvedValue({ id: "txn-1" } as never); + api.createTransaction.mockResolvedValue({ id: "txn-1" } as never); + api.createSignature.mockResolvedValue({ id: "sig-1" } as never); + const wallet = await createMockWallet(chain, api, "api-key"); + api.getWallet.mockClear(); + + await create(wallet); + + expect(api.getWallet).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/wallets/src/wallets/wallet.quorum-approve.test.ts b/packages/wallets/src/wallets/wallet.quorum-approve.test.ts index cd203b6c8..872434af5 100644 --- a/packages/wallets/src/wallets/wallet.quorum-approve.test.ts +++ b/packages/wallets/src/wallets/wallet.quorum-approve.test.ts @@ -12,17 +12,21 @@ import { Base64, Hex } from "ox"; import type { Wallet } from "./wallet"; import { activeSignerMember, + createAwaitingRequest, createExpiredRequest, createHeldSigner, createMockApiClient, createMockWallet, createQuorumApiEntry, + createQuorumApiEntryWith, createQuorumPendingApproval, createSigningWallet, randomStellarMessage, settle, signatureOver, TRANSACTION_EXPIRED_API_MESSAGE, + UNSIGNED_SIGNATURE, + UNSIGNED_TRANSACTION, type MockedApiClient, } from "./__tests__/test-helpers"; @@ -33,21 +37,12 @@ const SUCCESS = { onChain: { txId: "0xhash", explorerLink: "https://explorer.example/tx/0xhash" }, }; // What approve() returns while other members must still sign (EDD 007 §7 "Approval"). -const AWAITING_TRANSACTION = { hash: undefined, explorerLink: undefined, transactionId: "txn-1" }; -const AWAITING_SIGNATURE = { signature: undefined, signatureId: "sig-1" }; - -// A quorum of `threshold` whose first members are `members`, the rest random email members. -function quorumWith(members: QuorumMember[], { threshold = 2, memberCount = 3 } = {}) { - const quorum = createQuorumApiEntry({ threshold, memberCount }); - return { ...quorum, members: [...members, ...quorum.members.slice(members.length)] }; -} - -const awaiting = (id: string, pending: object[], chainType = "evm") => ({ - id, - status: "awaiting-approval", - chainType, - approvals: { pending, submitted: [] }, -}); +const AWAITING_TRANSACTION = UNSIGNED_TRANSACTION; +const AWAITING_SIGNATURE = UNSIGNED_SIGNATURE; + +const quorumWith = createQuorumApiEntryWith; + +const awaiting = createAwaitingRequest; const sentApprovals = (api: MockedApiClient) => api.approveTransaction.mock.calls.map((call) => call[2].approvals); diff --git a/packages/wallets/src/wallets/wallet.quorum-errors.test.ts b/packages/wallets/src/wallets/wallet.quorum-errors.test.ts index 392fe4917..8d183d558 100644 --- a/packages/wallets/src/wallets/wallet.quorum-errors.test.ts +++ b/packages/wallets/src/wallets/wallet.quorum-errors.test.ts @@ -24,6 +24,7 @@ import { createMockSolanaSerializedTransaction, createMockWallet, type MockedApiClient, + EVM_TYPED_DATA, } from "./__tests__/test-helpers"; const QUORUM_CODE = "QUORUM_SIZE_INVALID"; @@ -39,13 +40,6 @@ const AWAITING_TRANSACTION = { approvals: { pending: [], submitted: [] }, }; const AWAITING_SIGNATURE = { id: "sig-1", status: "awaiting-approval", approvals: { pending: [], submitted: [] } }; -const TYPED_DATA = { - domain: { name: "App", version: "1", chainId: 84532, verifyingContract: RECIPIENT }, - types: { Mail: [{ name: "contents", type: "string" }] }, - primaryType: "Mail", - message: { contents: "hello" }, - chain: "base-sepolia", -} as const; const errorBody = (code: string) => ({ error: true, message: `rejected: ${code}`, code }); @@ -89,7 +83,7 @@ const ERROR_SITES: ErrorSite[] = [ chain: "base-sepolia", signerType: "api-key", arrange: (api, body) => api.createSignature.mockResolvedValue(body as never), - act: (wallet) => EVMWallet.from(wallet).signTypedData(TYPED_DATA as never), + act: (wallet) => EVMWallet.from(wallet).signTypedData(EVM_TYPED_DATA as never), todayError: SignatureNotCreatedError, }, { diff --git a/packages/wallets/src/wallets/wallet.quorum-member-signer.test.ts b/packages/wallets/src/wallets/wallet.quorum-member-signer.test.ts index b6be3e284..d36a2361b 100644 --- a/packages/wallets/src/wallets/wallet.quorum-member-signer.test.ts +++ b/packages/wallets/src/wallets/wallet.quorum-member-signer.test.ts @@ -2,9 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ApiClient } from "../api"; import type { Chain } from "../chains/chains"; import type { QuorumMember, QuorumSigner, RecoverySignerConfigForChain, SignerConfigForChain } from "../signers/types"; -import { QuorumRequestError } from "../utils/errors"; import { Wallet } from "./wallet"; -import { createMockApiClient, createQuorumApiEntry, type MockedApiClient } from "./__tests__/test-helpers"; +import { + createMockApiClient, + createQuorumApiEntry, + createQuorumApiEntryWith, + type MockedApiClient, +} from "./__tests__/test-helpers"; vi.mock("@/signers/server", async (importOriginal) => { const actual = await importOriginal(); @@ -83,10 +87,7 @@ const memberKey = (type: QuorumMember["type"]): MemberKey => { return key; }; -function quorumOf(member: QuorumMember): QuorumSigner { - const quorum = createQuorumApiEntry({ threshold: 2, memberCount: 3 }); - return { ...quorum, members: [member, ...quorum.members.slice(1)] } as QuorumSigner; -} +const quorumOf = (member: QuorumMember): QuorumSigner => createQuorumApiEntryWith([member]); async function walletWith( api: MockedApiClient, @@ -229,20 +230,26 @@ describe("useSigner() with a key that is only a quorum member", () => { }); describe("when a member key sends without naming the quorum", () => { - it("throws the API's SIGNER_IS_QUORUM_MEMBER as QuorumRequestError", async () => { + it("creates the transaction as the key's quorum", async () => { const [externalWallet] = MEMBER_KEYS; - const wallet = await walletWith(api, { recovery: [{ type: "api-key" }, quorumOf(externalWallet.member)] }); + const recoveryQuorum = quorumOf(externalWallet.member); + const wallet = await walletWith(api, { recovery: [{ type: "api-key" }, recoveryQuorum] }); await wallet.useSigner(externalWallet.config); - api.send.mockResolvedValue({ - error: true, - code: "SIGNER_IS_QUORUM_MEMBER", - message: "The signer is a quorum member; name the quorum as signer.", + api.getWallet.mockResolvedValue({ + type: "smart", + chainType: "evm", + address: EVM_ADDRESS, + config: { + adminSigner: { type: "api-key" }, + recoveryMethods: [{ type: "api-key" }, recoveryQuorum], + delegatedSigners: [], + }, } as never); + api.send.mockResolvedValue({ id: "txn-1" } as never); - const sending = wallet.send("0x1111111111111111111111111111111111111111", "usdc", "1"); + await wallet.send("0x1111111111111111111111111111111111111111", "usdc", "1", { prepareOnly: true }); - await expect(sending).rejects.toBeInstanceOf(QuorumRequestError); - await expect(sending).rejects.toMatchObject({ reason: "SIGNER_IS_QUORUM_MEMBER" }); + expect(api.send.mock.calls[0][2].signer).toBe(recoveryQuorum.locator); }); }); }); diff --git a/packages/wallets/src/wallets/wallet.signer-params.test.ts b/packages/wallets/src/wallets/wallet.signer-params.test.ts index 140e7fad7..71aa68b53 100644 --- a/packages/wallets/src/wallets/wallet.signer-params.test.ts +++ b/packages/wallets/src/wallets/wallet.signer-params.test.ts @@ -11,6 +11,7 @@ import { createMockSolanaSerializedTransaction, createMockWallet, type MockedApiClient, + EVM_TYPED_DATA, } from "./__tests__/test-helpers"; import type { SignerOption } from "./types"; @@ -68,6 +69,23 @@ const SIGNER_PATHS: SignerPath[] = [ }), sentSigner: createdSigner, }, + { + name: "EVM signMessage", + chain: "base-sepolia", + create: (wallet, signer) => + EVMWallet.from(wallet).signMessage({ message: "hello", options: { prepareOnly: true, signer } }), + sentSigner: (api) => api.createSignature.mock.calls[0][1].params.signer, + }, + { + name: "EVM signTypedData", + chain: "base-sepolia", + create: (wallet, signer) => + EVMWallet.from(wallet).signTypedData({ + ...EVM_TYPED_DATA, + options: { prepareOnly: true, signer }, + } as never), + sentSigner: (api) => api.createSignature.mock.calls[0][1].params.signer, + }, { name: "Stellar sendTransaction", chain: "stellar", @@ -90,6 +108,7 @@ describe("acting signer sent with a new transaction", () => { api = createMockApiClient(); api.send.mockResolvedValue({ id: "txn-1" } as unknown as SendResponse); api.createTransaction.mockResolvedValue(CREATED); + api.createSignature.mockResolvedValue({ id: "sig-1" } as never); stubServerSignerResolution(); }); @@ -151,6 +170,7 @@ describe("acting signer sent with a new transaction", () => { await expect(create(wallet, EXPLICIT_LOCATOR)).rejects.toThrow(/No signer is set/); expect(api.send).not.toHaveBeenCalled(); expect(api.createTransaction).not.toHaveBeenCalled(); + expect(api.createSignature).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/wallets/src/wallets/wallet.ts b/packages/wallets/src/wallets/wallet.ts index fb2536ce0..22497e432 100644 --- a/packages/wallets/src/wallets/wallet.ts +++ b/packages/wallets/src/wallets/wallet.ts @@ -38,7 +38,7 @@ import type { SignerOption, } from "./types"; import { isSignerLocatorOption } from "./types"; -import { mapApiSignerToSigner } from "../utils/signer-mapping"; +import { isSignerOnChain, mapApiSignerToSigner } from "../utils/signer-mapping"; import { DEVICE_SIGNER_NOT_SUPPORTED_ERROR_CODE, DeviceSignerNotSupportedError, @@ -86,13 +86,20 @@ import { ServerSignerResolver } from "../signers/server/resolver"; import { getSignerDescriptor } from "../signers/descriptors"; import { walletsLogger } from "../logger"; -import { getSignerLocator, hasQuorumLocatorPrefix, passkeyCredentialId } from "../utils/signer-locator"; +import { recoveryMethodsFromWalletConfig } from "../utils/recovery"; +import { + getSignerLocator, + hasQuorumLocatorPrefix, + isSameSignerLocator, + passkeyCredentialId, +} from "../utils/signer-locator"; import { toRecipientLocator, toTokenLocator } from "../utils/locators"; import { formatBalanceResponse } from "./services/balance-formatter"; import { SignerManager } from "./services/signer-manager"; import { DeviceRecoveryService } from "./services/device-recovery-service"; import { isQuorumPendingApproval } from "../quorum/approvals"; import { quorumsWithMember } from "../quorum/members"; +import { resolveActingRole } from "../quorum/roles"; import { type ApprovalSigning, collectApprovals } from "./services/approval-collector"; import { type PollingOptions, @@ -113,6 +120,8 @@ type WalletContructorType = { apiDelegatedServerSignerAddresses?: string[]; signers?: SignerConfigForChain[]; signer?: SignerAdapter; + /** The active `signer`'s locator when it was selected as only a quorum member. */ + quorumMemberLocator?: SignerLocator | null; }; // How `useSigner` holds a key: as a recovery method, a registered signer, or a member of one of the wallet's quorums. @@ -151,6 +160,8 @@ export class Wallet { #apiDelegatedServerSignerAddresses: string[] = []; #signerInitialization: Promise; #recovering: Promise | null = null; + // The active key when useSigner selected it as only a quorum member: creating calls then act as its quorum. + #quorumMemberLocator: SignerLocator | null = null; constructor(args: WalletContructorType, apiClient: ApiClient) { const { @@ -164,6 +175,7 @@ export class Wallet { apiDelegatedServerSignerAddresses, signers, signer, + quorumMemberLocator, } = args; this.#apiClient = apiClient; this.chain = chain; @@ -182,6 +194,7 @@ export class Wallet { .map((s) => s.address); this.#apiDelegatedServerSignerAddresses = apiDelegatedServerSignerAddresses ?? []; this.#initialSigners = signers ?? []; + this.#quorumMemberLocator = quorumMemberLocator ?? null; this.#serverSignerResolver = new ServerSignerResolver({ chain, projectId: this.#apiClient.projectId, @@ -325,6 +338,10 @@ export class Wallet { return wallet.#initialSigners; } + protected static getQuorumMemberLocator(wallet: Wallet): SignerLocator | null { + return wallet.#quorumMemberLocator; + } + protected static getApiRecoveryServerSignerAddresses(wallet: Wallet): string[] { return wallet.#serverSignerResolver.apiRecoveryAddresses; } @@ -353,11 +370,48 @@ export class Wallet { } /** The `signer` field of a create call: the call's `signer` option, or else the active signer. */ - protected resolveSignerParams(options?: { signer?: SignerOption }): { signer: string } { - if (options?.signer == null) { - return { signer: this.#signerManager.require().locator() }; + protected async resolveSignerParams( + options?: { signer?: SignerOption }, + requestChain: Chain = this.chain + ): Promise<{ signer: string }> { + if (options?.signer != null) { + return { signer: this.resolveSignerOption(options.signer) }; + } + const activeLocator = this.#signerManager.require().locator(); + if (this.#quorumMemberLocator == null || !isSameSignerLocator(activeLocator, this.#quorumMemberLocator)) { + return { signer: activeLocator }; } - return { signer: this.resolveSignerOption(options.signer) }; + return { signer: await this.#actingRoleLocator(activeLocator, requestChain) }; + } + + // A member-only key acts as its quorum (EDD 007 §7 "Role"). Recovery methods and delegated quorums come from one + // fresh read of the wallet's config, so no per-signer lookup can silently drop a quorum from the decision. + async #actingRoleLocator(heldLocator: SignerLocator, requestChain: Chain): Promise { + const walletResponse = await this.#apiClient.getWallet(this.walletLocator); + if ("error" in walletResponse) { + throw new WalletNotAvailableError(JSON.stringify(walletResponse)); + } + // A response the role cannot be read from counts as a failed read: guessing could name the wrong role. + if (!isSupportedSmartWallet(walletResponse) || walletResponse.config == null) { + throw new WalletNotAvailableError(JSON.stringify(walletResponse)); + } + const recoveryMethods = recoveryMethodsFromWalletConfig(walletResponse.config); + const configSigners = walletResponse.config.delegatedSigners ?? []; + const role = resolveActingRole({ + scope: "transaction", + heldLocator, + recoveryLocators: recoveryMethods + .filter((recovery) => !isQuorumSigner(recovery)) + .map((recovery) => this.#signerManager.recoveryLocator(recovery)) + .filter((locator) => locator != null), + recoveryQuorums: recoveryMethods.filter(isQuorumSigner), + delegatedQuorums: configSigners.filter(isQuorumSigner), + // Registered on the request's chain: an EVM signer registered on other chains only is just a member there. + isRegisteredSigner: configSigners.some( + (signer) => isSameSignerLocator(signer.locator, heldLocator) && isSignerOnChain(signer, requestChain) + ), + }); + return role.kind === "none" ? heldLocator : role.locator; } /** A quorum acting signer creates the request unsigned and returns it, as `prepareOnly: true` does (EDD 007 §7). */ @@ -542,8 +596,8 @@ export class Wallet { * @param {string} token - The token (address or currency symbol) * @param {string} amount - The amount to send (decimal units) * @param {TransactionInputOptions} options - The options for the transaction - * When the signer is a quorum (`quorum:…`), the call returns the created transaction unsigned and without a - * hash, as with `prepareOnly: true`. Pass `prepareOnly: true` for that type. + * When the signer is a quorum, named (`quorum:…`) or inferred from a key that is only a member, the call returns + * the transaction unsigned and without a hash, as with `prepareOnly: true`; pass `prepareOnly: true` for that type. * @returns {Transaction} The transaction */ @WithLoggerContext({ @@ -579,7 +633,7 @@ export class Wallet { await this.preAuthIfNeeded(); - const signerParams = this.resolveSignerParams(options); + const signerParams = await this.resolveSignerParams(options); const sendParams = { recipient, amount, @@ -1077,6 +1131,10 @@ export class Wallet { ? this.#signerManager.assembleMember(internalConfig, role.status) : await this.#signerManager.assemble(internalConfig, { isAdminSigner: role.kind === "recovery" }); this.#signerManager.setActiveSigner(activeSigner); + this.#quorumMemberLocator = null; + if (role.kind === "member") { + this.#quorumMemberLocator = activeSigner.locator(); + } walletsLogger.info("wallet.useSigner.success", { signerLocator }); }