diff --git a/manifest.json b/manifest.json index cf61733..1c8aea8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Stellar Custom Wallet", - "version": "0.1.0", + "version": "0.1.1", "description": "A Stellar wallet with custom capabilities built with Deno and React.", "permissions": ["storage"], "host_permissions": ["https://*/*", "http://localhost/*"], diff --git a/src/background/dev-seed.ts b/src/background/dev-seed.ts index c12cf66..ba9fc49 100644 --- a/src/background/dev-seed.ts +++ b/src/background/dev-seed.ts @@ -15,6 +15,7 @@ import { getRpcServer, } from "@/background/contexts/chain/network.ts"; import { PrivacyProviderClient } from "@/background/services/privacy-provider-client.ts"; +import { extractPpPubkeyFromUrl } from "@/background/services/pp-url.ts"; import { checkAccountActivationStatus } from "@/background/contexts/chain/account-activation.ts"; import { TransactionBuilder } from "@stellar/stellar-sdk"; @@ -241,23 +242,24 @@ export async function applyDevSeed(): Promise { ); const existingUrls = new Set(channel?.providers.map((p) => p.url) ?? []); - // SEED_PROVIDERS entries are `name=url|pubkey`. The pubkey segment is - // required for per-PP URL paths; if missing the entry is skipped. + // SEED_PROVIDERS entries are `name=url`. The URL must end in the PP's + // Stellar pubkey (extractPpPubkeyFromUrl validates at call-time); entries + // whose URL doesn't parse are silently skipped here. const providerEntries = __SEED_PROVIDERS__.split(",").map((entry) => { const eqIdx = entry.indexOf("="); - const rhs = entry.slice(eqIdx + 1).trim(); - const [url, pubkey] = rhs.split("|").map((s) => s.trim()); - return { name: entry.slice(0, eqIdx).trim(), url, pubkey: pubkey ?? "" }; + return { + name: entry.slice(0, eqIdx).trim(), + url: entry.slice(eqIdx + 1).trim(), + }; }); - for (const { name, url, pubkey } of providerEntries) { - if (!url || !pubkey || existingUrls.has(url)) continue; + for (const { name, url } of providerEntries) { + if (!url || existingUrls.has(url)) continue; const providerId = crypto.randomUUID(); privateChannels.addProvider(network, channelId, { id: providerId, name, url, - pubkey, }); if (!firstProviderId) firstProviderId = providerId; console.log("[dev-seed] Provider added:", name, url); @@ -280,7 +282,7 @@ export async function applyDevSeed(): Promise { console.log("[dev-seed] Auto-connecting to provider:", provider.name); // Get auth challenge - const client = new PrivacyProviderClient(provider.url, provider.pubkey); + const client = new PrivacyProviderClient(provider.url); const challenge = await client.getAuthChallenge(publicKey); // Sign the challenge directly using the mnemonic @@ -296,8 +298,14 @@ export async function applyDevSeed(): Promise { transaction.sign(keypair); const signedXdr = transaction.toXDR(); - // Submit signed challenge - const authResponse = await client.postAuth(signedXdr); + // Submit signed challenge (PP-aware verify body) + const extracted = extractPpPubkeyFromUrl(provider.url); + if (!extracted) { + throw new Error( + `dev-seed provider URL missing pubkey path segment: ${provider.url}`, + ); + } + const authResponse = await client.postAuth(signedXdr, extracted.pubkey); // Save session const expiresAt = Date.now() + 24 * 60 * 60 * 1000; @@ -310,6 +318,7 @@ export async function applyDevSeed(): Promise { token: authResponse.token, expiresAt, entityStatus: authResponse.entityStatus, + kycSubmissionUrl: authResponse.kycSubmissionUrl, }, ); await privateChannels.setSelectedProvider( diff --git a/src/background/handler.ts b/src/background/handler.ts index 765159a..30760b0 100644 --- a/src/background/handler.ts +++ b/src/background/handler.ts @@ -53,7 +53,6 @@ import { handlePrepareWithdraw, handleWithdraw, } from "@/background/handlers/private/withdraw.ts"; -import { handleSubmitEntityKyc } from "@/background/handlers/private/submit-entity-kyc.ts"; import { ensureSessionHydrated, isUnlocked, @@ -117,7 +116,6 @@ const handlers: HandlerMap = { [MessageType.PrepareSend]: handlePrepareSend, [MessageType.Withdraw]: handleWithdraw, [MessageType.PrepareWithdraw]: handlePrepareWithdraw, - [MessageType.SubmitEntityKyc]: handleSubmitEntityKyc, }; browser.runtime.onMessage.addListener( diff --git a/src/background/handlers/private/add-privacy-provider.ts b/src/background/handlers/private/add-privacy-provider.ts index ba5ca03..2256c3b 100644 --- a/src/background/handlers/private/add-privacy-provider.ts +++ b/src/background/handlers/private/add-privacy-provider.ts @@ -1,16 +1,30 @@ import { MessageFor, MessageType, ResponseFor } from "@/background/messages.ts"; import { privateChannels } from "@/background/session.ts"; +import { extractPpPubkeyFromUrl } from "@/background/services/pp-url.ts"; export const handleAddPrivacyProvider = async ( message: MessageFor, ): Promise> => { try { + // Authoritative URL shape validation: the URL must encode the PP's + // Stellar pubkey as its last path segment. The popup form does a soft + // check first; the background is the gate. + if (!extractPpPubkeyFromUrl(message.url)) { + return { + type: MessageType.AddPrivacyProvider, + ok: false, + error: { + code: "UNKNOWN", + message: + "Provider URL must end with the PP's Stellar public key (e.g. https://provider-x.example/G…).", + }, + }; + } const providerId = crypto.randomUUID(); privateChannels.addProvider(message.network, message.channelId, { id: providerId, name: message.name, url: message.url, - pubkey: message.pubkey, }); await privateChannels.flush(); diff --git a/src/background/handlers/private/add-privacy-provider.types.ts b/src/background/handlers/private/add-privacy-provider.types.ts index fc595bb..b8be540 100644 --- a/src/background/handlers/private/add-privacy-provider.types.ts +++ b/src/background/handlers/private/add-privacy-provider.types.ts @@ -6,7 +6,6 @@ export type AddPrivacyProviderRequest = { channelId: string; name: string; url: string; - pubkey: string; }; export type AddPrivacyProviderResponse = diff --git a/src/background/handlers/private/connect-privacy-provider.ts b/src/background/handlers/private/connect-privacy-provider.ts index c47e088..cd93fb4 100644 --- a/src/background/handlers/private/connect-privacy-provider.ts +++ b/src/background/handlers/private/connect-privacy-provider.ts @@ -2,6 +2,7 @@ import { MessageType } from "@/background/messages.ts"; import type { Handler } from "@/background/messages.ts"; import { privateChannels, signingManager } from "@/background/session.ts"; import { PrivacyProviderClient } from "@/background/services/privacy-provider-client.ts"; +import { extractPpPubkeyFromUrl } from "@/background/services/pp-url.ts"; export const handleConnectPrivacyProvider: Handler< MessageType.ConnectPrivacyProvider @@ -10,14 +11,20 @@ export const handleConnectPrivacyProvider: Handler< channelId, providerId, providerUrl, - providerPubkey, accountId, publicKey, network, } = message; + const extracted = extractPpPubkeyFromUrl(providerUrl); + if (!extracted) { + throw new Error( + `Provider URL has no pubkey path segment: ${providerUrl}. Re-add the provider with the full URL ending in its Stellar public key.`, + ); + } + // 1. Get Authentication Challenge from Provider - const client = new PrivacyProviderClient(providerUrl, providerPubkey); + const client = new PrivacyProviderClient(providerUrl); const challenge = await client.getAuthChallenge(publicKey); // 2. Create Signing Request @@ -34,8 +41,8 @@ export const handleConnectPrivacyProvider: Handler< try { const signedXdr = await signingManager.waitForResult(request.id); - // 4. Submit Signed Challenge to Provider - const authResponse = await client.postAuth(signedXdr); + // 4. Submit Signed Challenge to Provider (PP-aware verify body) + const authResponse = await client.postAuth(signedXdr, extracted.pubkey); // Validate token was received if (!authResponse.token || typeof authResponse.token !== "string") { @@ -58,6 +65,7 @@ export const handleConnectPrivacyProvider: Handler< token: authResponse.token, expiresAt, entityStatus: authResponse.entityStatus, + kycSubmissionUrl: authResponse.kycSubmissionUrl, }, ); @@ -66,7 +74,8 @@ export const handleConnectPrivacyProvider: Handler< channelId, providerId, accountId, - hasToken: !!authResponse.token, + entityStatus: authResponse.entityStatus, + hasKycUrl: authResponse.kycSubmissionUrl !== null, }); // 6. Auto-select the provider diff --git a/src/background/handlers/private/connect-privacy-provider.types.ts b/src/background/handlers/private/connect-privacy-provider.types.ts index bbe88ac..fbf74d0 100644 --- a/src/background/handlers/private/connect-privacy-provider.types.ts +++ b/src/background/handlers/private/connect-privacy-provider.types.ts @@ -5,7 +5,6 @@ export type ConnectPrivacyProviderRequest = { channelAddress: string; providerId: string; providerUrl: string; - providerPubkey: string; accountId: string; publicKey: string; network: ChainNetwork; diff --git a/src/background/handlers/private/deposit.ts b/src/background/handlers/private/deposit.ts index ce74c99..4a40f1a 100644 --- a/src/background/handlers/private/deposit.ts +++ b/src/background/handlers/private/deposit.ts @@ -457,7 +457,7 @@ async function submitPreparedOperations( } // Submit bundle to provider - const client = new PrivacyProviderClient(provider.url, provider.pubkey); + const client = new PrivacyProviderClient(provider.url); try { const result = await client.submitBundle({ token: session.token, diff --git a/src/background/handlers/private/get-privacy-provider-auth-challenge.ts b/src/background/handlers/private/get-privacy-provider-auth-challenge.ts index e4d3bb0..3b03f64 100644 --- a/src/background/handlers/private/get-privacy-provider-auth-challenge.ts +++ b/src/background/handlers/private/get-privacy-provider-auth-challenge.ts @@ -7,8 +7,7 @@ export const handleGetPrivacyProviderAuthChallenge: Handler< > = async (message) => { const { providerUrl, publicKey } = message; - // SEP-10 challenge is global (not per-PP), so an empty pubkey is fine here. - const client = new PrivacyProviderClient(providerUrl, ""); + const client = new PrivacyProviderClient(providerUrl); const response = await client.getAuthChallenge(publicKey); return { diff --git a/src/background/handlers/private/send.ts b/src/background/handlers/private/send.ts index 46f2997..e8b6e25 100644 --- a/src/background/handlers/private/send.ts +++ b/src/background/handlers/private/send.ts @@ -554,7 +554,7 @@ async function submitPreparedOperations( } // Submit bundle to provider - const client = new PrivacyProviderClient(provider.url, provider.pubkey); + const client = new PrivacyProviderClient(provider.url); try { const result = await client.submitBundle({ token: session.token, diff --git a/src/background/handlers/private/submit-entity-kyc.ts b/src/background/handlers/private/submit-entity-kyc.ts deleted file mode 100644 index b61012e..0000000 --- a/src/background/handlers/private/submit-entity-kyc.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { MessageType } from "@/background/messages.ts"; -import type { Handler } from "@/background/messages.ts"; -import { privateChannels, unlockVault, vault } from "@/background/session.ts"; -import { Keys } from "@/keys/keys.ts"; -import { Buffer } from "buffer"; -import { PrivacyProviderClient } from "@/background/services/privacy-provider-client.ts"; - -// Wallet-side KYC submission. Mirrors the dance the recording rig used to -// do server-side: fetch nonce → sign with the user's Stellar key → submit -// `{ pubkey, name, jurisdictions, signedChallenge }` → provider promotes the -// entity to APPROVED. We then mirror that status onto the existing session so -// the next bundle attempt clears the BND_011 gate. -export const handleSubmitEntityKyc: Handler = - async (message) => { - try { - const { - network, - channelId, - providerId, - accountId, - password, - name, - jurisdictions, - } = message; - - const channels = privateChannels.getChannels(network); - const channel = channels.find((c) => c.id === channelId); - const provider = channel?.providers.find((p) => p.id === providerId); - if (!provider) { - return { - type: MessageType.SubmitEntityKyc, - ok: false, - error: { code: "UNKNOWN", message: "Provider not found" }, - }; - } - if (!provider.pubkey) { - return { - type: MessageType.SubmitEntityKyc, - ok: false, - error: { - code: "UNKNOWN", - message: - "Provider has no pubkey on record; re-add it with its public key.", - }, - }; - } - - await unlockVault({ password }); - const state = vault.store.getValue(); - const found = state.wallets - .flatMap((w) => w.accounts.map((a) => ({ wallet: w, account: a }))) - .find((x) => x.account.id === accountId); - if (!found) { - return { - type: MessageType.SubmitEntityKyc, - ok: false, - error: { code: "UNKNOWN", message: "Account not found" }, - }; - } - - let keypair; - if (found.wallet.type === "secret") { - if (found.account.type !== "imported") { - return { - type: MessageType.SubmitEntityKyc, - ok: false, - error: { - code: "UNKNOWN", - message: "Invalid account type for secret wallet", - }, - }; - } - keypair = Keys.keypairFromSecret(found.account.secret!); - } else { - keypair = await Keys.deriveStellarKeypairFromMnemonic( - found.wallet.mnemonic, - found.account.type === "derived" ? found.account.index : 0, - ); - } - - const client = new PrivacyProviderClient( - provider.url, - provider.pubkey, - ); - const { nonce } = await client.getEntityChallenge(keypair.publicKey()); - - // verify-stellar-signature accepts raw bytes: sign(base64-decoded nonce). - const nonceBytes = Buffer.from(nonce, "base64"); - const sig = keypair.sign(nonceBytes); - const signatureB64 = Buffer.from(sig).toString("base64"); - - const result = await client.submitEntity({ - pubkey: keypair.publicKey(), - name, - jurisdictions: jurisdictions ?? [], - signedChallenge: { nonce, signature: signatureB64 }, - }); - - // Mirror the new entity status onto the existing session so the next - // bundle attempt clears the BND_011 gate without forcing a reconnect. - const channelsNow = privateChannels.getChannels(network); - const channelNow = channelsNow.find((c) => c.id === channelId); - const providerNow = channelNow?.providers.find((p) => - p.id === providerId - ); - const session = providerNow?.sessions?.[accountId]; - if (session) { - await privateChannels.setProviderSession( - network, - channelId, - providerId, - accountId, - { ...session, entityStatus: result.status }, - ); - } - await privateChannels.flush(); - - return { type: MessageType.SubmitEntityKyc, ok: true }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - return { - type: MessageType.SubmitEntityKyc, - ok: false, - error: { code: "UNKNOWN", message: msg }, - }; - } - }; diff --git a/src/background/handlers/private/submit-entity-kyc.types.ts b/src/background/handlers/private/submit-entity-kyc.types.ts deleted file mode 100644 index 4d4c5be..0000000 --- a/src/background/handlers/private/submit-entity-kyc.types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { BackgroundError } from "@/background/types.ts"; -import type { ChainNetwork } from "@/persistence/stores/chain.types.ts"; - -export type SubmitEntityKycRequest = { - network: ChainNetwork; - channelId: string; - providerId: string; - accountId: string; - password: string; - name: string; - jurisdictions?: string[]; -}; - -export type SubmitEntityKycResponse = - | { - ok: true; - } - | { - ok: false; - error: BackgroundError; - }; diff --git a/src/background/handlers/private/withdraw.ts b/src/background/handlers/private/withdraw.ts index 393e35e..d0ff12c 100644 --- a/src/background/handlers/private/withdraw.ts +++ b/src/background/handlers/private/withdraw.ts @@ -530,7 +530,7 @@ async function submitPreparedOperations( } // Submit bundle to provider - const client = new PrivacyProviderClient(provider.url, provider.pubkey); + const client = new PrivacyProviderClient(provider.url); try { const result = await client.submitBundle({ token: session.token, diff --git a/src/background/messages.ts b/src/background/messages.ts index 2503098..0a6259b 100644 --- a/src/background/messages.ts +++ b/src/background/messages.ts @@ -149,10 +149,6 @@ import type { WithdrawRequest, WithdrawResponse, } from "@/background/handlers/private/withdraw.types.ts"; -import type { - SubmitEntityKycRequest, - SubmitEntityKycResponse, -} from "@/background/handlers/private/submit-entity-kyc.types.ts"; // Helper types and mapped types for messages and responses // ============================================================================== @@ -218,7 +214,6 @@ export enum MessageType { PrepareSend = "PREPARE_SEND", Withdraw = "WITHDRAW", PrepareWithdraw = "PREPARE_WITHDRAW", - SubmitEntityKyc = "SUBMIT_ENTITY_KYC", } export type MessagePayloadMap = { @@ -263,7 +258,6 @@ export type MessagePayloadMap = { [MessageType.PrepareSend]: PrepareSendRequest; [MessageType.Withdraw]: WithdrawRequest; [MessageType.PrepareWithdraw]: PrepareWithdrawRequest; - [MessageType.SubmitEntityKyc]: SubmitEntityKycRequest; }; export type ResponsePayloadMap = { @@ -308,5 +302,4 @@ export type ResponsePayloadMap = { [MessageType.PrepareSend]: PrepareSendResponse; [MessageType.Withdraw]: WithdrawResponse; [MessageType.PrepareWithdraw]: PrepareWithdrawResponse; - [MessageType.SubmitEntityKyc]: SubmitEntityKycResponse; }; diff --git a/src/background/services/pp-url.ts b/src/background/services/pp-url.ts new file mode 100644 index 0000000..b6ff66c --- /dev/null +++ b/src/background/services/pp-url.ts @@ -0,0 +1,30 @@ +// Every Privacy Provider URL encodes the PP's Stellar Ed25519 public key as +// its last non-empty path segment, e.g. `https://provider-x.example.com/`. +// The wallet stores only `{url, label}` per PP — pubkey + API base are +// derived at call-time via this helper. +// +// `apiBase` is the URL's scheme + host (+ port) WITHOUT the pubkey segment, +// suitable for building `${apiBase}/api/v1/providers/${pubkey}/...` paths. +// Returns null for any URL that doesn't parse, lacks a path segment matching +// the Stellar G-address shape, or is otherwise malformed. + +const STELLAR_G_ADDRESS = /^G[A-Z2-7]{55}$/; + +export type ExtractedPp = { pubkey: string; apiBase: string }; + +export function extractPpPubkeyFromUrl(url: string): ExtractedPp | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + const segments = parsed.pathname.split("/").filter((s) => s.length > 0); + const last = segments[segments.length - 1]; + if (!last || !STELLAR_G_ADDRESS.test(last)) return null; + const basePath = segments.slice(0, -1).join("/"); + const apiBase = basePath.length > 0 + ? `${parsed.origin}/${basePath}` + : parsed.origin; + return { pubkey: last, apiBase }; +} diff --git a/src/background/services/privacy-provider-client.ts b/src/background/services/privacy-provider-client.ts index 78a590f..a73eb8f 100644 --- a/src/background/services/privacy-provider-client.ts +++ b/src/background/services/privacy-provider-client.ts @@ -8,6 +8,7 @@ import { traceparent, } from "@/background/services/telemetry.ts"; import type { EntityStatus } from "@/persistence/stores/private-channels.types.ts"; +import { extractPpPubkeyFromUrl } from "@/background/services/pp-url.ts"; export type AuthChallengeResponse = { status: number; @@ -18,10 +19,6 @@ export type AuthChallengeResponse = { }; }; -export type EntityChallengeResponse = { - data: { nonce: string }; -}; - export class PrivacyProviderAuthError extends Error { constructor(message: string) { super(message); @@ -29,20 +26,25 @@ export class PrivacyProviderAuthError extends Error { } } +// The wallet stores `{url, label}` per PP. The client derives the API base +// (origin or origin + intermediate path) and the PP pubkey from the URL at +// construction. Throws on any malformed URL — callers are expected to surface +// the stale-URL notice to the user rather than propagate. export class PrivacyProviderClient { - private baseUrl: string; + private apiBase: string; private ppPubkey: string; private traceId?: string; private rootSpanId?: string; - constructor(url: string, ppPubkey: string) { - // Ensure protocol and no trailing slash - let normalized = url.replace(/\/$/, ""); - if (!/^https?:\/\//i.test(normalized)) { - normalized = `https://${normalized}`; + constructor(url: string) { + const extracted = extractPpPubkeyFromUrl(url); + if (!extracted) { + throw new Error( + `Provider URL has no Stellar pubkey path segment: ${url}`, + ); } - this.baseUrl = normalized; - this.ppPubkey = ppPubkey; + this.apiBase = extracted.apiBase; + this.ppPubkey = extracted.pubkey; if (isEnabled()) { const trace = startTrace(); @@ -114,7 +116,7 @@ export class PrivacyProviderClient { }); try { const response = await axios.get( - `${this.baseUrl}/api/v1/stellar/auth`, + `${this.apiBase}/api/v1/stellar/auth`, { params: { account: accountPublicKey }, headers: span ? this.traceHeaders(span.spanId) : {}, @@ -130,23 +132,30 @@ export class PrivacyProviderClient { async postAuth( signedChallenge: string, - ): Promise<{ token: string; entityStatus: EntityStatus }> { - // TODO: This endpoint implementation is specific to the current provider API - // and might not fully conform to SEP-10 standard yet. Move to standard SEP-10 - // once stellar.toml is hosted. + ppPublicKey: string, + ): Promise<{ + token: string; + entityStatus: EntityStatus; + kycSubmissionUrl: string | null; + }> { + // SEP-10 verify is PP-aware: `ppPublicKey` in the body, response carries + // per-PP entityStatus + per-PP kycSubmissionUrl. const span = this.startSpan("POST /api/v1/stellar/auth"); try { const response = await axios.post<{ status: number; message: string; - data: { jwt: string; entityStatus?: EntityStatus }; + data: { + jwt: string; + entityStatus?: EntityStatus; + kycSubmissionUrl?: string | null; + }; }>( - `${this.baseUrl}/api/v1/stellar/auth`, - { signedChallenge }, + `${this.apiBase}/api/v1/stellar/auth`, + { signedChallenge, ppPublicKey }, { headers: span ? this.traceHeaders(span.spanId) : {} }, ); - // Extract token from response.data.data.jwt const token = response.data.data?.jwt; if (!token || typeof token !== "string") { throw new Error( @@ -155,66 +164,12 @@ export class PrivacyProviderClient { }`, ); } - // entityStatus defaults to UNVERIFIED if the provider hasn't deployed - // the 0.7+ response shape yet, so the wallet can still gate KYC. const entityStatus: EntityStatus = response.data.data?.entityStatus ?? "UNVERIFIED"; + const kycSubmissionUrl = response.data.data?.kycSubmissionUrl ?? null; if (span) endSpan(span, { code: 0 }); - return { token, entityStatus }; - } catch (error) { - if (span) endSpan(span, { code: 2, message: String(error) }); - throw error; - } - } - - // KYC challenge — wallet requests a nonce per-PP, signs it with the entity's - // Stellar key, and posts back to /providers/:pp/entities to register. - async getEntityChallenge(pubkey: string): Promise<{ nonce: string }> { - const span = this.startSpan( - "POST /api/v1/providers/:pp/entities/challenge", - { "stellar.account": pubkey }, - ); - try { - const response = await axios.post<{ - data: { nonce: string }; - }>( - `${this.baseUrl}/api/v1/providers/${this.ppPubkey}/entities/challenge`, - { pubkey }, - { headers: span ? this.traceHeaders(span.spanId) : {} }, - ); - const nonce = response.data.data?.nonce; - if (!nonce) throw new Error("entity challenge missing nonce"); - if (span) endSpan(span, { code: 0 }); - return { nonce }; - } catch (error) { - if (span) endSpan(span, { code: 2, message: String(error) }); - throw error; - } - } - - async submitEntity(params: { - pubkey: string; - name: string; - jurisdictions?: string[]; - signedChallenge: { nonce: string; signature: string }; - }): Promise<{ entityId: string; status: EntityStatus }> { - const span = this.startSpan("POST /api/v1/providers/:pp/entities"); - try { - const response = await axios.post<{ - data: { entityId: string; status: EntityStatus }; - }>( - `${this.baseUrl}/api/v1/providers/${this.ppPubkey}/entities`, - { - pubkey: params.pubkey, - name: params.name, - jurisdictions: params.jurisdictions ?? [], - signedChallenge: params.signedChallenge, - }, - { headers: span ? this.traceHeaders(span.spanId) : {} }, - ); - if (span) endSpan(span, { code: 0 }); - return response.data.data; + return { token, entityStatus, kycSubmissionUrl }; } catch (error) { if (span) endSpan(span, { code: 2, message: String(error) }); throw error; @@ -226,8 +181,6 @@ export class PrivacyProviderClient { operationsMLXDR: string[]; channelContractId: string; }): Promise<{ id: string }> { - // Validate and clean token - // This will throw PrivacyProviderAuthError if token is invalid const token = this.validateToken(params.token); const span = this.startSpan( @@ -244,7 +197,7 @@ export class PrivacyProviderClient { message: string; data: { operationsBundleId: string; status: string }; }>( - `${this.baseUrl}/api/v1/providers/${this.ppPubkey}/entity/bundles`, + `${this.apiBase}/api/v1/providers/${this.ppPubkey}/entity/bundles`, { operationsMLXDR: params.operationsMLXDR, channelContractId: params.channelContractId, @@ -268,13 +221,11 @@ export class PrivacyProviderClient { if (span) endSpan(span, { code: 0 }); } catch (error: unknown) { if (span) endSpan(span, { code: 2, message: String(error) }); - // Check if it's an authentication error if (this.isAuthError(error)) { throw new PrivacyProviderAuthError( "Provider session expired or invalid. Please reconnect to the provider.", ); } - // Re-throw other errors as-is throw error; } @@ -305,7 +256,7 @@ export class PrivacyProviderClient { message: string; data: { status: string }; }>( - `${this.baseUrl}/api/v1/providers/${this.ppPubkey}/entity/bundles/${bundleId}`, + `${this.apiBase}/api/v1/providers/${this.ppPubkey}/entity/bundles/${bundleId}`, { headers: { Authorization: `Bearer ${token}`, diff --git a/src/persistence/stores/private-channels.ts b/src/persistence/stores/private-channels.ts index e512aae..1420402 100644 --- a/src/persistence/stores/private-channels.ts +++ b/src/persistence/stores/private-channels.ts @@ -9,14 +9,17 @@ import type { import type { ChainNetwork } from "@/persistence/stores/chain.types.ts"; type LegacyPrivacyProviderSession = - & Omit + & Omit & { entityStatus?: PrivacyProviderSession["entityStatus"]; + kycSubmissionUrl?: PrivacyProviderSession["kycSubmissionUrl"]; }; type LegacyPrivacyProvider = - & Omit + & Omit & { + // v3 (PR #20) stored an explicit `pubkey` field; v4 drops it (URL carries + // the pubkey as its last path segment). Migration strips this if present. pubkey?: string; session?: LegacyPrivacyProviderSession; sessions?: Record; @@ -40,7 +43,7 @@ type LegacyPrivateChannelsState = { }; const DEFAULT_PRIVATE_CHANNELS_STATE: PrivateChannelsState = { - version: 3, + version: 4, channelsByNetwork: {}, selectedChannelIdByNetwork: {}, }; @@ -100,15 +103,13 @@ export class PrivateChannelsStore extends PersistedStore { const legacySessions = p.session ? { default: p.session } : p.sessions || {}; - // Pre-pubkey providers can't reach the per-PP bundle URL - // until the user re-adds them with a pubkey. Default to - // empty so the type-check passes; runtime will refuse to - // submit bundles against an empty pubkey. + // v4 strips any `pubkey` field carried over from v3 (PR #20). + // Stale URLs (no pubkey path segment) are detected at render + // time and surface a "needs re-adding" notice. return { id: p.id, name: p.name, url: p.url, - pubkey: (p as { pubkey?: string }).pubkey ?? "", sessions: Object.fromEntries( Object.entries(legacySessions).map(([k, s]) => [ k, @@ -116,6 +117,7 @@ export class PrivateChannelsStore extends PersistedStore { token: s.token, expiresAt: s.expiresAt, entityStatus: s.entityStatus ?? "UNVERIFIED", + kycSubmissionUrl: s.kycSubmissionUrl ?? null, } satisfies PrivacyProviderSession, ]), ), @@ -134,7 +136,7 @@ export class PrivateChannelsStore extends PersistedStore { DEFAULT_PRIVATE_CHANNELS_STATE.selectedChannelIdByNetwork; return { - version: 3, + version: 4, channelsByNetwork: migratedChannelsByNetwork, selectedChannelIdByNetwork, }; @@ -196,7 +198,7 @@ export class PrivateChannelsStore extends PersistedStore { addProvider( network: ChainNetwork, channelId: string, - provider: { id: string; name: string; url: string; pubkey: string }, + provider: { id: string; name: string; url: string }, ) { this.store.update((prev) => { const channels = prev.channelsByNetwork[network] ?? []; diff --git a/src/persistence/stores/private-channels.types.ts b/src/persistence/stores/private-channels.types.ts index 4ec6b09..cc39405 100644 --- a/src/persistence/stores/private-channels.types.ts +++ b/src/persistence/stores/private-channels.types.ts @@ -10,18 +10,22 @@ export type EntityStatus = "UNVERIFIED" | "APPROVED" | "PENDING" | "BLOCKED"; export type PrivacyProviderSession = { token: string; expiresAt: number; - // Mirrors the provider-platform entity record's status at session-mint time. - // Wallet uses this to gate bundle ops behind a KYC submission step. + // Mirrors the provider-platform PER-PP entity status at session-mint time. + // Wallet gates the "Submit KYC" link render on this field. entityStatus: EntityStatus; + // Operator-supplied KYC submission URL for THIS PP. The wallet renders it + // verbatim as the anchor href when entityStatus != APPROVED. Null when the + // operator hasn't published a URL — link is hidden. + kycSubmissionUrl: string | null; }; export type PrivacyProvider = { id: string; name: string; + // The PP URL encodes the PP's Stellar public key as its last path segment. + // The wallet parses it at call-time via extractPpPubkeyFromUrl(); pubkey is + // never persisted as a separate field. url: string; - // Required for per-PP URL paths (/api/v1/providers/:pubkey/...) — supplied - // by the operator alongside the URL. Stored once at add-provider time. - pubkey: string; sessions: Record; // Keyed by account ID }; @@ -38,7 +42,7 @@ export type PrivateChannel = { }; export type PrivateChannelsState = { - version: 3; + version: 4; channelsByNetwork: Partial>; selectedChannelIdByNetwork: Partial>; }; diff --git a/src/popup/api/add-privacy-provider.ts b/src/popup/api/add-privacy-provider.ts index f506fdd..cb98ea1 100644 --- a/src/popup/api/add-privacy-provider.ts +++ b/src/popup/api/add-privacy-provider.ts @@ -7,7 +7,6 @@ export async function addPrivacyProvider(params: { channelId: string; name: string; url: string; - pubkey: string; }): Promise<{ providerId: string }> { const res = await callBackground({ type: MessageType.AddPrivacyProvider, @@ -15,7 +14,6 @@ export async function addPrivacyProvider(params: { channelId: params.channelId, name: params.name, url: params.url, - pubkey: params.pubkey, }); if ("ok" in res && res.ok === false) { diff --git a/src/popup/api/submit-entity-kyc.ts b/src/popup/api/submit-entity-kyc.ts deleted file mode 100644 index 9341513..0000000 --- a/src/popup/api/submit-entity-kyc.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { MessageType } from "@/background/messages.ts"; -import { ApiError, callBackground } from "@/popup/api/client.ts"; -import type { ChainNetwork } from "@/persistence/stores/chain.types.ts"; - -export async function submitEntityKyc(params: { - network: ChainNetwork; - channelId: string; - providerId: string; - accountId: string; - password: string; - name: string; - jurisdictions?: string[]; -}): Promise { - const res = await callBackground({ - type: MessageType.SubmitEntityKyc, - network: params.network, - channelId: params.channelId, - providerId: params.providerId, - accountId: params.accountId, - password: params.password, - name: params.name, - jurisdictions: params.jurisdictions, - }); - - if ("ok" in res && res.ok === false) { - throw new ApiError( - res.error.message ?? "Failed to submit KYC", - res.error.code, - ); - } -} diff --git a/src/popup/organisms/privacy-providers.tsx b/src/popup/organisms/privacy-providers.tsx index ba686fd..e7b7716 100644 --- a/src/popup/organisms/privacy-providers.tsx +++ b/src/popup/organisms/privacy-providers.tsx @@ -3,75 +3,52 @@ import { cn } from "@/popup/utils/cn.ts"; import { Spinner } from "@/popup/atoms/spinner.tsx"; import { IconPlus, IconServer, IconTrash, IconX } from "@tabler/icons-react"; import type { PrivateChannel } from "@/persistence/stores/private-channels.types.ts"; +import { extractPpPubkeyFromUrl } from "@/background/services/pp-url.ts"; export type PrivacyProvidersProps = { channel: PrivateChannel; accountId?: string; - onAddProvider: (name: string, url: string, pubkey: string) => Promise; + onAddProvider: (name: string, url: string) => Promise; onRemoveProvider: (providerId: string) => Promise; onSelectProvider: (providerId: string | undefined) => Promise; - onSubmitKyc: ( - providerId: string, - name: string, - password: string, - ) => Promise; }; export function PrivacyProviders(props: PrivacyProvidersProps) { const [isAdding, setIsAdding] = useState(false); const [name, setName] = useState(""); const [url, setUrl] = useState(""); - const [port, setPort] = useState(""); - const [pubkey, setPubkey] = useState(""); const [busy, setBusy] = useState(false); + const [urlError, setUrlError] = useState(null); const [processingProviderId, setProcessingProviderId] = useState< string | null >(null); const [providerError, setProviderError] = useState< { id: string; message: string } | null >(null); - const [kycForProviderId, setKycForProviderId] = useState(null); - const [kycName, setKycName] = useState(""); - const [kycPassword, setKycPassword] = useState(""); - const [kycBusy, setKycBusy] = useState(false); - const [kycError, setKycError] = useState(null); const handleAdd = async () => { - if (!name.trim() || !url.trim() || !pubkey.trim()) return; + if (!name.trim() || !url.trim()) return; + const trimmed = url.trim(); + // Soft check — background runs the authoritative validation. Catching it + // here just spares a round-trip. + if (!extractPpPubkeyFromUrl(trimmed)) { + setUrlError( + "URL must end with the PP's Stellar public key (e.g. https://provider-x.example/G…).", + ); + return; + } + setUrlError(null); setBusy(true); try { - let finalUrl = url.trim(); - if (port.trim()) { - finalUrl = finalUrl.replace(/\/$/, ""); - finalUrl = `${finalUrl}:${port.trim()}`; - } - await props.onAddProvider(name, finalUrl, pubkey.trim()); + await props.onAddProvider(name, trimmed); setIsAdding(false); setName(""); setUrl(""); - setPort(""); - setPubkey(""); } finally { setBusy(false); } }; - const handleSubmitKyc = async (providerId: string) => { - if (!kycName.trim() || !kycPassword.trim()) return; - setKycBusy(true); - setKycError(null); - try { - await props.onSubmitKyc(providerId, kycName.trim(), kycPassword); - setKycForProviderId(null); - setKycName(""); - setKycPassword(""); - } catch (err) { - setKycError(err instanceof Error ? err.message : String(err)); - } finally { - setKycBusy(false); - } - }; - const handleToggleConnection = async (id: string, isSelected: boolean) => { if (processingProviderId) return; setProcessingProviderId(id); @@ -102,7 +79,10 @@ export function PrivacyProviders(props: PrivacyProvidersProps) { + + + ); + } return (
@@ -296,75 +290,21 @@ export function PrivacyProviders(props: PrivacyProvidersProps) {
)} {session && session.entityStatus !== "APPROVED" && - kycForProviderId !== p.id && ( - - )} - {kycForProviderId === p.id && ( -
-

- Provider {p.name} needs you to KYC before accepting bundles. -

- setKycName(e.target.value)} - placeholder="Legal name" - className="w-full px-3 py-2 rounded-lg text-sm bg-background/50 border border-foreground/10 text-foreground placeholder:text-foreground/30 focus:outline-none focus:border-secondary/50" - /> - setKycPassword(e.target.value)} - placeholder="Wallet password" - className="w-full px-3 py-2 rounded-lg text-sm bg-background/50 border border-foreground/10 text-foreground placeholder:text-foreground/30 focus:outline-none focus:border-secondary/50" - /> - {kycError &&

{kycError}

} -
- - -
-
+ +
)} ); diff --git a/src/popup/organisms/private-channel-manager.tsx b/src/popup/organisms/private-channel-manager.tsx index 5b82ed6..6e0b53c 100644 --- a/src/popup/organisms/private-channel-manager.tsx +++ b/src/popup/organisms/private-channel-manager.tsx @@ -14,19 +14,12 @@ export type PrivateChannelManagerProps = { channelId: string, name: string, url: string, - pubkey: string, ) => Promise; onRemoveProvider: (channelId: string, providerId: string) => Promise; onSelectProvider: ( channelId: string, providerId: string | undefined, ) => Promise; - onSubmitKyc: ( - channelId: string, - providerId: string, - name: string, - password: string, - ) => Promise; }; export function PrivateChannelManager(props: PrivateChannelManagerProps) { @@ -164,14 +157,12 @@ export function PrivateChannelManager(props: PrivateChannelManagerProps) { - props.onAddProvider(selectedChannel.id, name, url, pubkey)} + onAddProvider={(name, url) => + props.onAddProvider(selectedChannel.id, name, url)} onRemoveProvider={(pid) => props.onRemoveProvider(selectedChannel.id, pid)} onSelectProvider={(pid) => props.onSelectProvider(selectedChannel.id, pid)} - onSubmitKyc={(pid, name, password) => - props.onSubmitKyc(selectedChannel.id, pid, name, password)} /> )} diff --git a/src/popup/organisms/private-channel-menu.tsx b/src/popup/organisms/private-channel-menu.tsx index 1125340..19a9810 100644 --- a/src/popup/organisms/private-channel-menu.tsx +++ b/src/popup/organisms/private-channel-menu.tsx @@ -17,19 +17,12 @@ export type PrivateChannelMenuProps = { channelId: string, name: string, url: string, - pubkey: string, ) => Promise; onRemoveProvider: (channelId: string, providerId: string) => Promise; onSelectProvider: ( channelId: string, providerId: string | undefined, ) => Promise; - onSubmitKyc: ( - channelId: string, - providerId: string, - name: string, - password: string, - ) => Promise; }; export function PrivateChannelMenu(props: PrivateChannelMenuProps) { @@ -59,14 +52,12 @@ export function PrivateChannelMenu(props: PrivateChannelMenuProps) { - props.onAddProvider(managedChannel.id, name, url, pubkey)} + onAddProvider={(name, url) => + props.onAddProvider(managedChannel.id, name, url)} onRemoveProvider={(providerId) => props.onRemoveProvider(managedChannel.id, providerId)} onSelectProvider={(providerId) => props.onSelectProvider(managedChannel.id, providerId)} - onSubmitKyc={(providerId, name, password) => - props.onSubmitKyc(managedChannel.id, providerId, name, password)} /> ); diff --git a/src/popup/pages/home-page.tsx b/src/popup/pages/home-page.tsx index 5aca926..aa61e57 100644 --- a/src/popup/pages/home-page.tsx +++ b/src/popup/pages/home-page.tsx @@ -16,7 +16,6 @@ import { ensurePrivateChannelTracking } from "@/popup/api/ensure-private-channel import { getPrivateStats } from "@/popup/api/get-private-stats.ts"; import { showError, showSuccess } from "@/popup/utils/toast.tsx"; import { addPrivacyProvider } from "@/popup/api/add-privacy-provider.ts"; -import { submitEntityKyc } from "@/popup/api/submit-entity-kyc.ts"; import { removePrivacyProvider } from "@/popup/api/remove-privacy-provider.ts"; import { connectPrivacyProvider } from "@/popup/api/connect-privacy-provider.ts"; import { disconnectPrivacyProvider } from "@/popup/api/disconnect-privacy-provider.ts"; @@ -205,7 +204,6 @@ export function HomePage() { channelId: string, name: string, url: string, - pubkey: string, ) => { try { await addPrivacyProvider({ @@ -213,7 +211,6 @@ export function HomePage() { channelId, name, url, - pubkey, }); await refreshPrivateChannels(); showSuccess("Provider added successfully"); @@ -224,34 +221,6 @@ export function HomePage() { } }; - const onSubmitEntityKyc = async ( - channelId: string, - providerId: string, - name: string, - password: string, - ) => { - try { - if (!selectedAccount?.accountId) { - throw new Error("No active account"); - } - await submitEntityKyc({ - network: selectedNetwork as ChainNetwork, - channelId, - providerId, - accountId: selectedAccount.accountId, - password, - name, - }); - await refreshPrivateChannels(); - showSuccess("KYC submitted — provider authorized"); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(err); - showError(msg || "Failed to submit KYC"); - throw err; - } - }; - const onRemovePrivacyProvider = async ( channelId: string, providerId: string, @@ -316,7 +285,6 @@ export function HomePage() { channelAddress: channel.contractId, providerId: provider.id, providerUrl: provider.url, - providerPubkey: provider.pubkey, accountId: selectedAccount.accountId, publicKey: selectedAccount.publicKey, network: selectedNetwork as ChainNetwork, @@ -1129,7 +1097,6 @@ export function HomePage() { onAddPrivacyProvider={onAddPrivacyProvider} onRemovePrivacyProvider={onRemovePrivacyProvider} onSelectPrivacyProvider={onSelectPrivacyProvider} - onSubmitEntityKyc={onSubmitEntityKyc} activation={activation} onFundWithFriendbot={onFundWithFriendbot} accountPickerOpen={accountPickerOpen} diff --git a/src/popup/templates/home-template.tsx b/src/popup/templates/home-template.tsx index 39abc29..b55e04a 100644 --- a/src/popup/templates/home-template.tsx +++ b/src/popup/templates/home-template.tsx @@ -95,7 +95,6 @@ export type HomeTemplateProps = { channelId: string, name: string, url: string, - pubkey: string, ) => Promise; onRemovePrivacyProvider?: ( channelId: string, @@ -105,12 +104,6 @@ export type HomeTemplateProps = { channelId: string, providerId: string | undefined, ) => Promise; - onSubmitEntityKyc?: ( - channelId: string, - providerId: string, - name: string, - password: string, - ) => Promise; activation?: { status: "created" | "not_created" | "unknown"; @@ -385,13 +378,8 @@ export function HomeTemplate(props: HomeTemplateProps) { props.onAddPrivateChannel?.(); props.setChannelPickerOpen?.(false); }} - onAddProvider={async (channelId, name, url, pubkey) => { - await props.onAddPrivacyProvider?.( - channelId, - name, - url, - pubkey, - ); + onAddProvider={async (channelId, name, url) => { + await props.onAddPrivacyProvider?.(channelId, name, url); }} onRemoveProvider={async (channelId, providerId) => { await props.onRemovePrivacyProvider?.(channelId, providerId); @@ -399,14 +387,6 @@ export function HomeTemplate(props: HomeTemplateProps) { onSelectProvider={async (channelId, providerId) => { await props.onSelectPrivacyProvider?.(channelId, providerId); }} - onSubmitKyc={async (channelId, providerId, name, password) => { - await props.onSubmitEntityKyc?.( - channelId, - providerId, - name, - password, - ); - }} /> } />