Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/whatsapp-signer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@crossmint/wallets-sdk": minor
---

Add support for WhatsApp non-custodial signers
6 changes: 3 additions & 3 deletions packages/client/react-base/src/hooks/useSignerAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const throwNotAvailable = (functionName: string) => () => {
throw new Error(`${functionName} is not available. Make sure you're using an email or phone signer wallet.`);
};

export type SignerType = "email" | "phone";
export type SignerType = "email" | "phone" | "whatsapp";
export type DialogStep = "initial" | "otp";

export interface SignerAuthState {
Expand Down Expand Up @@ -128,15 +128,15 @@ export function useSignerAuth(): SignerAuthState & SignerAuthHandlers {

const onAuthRequired: Callbacks["onAuthRequired"] = useCallback(
(
signerType: "email" | "phone",
signerType: "email" | "phone" | "whatsapp",
signerLocator: string,
needsAuth: boolean,
sendMessageWithOtp: () => Promise<void>,
verifyOtp: (otp: string) => Promise<void>,
reject: () => void
) => {
const signerValue = signerLocator.split(":")[1];
if (signerType === "phone" && signerValue != null) {
if ((signerType === "phone" || signerType === "whatsapp") && signerValue != null) {
setPhoneSignerDialogOpen(needsAuth);
setActiveAuthPhone(needsAuth ? signerValue : undefined);
sendPhoneOtpRef.current = sendMessageWithOtp;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export function CrossmintWalletBaseProvider({

const wrappedOnAuthRequired = useCallback(
async (
signerType: "email" | "phone",
signerType: "email" | "phone" | "whatsapp",
signerLocator: string,
needsAuth: boolean,
sendOtp: () => Promise<void>,
Expand All @@ -255,7 +255,7 @@ export function CrossmintWalletBaseProvider({

const initializeWebViewIfNeeded = useCallback(
async (signer: SignerConfigForChain<Chain>) => {
if (signer.type === "email" || signer.type === "phone") {
if (signer.type === "email" || signer.type === "phone" || signer.type === "whatsapp") {
await initializeWebView?.();
}
},
Expand Down
2 changes: 2 additions & 0 deletions packages/wallets/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type {
SignerAdapter,
EmailSignerConfig,
PhoneSignerConfig,
WhatsappSignerConfig,
ExternalWalletSignerConfig,
ExternalWalletRegistrationConfig,
EvmExternalWalletSignerConfig,
Expand All @@ -68,6 +69,7 @@ export type {
SignerLocator,
EmailSignerLocator,
PhoneSignerLocator,
WhatsappSignerLocator,
PasskeySignerLocator,
DeviceSignerLocator,
DeviceSignerConfig,
Expand Down
142 changes: 76 additions & 66 deletions packages/wallets/src/openapi.json

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions packages/wallets/src/signers/descriptors/descriptors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ function makeCtx(
it.each<[name: string, type: string, config: Config, message: string]>([
["email missing email", "email", cfg({ type: "email" }), "Email signer requires an email address"],
["phone missing phone", "phone", cfg({ type: "phone" }), "Phone signer requires a phone number"],
["whatsapp missing phone", "whatsapp", cfg({ type: "whatsapp" }), "Whatsapp signer requires a phone number"],
[
"ext missing address",
"external-wallet",
Expand All @@ -63,6 +64,7 @@ it.each<[name: string, type: string, config: Config, message: string]>([
it.each<[type: string, config: Config]>([
["email", cfg({ type: "email", email: "a@b.com" })],
["phone", cfg({ type: "phone", phone: "+15551234" })],
["whatsapp", cfg({ type: "whatsapp", phone: "+15551234" })],
["external-wallet", cfg({ type: "external-wallet", address: "0xabc", onSign: vi.fn() })],
["api-key", cfg({ type: "api-key" })],
["passkey", cfg({ type: "passkey" })],
Expand All @@ -72,9 +74,10 @@ it.each<[type: string, config: Config]>([
expect(() => getSignerDescriptor(type as never).validateConfig(config as never)).not.toThrow();
});

it.each<[type: "email" | "phone", field: "email" | "phone", value: string]>([
it.each<[type: "email" | "phone" | "whatsapp", field: "email" | "phone", value: string]>([
["email", "email", "a@b.com"],
["phone", "phone", "+15551234"],
["whatsapp", "phone", "+15551234"],
])("buildInternalConfig: %s threads crossmint/clientTEEConnection/onAuthRequired from ctx", (type, field, value) => {
const result = getSignerDescriptor(type).buildInternalConfig({ type, [field]: value } as never, makeCtx());
expect(result).toEqual({
Expand Down Expand Up @@ -139,7 +142,7 @@ it.each<[name: string, config: Config, expected: object]>([
expect(getSignerDescriptor("passkey").buildInternalConfig(config as never, makeCtx())).toMatchObject(expected);
});

it.each<[type: string]>([["email"], ["phone"], ["passkey"], ["api-key"]])(
it.each<[type: string]>([["email"], ["phone"], ["whatsapp"], ["passkey"], ["api-key"]])(
"canAutoAssemble: %s is always true",
(type) => {
expect(getSignerDescriptor(type as never).canAutoAssemble({ type } as never, makeCtx())).toBe(true);
Expand Down Expand Up @@ -192,6 +195,7 @@ it("addSignerPayload: device without publicKey returns locator", () => {
it.each<[type: string, config: Config, expected: string]>([
["email", cfg({ type: "email", email: "a@b.com" }), "email:a@b.com"],
["phone", cfg({ type: "phone", phone: "+15551234" }), "phone:+15551234"],
["whatsapp", cfg({ type: "whatsapp", phone: "+15551234" }), "whatsapp:+15551234"],
["api-key", cfg({ type: "api-key" }), "api-key"],
["external-wallet", cfg({ type: "external-wallet", address: "0xabc", onSign: vi.fn() }), "external-wallet:0xabc"],
])("addSignerPayload: %s returns locator", (type, config, expected) => {
Expand Down Expand Up @@ -279,6 +283,7 @@ it.each<[name: string, type: string, config: Config, recovery: Config, expected:
it.each([
["email", true],
["phone", true],
["whatsapp", true],
["api-key", true],
["device", true],
["external-wallet", true],
Expand All @@ -295,7 +300,7 @@ it.each([
expect(getSignerDescriptor(type).signerUnavailableReason()).toMatch(pattern);
});

it.each(["email", "phone", "api-key", "device", "passkey"] as const)(
it.each(["email", "phone", "whatsapp", "api-key", "device", "passkey"] as const)(
"signerUnavailableReason: %s returns null so require() applies the generic fallback",
(type) => {
expect(getSignerDescriptor(type).signerUnavailableReason()).toBeNull();
Expand Down
3 changes: 3 additions & 0 deletions packages/wallets/src/signers/descriptors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { externalWalletSignerDescriptor } from "./external-wallet";
import { passkeySignerDescriptor } from "./passkey";
import { phoneSignerDescriptor } from "./phone";
import { serverSignerDescriptor } from "./server";
import { whatsappSignerDescriptor } from "./whatsapp";
import type { SignerDescriptor } from "./types";

export type { SignerDescriptor, SignerDescriptorContext } from "./types";
Expand All @@ -17,6 +18,8 @@ export function getSignerDescriptor<C extends Chain>(type: SignerDescriptor["typ
return emailSignerDescriptor as SignerDescriptor<C>;
case "phone":
return phoneSignerDescriptor as SignerDescriptor<C>;
case "whatsapp":
return whatsappSignerDescriptor as SignerDescriptor<C>;
case "passkey":
return passkeySignerDescriptor as SignerDescriptor<C>;
case "device":
Expand Down
2 changes: 1 addition & 1 deletion packages/wallets/src/signers/descriptors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface SignerDescriptorContext<C extends Chain> {
}

export interface SignerDescriptor<C extends Chain = Chain> {
readonly type: "email" | "phone" | "passkey" | "device" | "api-key" | "server" | "external-wallet";
readonly type: "email" | "phone" | "whatsapp" | "passkey" | "device" | "api-key" | "server" | "external-wallet";
validateConfig(config: SignerConfigForChain<C>): void;
buildInternalConfig(
config: SignerConfigForChain<C> | ApiSourcedServerSignerConfig,
Expand Down
47 changes: 47 additions & 0 deletions packages/wallets/src/signers/descriptors/whatsapp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { RegisterSignerParams } from "../../api";
import type { Chain } from "../../chains/chains";
import { getSignerLocator } from "../../utils/signer-locator";
import type {
InternalSignerConfig,
SignerConfigForChain,
RecoverySignerConfigForChain,
SignerLocator,
WhatsappSignerConfig,
} from "../types";
import type { SignerDescriptor, SignerDescriptorContext } from "./types";

export const whatsappSignerDescriptor: SignerDescriptor = {
type: "whatsapp",
validateConfig(config: SignerConfigForChain<Chain>): void {
if (!("phone" in config) || config.phone == null) {
throw new Error("Whatsapp signer requires a phone number");
}
},
buildInternalConfig(
config: SignerConfigForChain<Chain>,
ctx: SignerDescriptorContext<Chain>
): InternalSignerConfig<Chain> {
const whatsappConfig = config as WhatsappSignerConfig;
return {
type: "whatsapp",
phone: whatsappConfig.phone,
locator: `whatsapp:${whatsappConfig.phone}` as SignerLocator,
address: ctx.walletAddress,
crossmint: ctx.crossmint,
clientTEEConnection: ctx.clientTEEConnection,
resetSignerFrame: ctx.resetSignerFrame,
onAuthRequired: ctx.onAuthRequired,
} as InternalSignerConfig<Chain>;
},
canAutoAssemble(): boolean {
return true;
},
addSignerPayload(config: SignerConfigForChain<Chain>): RegisterSignerParams["signer"] {
return getSignerLocator(config);
},
matchesRecovery(config: SignerConfigForChain<Chain>, recovery: RecoverySignerConfigForChain<Chain>): boolean {
return getSignerLocator(config) === getSignerLocator(recovery as SignerConfigForChain<Chain>);
},
adoptsRecoveryConfigOnMatch: true,
signerUnavailableReason: () => null,
};
1 change: 1 addition & 0 deletions packages/wallets/src/signers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function assembleSigner<C extends Chain>(
switch (config.type) {
case "email":
case "phone":
case "whatsapp":
if (chain === "solana") {
return new SolanaNonCustodialSigner(config);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/wallets/src/signers/non-custodial/ncs-evm-signer.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { EmailInternalSignerConfig, PhoneInternalSignerConfig } from "../types";
import type { EmailInternalSignerConfig, PhoneInternalSignerConfig, WhatsappInternalSignerConfig } from "../types";
import { NonCustodialSigner, DEFAULT_EVENT_OPTIONS } from "./ncs-signer";
import { PersonalMessage } from "ox";
import { isHex, toHex, type Hex } from "viem";
import { walletsLogger } from "../../logger";

export class EVMNonCustodialSigner extends NonCustodialSigner {
constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig) {
constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig | WhatsappInternalSignerConfig) {
super(config);
}

Expand Down
15 changes: 11 additions & 4 deletions packages/wallets/src/signers/non-custodial/ncs-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type {
PhoneInternalSignerConfig,
PhoneSignerLocator,
SignerAdapter,
WhatsappInternalSignerConfig,
WhatsappSignerLocator,
} from "../types";
import {
AuthRejectedError,
Expand All @@ -20,7 +22,7 @@ import type { SignerOutputEvent } from "@crossmint/client-signers";
import { walletsLogger } from "../../logger";

export abstract class NonCustodialSigner implements SignerAdapter {
public readonly type: "email" | "phone";
public readonly type: "email" | "phone" | "whatsapp";
private _needsAuth = true;
private _authPromise: {
promise: Promise<void>;
Expand All @@ -30,15 +32,17 @@ export abstract class NonCustodialSigner implements SignerAdapter {
private _initializationPromise: Promise<void> | null = null;
private _onboardingConnectionGeneration: number | null = null;

constructor(protected config: EmailInternalSignerConfig | PhoneInternalSignerConfig) {
constructor(
protected config: EmailInternalSignerConfig | PhoneInternalSignerConfig | WhatsappInternalSignerConfig
) {
// Only initialize the signer if running client-side
if (typeof window !== "undefined") {
this._initializationPromise = this.initialize();
}
this.type = this.config.type;
}

locator(): EmailSignerLocator | PhoneSignerLocator {
locator(): EmailSignerLocator | PhoneSignerLocator | WhatsappSignerLocator {
return this.config.locator;
}

Expand Down Expand Up @@ -288,7 +292,10 @@ export abstract class NonCustodialSigner implements SignerAdapter {
if (this.config.type === "email") {
return `email:${this.config.email}`;
}
return `phone:${this.config.phone}`;
if (this.config.type === "phone") {
return `phone:${this.config.phone}`;
}
return `whatsapp:${this.config.phone}`;
}

private async verifyOtp(encryptedOtp: string) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { VersionedTransaction } from "@solana/web3.js";
import base58 from "bs58";
import type { EmailInternalSignerConfig, PhoneInternalSignerConfig } from "../types";
import type { EmailInternalSignerConfig, PhoneInternalSignerConfig, WhatsappInternalSignerConfig } from "../types";
import { NonCustodialSigner, DEFAULT_EVENT_OPTIONS } from "./ncs-signer";
import { walletsLogger } from "../../logger";

export class SolanaNonCustodialSigner extends NonCustodialSigner {
constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig) {
constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig | WhatsappInternalSignerConfig) {
super(config);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { EmailInternalSignerConfig, PhoneInternalSignerConfig } from "../types";
import type { EmailInternalSignerConfig, PhoneInternalSignerConfig, WhatsappInternalSignerConfig } from "../types";
import { DEFAULT_EVENT_OPTIONS, NonCustodialSigner } from "./ncs-signer";
import { walletsLogger } from "../../logger";

export class StellarNonCustodialSigner extends NonCustodialSigner {
constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig) {
constructor(config: EmailInternalSignerConfig | PhoneInternalSignerConfig | WhatsappInternalSignerConfig) {
super(config);
}

Expand Down
33 changes: 29 additions & 4 deletions packages/wallets/src/signers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,16 @@ export type PhoneSignerConfig = {
locator?: string;
};

export type NonCustodialSignerType = PhoneSignerConfig["type"] | EmailSignerConfig["type"];
export type WhatsappSignerConfig = {
type: "whatsapp";
phone?: string;
locator?: string;
};

export type NonCustodialSignerType =
| PhoneSignerConfig["type"]
| WhatsappSignerConfig["type"]
| EmailSignerConfig["type"];

export type ExternalWalletSignerConfigForChain<C extends Chain> = C extends SolanaChain
? SolanaExternalWalletSignerConfig
Expand Down Expand Up @@ -158,6 +167,12 @@ export type PhoneInternalSignerConfig = PhoneSignerConfig &
onAuthRequired?: Callbacks["onAuthRequired"];
};

export type WhatsappInternalSignerConfig = WhatsappSignerConfig &
Omit<BaseInternalSignerConfig, "locator"> & {
locator: WhatsappSignerLocator;
onAuthRequired?: Callbacks["onAuthRequired"];
};

export type DeviceInternalSignerConfig = {
type: "device";
locator?: DeviceSignerLocator;
Expand Down Expand Up @@ -189,6 +204,7 @@ export type ServerInternalSignerConfig = {
export type InternalSignerConfig<C extends Chain> =
| EmailInternalSignerConfig
| PhoneInternalSignerConfig
| WhatsappInternalSignerConfig
| PasskeyInternalSignerConfig
| ApiKeyInternalSignerConfig
| ExternalWalletInternalSignerConfig<C>
Expand Down Expand Up @@ -225,16 +241,23 @@ export type DeviceSignerConfig = {
};

export type SignerConfigForChain<C extends Chain> = C extends SolanaChain
? EmailSignerConfig | PhoneSignerConfig | BaseSignerConfig<C> | DeviceSignerConfig
? EmailSignerConfig | PhoneSignerConfig | WhatsappSignerConfig | BaseSignerConfig<C> | DeviceSignerConfig
: C extends StellarChain
? EmailSignerConfig | PhoneSignerConfig | BaseSignerConfig<C> | DeviceSignerConfig
: EmailSignerConfig | PhoneSignerConfig | PasskeySignerConfig | BaseSignerConfig<C> | DeviceSignerConfig;
? EmailSignerConfig | PhoneSignerConfig | WhatsappSignerConfig | BaseSignerConfig<C> | DeviceSignerConfig
:
| EmailSignerConfig
| PhoneSignerConfig
| WhatsappSignerConfig
| PasskeySignerConfig
| BaseSignerConfig<C>
| DeviceSignerConfig;

////////////////////////////////////////////////////////////
// Signer locator types
////////////////////////////////////////////////////////////
export type EmailSignerLocator = `email:${string}`;
export type PhoneSignerLocator = `phone:${string}`;
export type WhatsappSignerLocator = `whatsapp:${string}`;
export type PasskeySignerLocator = `passkey:${string}`;
export type DeviceSignerLocator = `device:${string}`;
export type ExternalWalletSignerLocator = `external-wallet:${string}`;
Expand All @@ -244,6 +267,7 @@ export type ServerSignerLocator = `server:${string}`;
export type SignerLocator =
| EmailSignerLocator
| PhoneSignerLocator
| WhatsappSignerLocator
| PasskeySignerLocator
| DeviceSignerLocator
| ExternalWalletSignerLocator
Expand All @@ -256,6 +280,7 @@ export type SignerLocator =
type SignResultMap = {
email: BaseSignResult;
phone: BaseSignResult;
whatsapp: BaseSignResult;
"api-key": BaseSignResult;
"external-wallet": BaseSignResult;
server: BaseSignResult;
Expand Down
5 changes: 5 additions & 0 deletions packages/wallets/src/utils/signer-locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ describe("getSignerLocator", () => {
expect(getSignerLocator(signer)).toBe("phone:+1234567890");
});

it("returns whatsapp locator", () => {
const signer = { type: "whatsapp", phone: "+1234567890" } as SignerConfigForChain<Chain>;
expect(getSignerLocator(signer)).toBe("whatsapp:+1234567890");
});

it("returns passkey locator", () => {
const signer = { type: "passkey", id: "cred-123" } as SignerConfigForChain<Chain>;
expect(getSignerLocator(signer)).toBe("passkey:cred-123");
Expand Down
Loading
Loading