Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/quorum-act-as-quorum.md
Original file line number Diff line number Diff line change
@@ -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:<id>"` 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.
21 changes: 20 additions & 1 deletion packages/wallets/src/utils/recovery.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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([]);
});
});
10 changes: 10 additions & 0 deletions packages/wallets/src/utils/recovery.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -37,6 +38,15 @@ export function recoveryMethodsFromCreateArgs<C extends Chain>(
return [args.recovery];
}

/** The recovery methods the wallets API lists in a wallet's config: `recoveryMethods`, else the deprecated `adminSigner`. */
export function recoveryMethodsFromWalletConfig<C extends Chain>(config: {
adminSigner?: unknown;
recoveryMethods?: unknown[];
}): Array<RecoverySignerConfigForChain<C>> {
const recoveryMethods = config.recoveryMethods ?? (config.adminSigner != null ? [config.adminSigner] : []);
return recoveryMethods as Array<RecoverySignerConfigForChain<C>>;
}

/** True when the caller supplied a list of recovery methods, through `recoveryMethods` or a list under the deprecated `recovery`. */
export function hasRecoveryMethodList<C extends Chain>(
args: Pick<WalletCreateArgs<C>, "recovery" | "recoveryMethods">
Expand Down
36 changes: 35 additions & 1 deletion packages/wallets/src/utils/signer-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
24 changes: 15 additions & 9 deletions packages/wallets/src/utils/signer-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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 {
Expand Down
32 changes: 32 additions & 0 deletions packages/wallets/src/wallets/__tests__/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)));

Expand All @@ -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;
23 changes: 14 additions & 9 deletions packages/wallets/src/wallets/evm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class EVMWallet extends Wallet<EVMChain> {
apiDelegatedServerSignerAddresses: Wallet.getApiDelegatedServerSignerAddresses(wallet),
signer: wallet.signer,
signers: Wallet.getInitialSigners(wallet),
quorumMemberLocator: Wallet.getQuorumMemberLocator(wallet),
},
Wallet.getApiClient(wallet)
);
Expand All @@ -51,8 +52,8 @@ export class EVMWallet extends Wallet<EVMChain> {
/**
* 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({
Expand All @@ -69,7 +70,7 @@ export class EVMWallet extends Wallet<EVMChain> {

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)) {
Expand All @@ -93,6 +94,8 @@ export class EVMWallet extends Wallet<EVMChain> {

/**
* 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
*/
Expand All @@ -109,12 +112,12 @@ export class EVMWallet extends Wallet<EVMChain> {
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,
},
});
Expand All @@ -124,7 +127,7 @@ export class EVMWallet extends Wallet<EVMChain> {
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,
});
Expand All @@ -141,6 +144,8 @@ export class EVMWallet extends Wallet<EVMChain> {

/**
* 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
*/
Expand All @@ -157,7 +162,6 @@ export class EVMWallet extends Wallet<EVMChain> {
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" });
Expand All @@ -170,6 +174,7 @@ export class EVMWallet extends Wallet<EVMChain> {
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: {
Expand All @@ -185,7 +190,7 @@ export class EVMWallet extends Wallet<EVMChain> {
primaryType,
types: types as unknown as Record<string, Array<{ name: string; type: string }>>,
},
signer: signer.locator(),
...signerParams,
chain,
},
});
Expand All @@ -195,7 +200,7 @@ export class EVMWallet extends Wallet<EVMChain> {
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,
});
Expand Down
3 changes: 2 additions & 1 deletion packages/wallets/src/wallets/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class SolanaWallet extends Wallet<SolanaChain> {
apiDelegatedServerSignerAddresses: Wallet.getApiDelegatedServerSignerAddresses(wallet),
signer: wallet.signer,
signers: Wallet.getInitialSigners(wallet),
quorumMemberLocator: Wallet.getQuorumMemberLocator(wallet),
},
Wallet.getApiClient(wallet)
);
Expand Down Expand Up @@ -60,7 +61,7 @@ export class SolanaWallet extends Wallet<SolanaChain> {
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)) {
Expand Down
7 changes: 4 additions & 3 deletions packages/wallets/src/wallets/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class StellarWallet extends Wallet<StellarChain> {
apiDelegatedServerSignerAddresses: Wallet.getApiDelegatedServerSignerAddresses(wallet),
signer: wallet.signer,
signers: Wallet.getInitialSigners(wallet),
quorumMemberLocator: Wallet.getQuorumMemberLocator(wallet),
},
Wallet.getApiClient(wallet)
);
Expand All @@ -47,8 +48,8 @@ export class StellarWallet extends Wallet<StellarChain> {
/**
* 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({
Expand All @@ -64,7 +65,7 @@ export class StellarWallet extends Wallet<StellarChain> {
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)) {
Expand Down
2 changes: 1 addition & 1 deletion packages/wallets/src/wallets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
10 changes: 6 additions & 4 deletions packages/wallets/src/wallets/wallet-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<C>
>;
const apiRecoverySigners = recoveryMethodsFromWalletConfig<C>(walletConfig);
const recoverySigners = this.mergeRecoverySigners(
apiRecoverySigners,
recoveryMethodsFromCreateArgs(createArgs),
Expand Down
Loading
Loading