diff --git a/.changeset/add-deploy-immediately-flag.md b/.changeset/add-deploy-immediately-flag.md new file mode 100644 index 000000000..3b8548d4c --- /dev/null +++ b/.changeset/add-deploy-immediately-flag.md @@ -0,0 +1,10 @@ +--- +"@crossmint/wallets-sdk": minor +--- + +feat(wallets): add `deployImmediately` flag to EVM `addSigner` + +When registering a delegated signer on an EVM wallet, the SDK now sends +`deployImmediately: true` by default, causing the API to return an on-chain +registration transaction instead of the lazy signature-request flow. This can be +overridden by passing `{ deployImmediately: false }` in the options. diff --git a/packages/wallets/src/api/types.ts b/packages/wallets/src/api/types.ts index a3fd6f183..f97cb771c 100644 --- a/packages/wallets/src/api/types.ts +++ b/packages/wallets/src/api/types.ts @@ -96,6 +96,7 @@ export type RegisterSignerParams = { signer: SignerLocator | RegisterSignerPasskeyParams | SignerConfigForChain; chain?: RegisterSignerChain; scopes?: Scope[]; + deployImmediately?: boolean; }; export type RegisterSignerResponse = DelegatedSignerV2025DtoClass | WalletsV2025ControllerCreateDelegatedSigner2Error; export type RemoveSignerParams = { diff --git a/packages/wallets/src/utils/signer-mapping.test.ts b/packages/wallets/src/utils/signer-mapping.test.ts index d4a9042be..bef138c18 100644 --- a/packages/wallets/src/utils/signer-mapping.test.ts +++ b/packages/wallets/src/utils/signer-mapping.test.ts @@ -24,6 +24,28 @@ describe("getPendingSignerOperation", () => { }); }); + it("returns a transaction operation for EVM deployImmediately chain entries", () => { + const apiSigner = { + type: "external-wallet", + address: "0x123", + locator: "external-wallet:0x123", + chains: { + "base-sepolia": { + id: "tx-456", + status: "awaiting-approval", + onChain: { userOperation: "0xabc", userOperationHash: "0xdef" }, + chainType: "evm", + walletType: "smart", + }, + }, + } as unknown as APISigner; + + expect(getPendingSignerOperation(apiSigner, "base-sepolia")).toEqual({ + type: "transaction", + id: "tx-456", + }); + }); + it("returns null for failed signer registrations", () => { const apiSigner = { type: "email", diff --git a/packages/wallets/src/utils/signer-mapping.ts b/packages/wallets/src/utils/signer-mapping.ts index 4915538a1..d2c816dda 100644 --- a/packages/wallets/src/utils/signer-mapping.ts +++ b/packages/wallets/src/utils/signer-mapping.ts @@ -109,7 +109,8 @@ export function getPendingSignerOperation( if ("chains" in apiSigner && apiSigner.chains != null) { const chainEntry = apiSigner.chains[chain]; if (chainEntry != null && (chainEntry.status === "pending" || chainEntry.status === "awaiting-approval")) { - return { type: "signature", id: chainEntry.id }; + const operationType = "onChain" in chainEntry ? "transaction" : "signature"; + return { type: operationType, id: chainEntry.id }; } } diff --git a/packages/wallets/src/wallets/types.ts b/packages/wallets/src/wallets/types.ts index 483c65616..1feda4433 100644 --- a/packages/wallets/src/wallets/types.ts +++ b/packages/wallets/src/wallets/types.ts @@ -38,6 +38,7 @@ export type SignatureInputOptions = PrepareOnly; export type AddSignerOptions = PrepareOnly & { scopes?: Scope[]; + deployImmediately?: boolean; }; export type RemoveSignerOptions = PrepareOnly; @@ -52,7 +53,7 @@ export type MigrateOptions = Partial & { export type AddSignerReturnType = C extends "solana" | "stellar" ? Signer & { transactionId: string } - : Signer & { signatureId?: string }; + : Signer & { signatureId?: string; transactionId?: string }; export type RemoveSignerReturnType = { transactionId: string; status?: "success" }; diff --git a/packages/wallets/src/wallets/wallet.test.ts b/packages/wallets/src/wallets/wallet.test.ts index 42d63167a..ab1edaf52 100644 --- a/packages/wallets/src/wallets/wallet.test.ts +++ b/packages/wallets/src/wallets/wallet.test.ts @@ -658,6 +658,7 @@ describe("Wallet - addSigner()", () => { expect.objectContaining({ signer: "external-wallet:0x456", chain: "base-sepolia", + deployImmediately: true, }) ); expect(result.type).toBe("external-wallet"); @@ -665,6 +666,100 @@ describe("Wallet - addSigner()", () => { expect(result.status).toBe("success"); }); + it("passes deployImmediately: false when explicitly set", async () => { + const mockRegisterResponse = { + type: "external-wallet", + address: "0x456", + locator: "external-wallet:0x456", + chains: { + "base-sepolia": { + id: "sig-123", + status: "success", + }, + }, + }; + + mockApiClient.registerSigner.mockResolvedValue(mockRegisterResponse as any); + + await evmWallet.addSigner( + { type: "external-wallet", address: "0x456" }, + { prepareOnly: false, deployImmediately: false } + ); + + expect(mockApiClient.registerSigner).toHaveBeenCalledWith( + "me:evm:smart", + expect.objectContaining({ + signer: "external-wallet:0x456", + chain: "base-sepolia", + deployImmediately: false, + }) + ); + }); + + it("approves transaction when deployImmediately response has onChain field", async () => { + const mockRegisterResponse = { + type: "external-wallet", + address: "0x456", + locator: "external-wallet:0x456", + chains: { + "base-sepolia": { + id: "tx-789", + status: "awaiting-approval", + onChain: { userOperation: "0xabc", userOperationHash: "0xdef" }, + chainType: "evm", + walletType: "smart", + }, + }, + }; + + const mockTransactionResponse = { + id: "tx-789", + status: "success", + onChain: { + txId: "0xhash", + explorerLink: "https://basescan.org/tx/0xhash", + }, + }; + + mockApiClient.registerSigner.mockResolvedValue(mockRegisterResponse as any); + mockApiClient.getTransaction.mockResolvedValue(mockTransactionResponse as any); + + const addPromise = evmWallet.addSigner({ type: "external-wallet", address: "0x456" }); + await vi.runAllTimersAsync(); + const result = await addPromise; + + expect(result.type).toBe("external-wallet"); + expect(result.status).toBe("success"); + }); + + it("returns transactionId with prepareOnly for deployImmediately flow", async () => { + const mockRegisterResponse = { + type: "external-wallet", + address: "0x456", + locator: "external-wallet:0x456", + chains: { + "base-sepolia": { + id: "tx-789", + status: "awaiting-approval", + onChain: { userOperation: "0xabc", userOperationHash: "0xdef" }, + chainType: "evm", + walletType: "smart", + }, + }, + }; + + mockApiClient.registerSigner.mockResolvedValue(mockRegisterResponse as any); + + const result = await evmWallet.addSigner( + { type: "external-wallet", address: "0x456" }, + { prepareOnly: true } + ); + + expect(result.transactionId).toBe("tx-789"); + expect(result.type).toBe("external-wallet"); + expect(result.status).toBe("awaiting-approval"); + }); + it("returns signatureId with prepareOnly", async () => { const mockRegisterResponse = { type: "external-wallet", diff --git a/packages/wallets/src/wallets/wallet.ts b/packages/wallets/src/wallets/wallet.ts index e4280f2a4..3ddaac473 100644 --- a/packages/wallets/src/wallets/wallet.ts +++ b/packages/wallets/src/wallets/wallet.ts @@ -774,10 +774,14 @@ export class Wallet { } : getSignerLocator(resolvedSigner); + const isEvm = this.chain !== "solana" && this.chain !== "stellar"; + const deployImmediately = isEvm ? options?.deployImmediately ?? true : undefined; + const response = await this.#apiClient.registerSigner(this.walletLocator, { signer: signerInput as RegisterSignerParams["signer"], chain: this.getSignerRegistrationChain(), ...(options?.scopes != null && { scopes: options.scopes }), + ...(deployImmediately != null && { deployImmediately }), }); if ("error" in response) {