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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function PhantomWallet() {
}
};


const handleNavigateBack = () => {
setCurrentScreen("navigation");
};
Expand Down
7 changes: 7 additions & 0 deletions examples/browser-sdk-demo-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ <h3>Provider Configuration</h3>
<option value="kit">@solana/kit</option>
</select>
</div>

<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="externalWalletAuth" /> Use External Wallet Authentication
</label>
<small class="help-text">Connect with Phantom browser extension for authentication</small>
</div>
</div>

<div class="section">
Expand Down
38 changes: 37 additions & 1 deletion examples/browser-sdk-demo-app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Get configuration UI elements
const providerTypeSelect = document.getElementById("providerType") as HTMLSelectElement;
const solanaProviderSelect = document.getElementById("solanaProvider") as HTMLSelectElement;
const externalWalletAuthCheckbox = document.getElementById("externalWalletAuth") as HTMLInputElement;
const testWeb3jsBtn = document.getElementById("testWeb3jsBtn") as HTMLButtonElement;
const testKitBtn = document.getElementById("testKitBtn") as HTMLButtonElement;
const testEthereumBtn = document.getElementById("testEthereumBtn") as HTMLButtonElement;
Expand Down Expand Up @@ -268,7 +269,18 @@ document.addEventListener("DOMContentLoaded", () => {
connectBtn.onclick = async () => {
try {
sdk = createSDK();
const result = await sdk.connect();

// Check if external wallet authentication is selected
const useExternalWallet = externalWalletAuthCheckbox?.checked || false;
const connectOptions = {
embeddedWalletType: useExternalWallet ? "app-wallet": "user-wallet",
authOptions: useExternalWallet ? {
provider: "external_wallet" as const,
}: undefined
}

console.log("Connecting with options:", connectOptions);
const result = await sdk.connect(connectOptions);
connectedAddresses = result.addresses;

console.log("Connected successfully:", result);
Expand Down Expand Up @@ -548,6 +560,30 @@ document.addEventListener("DOMContentLoaded", () => {
};
}

// Update external wallet auth visibility based on provider type
function updateExternalWalletVisibility() {
if (externalWalletAuthCheckbox) {
const parentDiv = externalWalletAuthCheckbox.closest('.form-group') as HTMLDivElement;
if (parentDiv) {
const isEmbedded = providerTypeSelect.value === "embedded";
parentDiv.style.display = isEmbedded ? "block" : "none";
if (!isEmbedded) {
externalWalletAuthCheckbox.checked = false;
}
}
}
}

// Provider type change handler
if (providerTypeSelect) {
providerTypeSelect.onchange = () => {
updateExternalWalletVisibility();
};
}

// Initialize external wallet visibility
updateExternalWalletVisibility();

// Initialize button states
updateButtonStates(false);

Expand Down
7 changes: 7 additions & 0 deletions examples/browser-sdk-demo-app/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ h3 {
margin: 0;
}

.help-text {
display: block;
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.25rem;
}

/* Button Styles */
button {
padding: 0.625rem 1rem;
Expand Down
40 changes: 40 additions & 0 deletions packages/client/src/PhantomClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
type CreateAuthenticatorParams,
type DeleteAuthenticatorParams,
type UserConfig,
type GetOrCreatePhantomOrganizationParams,
} from "./types";

import type { Stamper } from "@phantom/sdk-types";
Expand Down Expand Up @@ -436,6 +437,29 @@ export class PhantomClient {
}
}

/**
* Get or create a Phantom organization using an external wallet public key
* This method is used for external wallet integration (e.g., browser extension)
*/
async getOrCreatePhantomOrganization(params: GetOrCreatePhantomOrganizationParams): Promise<ExternalKmsOrganization> {
try {
const request = {
method: 'getOrCreatePhantomOrganization',
params: {
publicKey: params.publicKey
},
timestampMs: Date.now(),
};

const response = await this.kmsApi.postKmsRpc(request as any);
const result = response.data.result as ExternalKmsOrganization;
return result;
} catch (error: any) {
console.error("Failed to get or create Phantom organization:", error.response?.data || error.message);
throw new Error(`Failed to get or create Phantom organization: ${error.response?.data?.message || error.message}`);
}
}

/**
* Create an authenticator for a user in an organization
*/
Expand Down Expand Up @@ -537,6 +561,22 @@ export class PhantomClient {
}
}

/**
* Get a wallet by tag, or create one if it doesn't exist
* This is useful for external wallet integration where we want a consistent wallet for a specific tag
*/
async getOrCreateWalletWithTag(params: GetWalletWithTagParams & { walletName?: string }): Promise<any> {
try {
// First try to get the wallet with the tag
return await this.getWalletWithTag(params);
} catch (error: any) {
// If wallet doesn't exist, create a new one with the tag as the name
// Wallet with tag not found, creating new wallet
const walletName = params.walletName || params.tag;
return await this.createWallet(walletName);
}
}

/**
* Stamp an axios request with the provided stamper
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,7 @@ export interface UserConfig {
role?: "admin" | "user"; // Optional, defaults to 'admin'
authenticators: AuthenticatorConfig[];
}

export interface GetOrCreatePhantomOrganizationParams {
publicKey: string; // base58 encoded public key from external wallet
}
1 change: 1 addition & 0 deletions packages/embedded-provider-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@phantom/client": "workspace:^",
"@phantom/constants": "workspace:^",
"@phantom/parsers": "workspace:^",
"@phantom/phantom-wallet-stamper": "workspace:^",
"@phantom/sdk-types": "workspace:^",
"bs58": "^6.0.0"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ describe("EmbeddedProvider Core", () => {
};

await expect(provider.connect(invalidAuthOptions)).rejects.toThrow(
'Invalid auth provider: invalid-provider. Must be "google", "apple", or "jwt"',
'Invalid auth provider: invalid-provider. Must be "google", "apple", "jwt", or "external_wallet"',
);
});
});
Expand Down
131 changes: 128 additions & 3 deletions packages/embedded-provider-core/src/embedded-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { JWTAuth } from "./auth/jwt-auth";
import { generateSessionId } from "./utils/session";
import { retryWithBackoff } from "./utils/retry";
import type { StamperWithKeyManagement } from "@phantom/sdk-types";
import { PhantomWalletStamper } from "@phantom/phantom-wallet-stamper";
export class EmbeddedProvider {
private config: EmbeddedProviderConfig;
private platform: PlatformAdapter;
Expand Down Expand Up @@ -140,15 +141,133 @@ export class EmbeddedProvider {
private validateAuthOptions(authOptions?: AuthOptions): void {
if (!authOptions) return;

if (authOptions.provider && !["google", "apple", "jwt"].includes(authOptions.provider)) {
throw new Error(`Invalid auth provider: ${authOptions.provider}. Must be "google", "apple", or "jwt"`);
if (authOptions.provider && !["google", "apple", "jwt", "external_wallet"].includes(authOptions.provider)) {
throw new Error(`Invalid auth provider: ${authOptions.provider}. Must be "google", "apple", "jwt", or "external_wallet"`);
}

if (authOptions.provider === "jwt" && !authOptions.jwtToken) {
throw new Error("JWT token is required when using JWT authentication");
}
}

/*
* We use this method to handle external wallet authentication flow.
* It connects to Phantom wallet, gets/creates organization and wallet, then sets up local IndexedDB stamper.
*/
private async handleExternalWalletAuth(
_organizationId: string,
_stamperInfo: StamperInfo,
): Promise<Session> {
this.logger.info("EMBEDDED_PROVIDER", "Starting external wallet authentication flow");

// Step 1: Create and initialize PhantomWallet stamper
this.logger.log("EMBEDDED_PROVIDER", "Creating PhantomWallet stamper");
const phantomStamper = new PhantomWalletStamper({
platform: "auto",
timeout: 30000,
});

const phantomStamperInfo = await phantomStamper.init();
this.logger.log("EMBEDDED_PROVIDER", "PhantomWallet stamper initialized", {
publicKey: phantomStamperInfo.publicKey,
keyId: phantomStamperInfo.keyId,
});

// Step 2: Create temporary client with Phantom stamper to get/create organization
this.logger.log("EMBEDDED_PROVIDER", "Creating temporary PhantomClient with Phantom stamper");
const phantomClient = new PhantomClient(
{
apiBaseUrl: this.config.apiBaseUrl,
},
phantomStamper,
);

// Step 3: Get or create organization using Phantom wallet
this.logger.log("EMBEDDED_PROVIDER", "Getting or creating Phantom organization");
const base64urlPhantomKey = base64urlEncode(bs58.decode(phantomStamperInfo.publicKey));
const phantomOrganization = await phantomClient.getOrCreatePhantomOrganization({
publicKey: base64urlPhantomKey,
});
this.logger.info("EMBEDDED_PROVIDER", "Phantom organization ready", {
organizationId: phantomOrganization.organizationId,
});

// Step 4: Get or create wallet by tag using Phantom stamper
const walletTag = `external-wallet-${phantomStamperInfo.publicKey.slice(0, 8)}`;
this.logger.log("EMBEDDED_PROVIDER", "Getting or creating wallet with tag", { walletTag });
const wallet = await phantomClient.getOrCreateWalletWithTag({
organizationId: phantomOrganization.organizationId,
tag: walletTag,
derivationPaths: [
"m/44'/501'/0'/0'", // Solana
"m/44'/60'/0'/0/0", // Ethereum
"m/84'/0'/0'/0/0", // Bitcoin
],
walletName: `External Wallet ${phantomStamperInfo.publicKey.slice(0, 8)}`,
});
this.logger.info("EMBEDDED_PROVIDER", "External wallet ready", {
walletId: wallet.walletId,
tag: walletTag,
});

// Step 5: Initialize local platform stamper
this.logger.log("EMBEDDED_PROVIDER", "Initializing local platform stamper");
const localStamperInfo = await this.stamper.init();
this.logger.log("EMBEDDED_PROVIDER", "Local platform stamper initialized", {
publicKey: localStamperInfo.publicKey,
keyId: localStamperInfo.keyId,
});

// Step 6: Add local stamper as new authenticator on the Phantom organization
this.logger.log("EMBEDDED_PROVIDER", "Adding local authenticator to Phantom organization");
const base64urlLocalKey = base64urlEncode(bs58.decode(localStamperInfo.publicKey));

// Create authenticator for the admin user in the organization
const username = `user-phantom-${phantomStamperInfo.publicKey.slice(0, 8)}`;
await phantomClient.createAuthenticator({
organizationId: phantomOrganization.organizationId,
username: username,
authenticatorName: `local-${localStamperInfo.keyId}`,
authenticator: {
authenticatorName: `local-${localStamperInfo.keyId}`,
authenticatorKind: "keypair",
publicKey: base64urlLocalKey,
algorithm: "Ed25519",
},
});
this.logger.info("EMBEDDED_PROVIDER", "Local authenticator added to Phantom organization", {
organizationId: phantomOrganization.organizationId,
username: username,
authenticatorName: `local-${localStamperInfo.keyId}`,
});

// Step 7: Create completed session
const now = Date.now();
const session: Session = {
sessionId: generateSessionId(),
walletId: wallet.walletId,
organizationId: phantomOrganization.organizationId,
stamperInfo: localStamperInfo, // Use local stamper for subsequent operations
authProvider: "external-wallet",
userInfo: {
embeddedWalletType: "app-wallet", // Creating an app wallet
authProvider: "external_wallet", // But using external wallet for auth
phantomPublicKey: phantomStamperInfo.publicKey,
},
status: "completed",
createdAt: now,
lastUsed: now,
};

await this.storage.saveSession(session);
this.logger.info("EMBEDDED_PROVIDER", "External wallet authentication completed", {
walletId: wallet.walletId,
organizationId: phantomOrganization.organizationId,
});

return session;
}

/*
* We use this method to initialize the stamper and create an organization for new sessions.
* This is the first step when no existing session is found and we need to set up a new wallet.
Expand Down Expand Up @@ -256,7 +375,7 @@ export class EmbeddedProvider {

// Update session last used timestamp (only for non-redirect flows)
// For redirect flows, timestamp is updated before redirect to prevent race condition
if (!authOptions || authOptions.provider === "jwt" || this.config.embeddedWalletType === "app-wallet") {
if (!authOptions || authOptions.provider === "jwt" || authOptions.provider === "external_wallet" || this.config.embeddedWalletType === "app-wallet") {
session.lastUsed = Date.now();
await this.storage.saveSession(session);
}
Expand Down Expand Up @@ -418,6 +537,12 @@ export class EmbeddedProvider {
// Route to appropriate authentication flow based on authOptions
if (authOptions?.provider === "jwt") {
return await this.handleJWTAuth(organizationId, stamperInfo, authOptions);
} else if (authOptions?.provider === "external_wallet") {
this.logger.info("EMBEDDED_PROVIDER", "Creating embedded wallet with external wallet authentication", {
organizationId,
});
// Handle external wallet authentication for embedded wallet
return await this.handleExternalWalletAuth(organizationId, stamperInfo);
} else {
// This will redirect in browser, so we don't return a session
// In react-native this will return an auth result
Expand Down
Loading