fix: one wallet per EncryptID user, deduplicate fund claims

- OpenfortProvider.findOrCreateWallet() searches by player name before
  creating, ensuring the same email always maps to the same wallet
- Fund claims endpoint expires old pending claims before creating new ones
- Added expireFundClaim() to db layer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-06 23:33:45 -08:00
parent 5f3ffcc8d2
commit 6c807afeb0
4 changed files with 58 additions and 7 deletions

View File

@ -32,11 +32,44 @@ export class OpenfortProvider {
} }
/** /**
* Create an embedded wallet for an on-ramp user. * Find an existing wallet by player name, or create a new one.
* Each user gets an Openfort "player" with a smart account on Base. * Ensures one wallet per label (e.g. "user:alice@example.com").
*/ */
async createWallet(label: string, metadata?: Record<string, string>): Promise<WalletInfo> { async findOrCreateWallet(label: string, metadata?: Record<string, string>): Promise<WalletInfo> {
try { try {
// Search for existing player by name
const existing = await this.client.players.list({ name: label, limit: 1 });
if (existing.data.length > 0) {
const player = existing.data[0];
const accounts = await this.client.accounts.v1.list({
player: player.id,
chainId: this.config.chainId,
});
if (accounts.data.length > 0) {
const account = accounts.data[0];
console.log(`[openfort] Found existing wallet: player=${player.id} address=${account.address} label=${label}`);
return {
playerId: player.id,
accountId: account.id,
address: account.address,
chainId: this.config.chainId,
};
}
// Player exists but no account on this chain — create one
const account = await this.client.accounts.v1.create({
player: player.id,
chainId: this.config.chainId,
});
console.log(`[openfort] Created account for existing player: player=${player.id} address=${account.address}`);
return {
playerId: player.id,
accountId: account.id,
address: account.address,
chainId: this.config.chainId,
};
}
// No existing player — create new
const player = await this.client.players.create({ const player = await this.client.players.create({
name: label, name: label,
metadata: metadata ?? {}, metadata: metadata ?? {},
@ -47,7 +80,7 @@ export class OpenfortProvider {
chainId: this.config.chainId, chainId: this.config.chainId,
}); });
console.log(`[openfort] Created wallet: player=${player.id} address=${account.address} label=${label}`); console.log(`[openfort] Created new wallet: player=${player.id} address=${account.address} label=${label}`);
return { return {
playerId: player.id, playerId: player.id,
@ -56,8 +89,15 @@ export class OpenfortProvider {
chainId: this.config.chainId, chainId: this.config.chainId,
}; };
} catch (error) { } catch (error) {
console.error(`[openfort] Failed to create wallet for "${label}":`, error); console.error(`[openfort] Failed to find/create wallet for "${label}":`, error);
throw error; throw error;
} }
} }
/**
* @deprecated Use findOrCreateWallet instead
*/
async createWallet(label: string, metadata?: Record<string, string>): Promise<WalletInfo> {
return this.findOrCreateWallet(label, metadata);
}
} }

View File

@ -177,8 +177,8 @@ routes.post("/api/flows/user-onramp", async (c) => {
|| process.env.ONRAMP_PROVIDER || process.env.ONRAMP_PROVIDER
|| (_coinbaseOnramp ? 'coinbase' : 'transak'); || (_coinbaseOnramp ? 'coinbase' : 'transak');
// 1. Create Openfort smart wallet for this user // 1. Find or create Openfort smart wallet for this user (one wallet per email)
const wallet = await _openfort.createWallet(`user:${email}`, { const wallet = await _openfort.findOrCreateWallet(`user:${email}`, {
type: 'user-onramp', type: 'user-onramp',
email, email,
}); });

View File

@ -1271,6 +1271,10 @@ export async function acceptFundClaim(token: string, userId: string): Promise<St
return rowToFundClaim(rows[0]); return rowToFundClaim(rows[0]);
} }
export async function expireFundClaim(claimId: string): Promise<void> {
await sql`UPDATE fund_claims SET status = 'expired', email = NULL WHERE id = ${claimId} AND status IN ('pending', 'resent')`;
}
export async function cleanExpiredFundClaims(): Promise<number> { export async function cleanExpiredFundClaims(): Promise<number> {
// Null out email on expired claims, then mark them expired // Null out email on expired claims, then mark them expired
await sql`UPDATE fund_claims SET email = NULL, status = 'expired' WHERE status IN ('pending', 'resent') AND expires_at < NOW()`; await sql`UPDATE fund_claims SET email = NULL, status = 'expired' WHERE status IN ('pending', 'resent') AND expires_at < NOW()`;

View File

@ -77,6 +77,7 @@ import {
getFundClaimByToken, getFundClaimByToken,
getFundClaimsByEmailHash, getFundClaimsByEmailHash,
acceptFundClaim, acceptFundClaim,
expireFundClaim,
cleanExpiredFundClaims, cleanExpiredFundClaims,
sql, sql,
} from './db.js'; } from './db.js';
@ -2930,6 +2931,12 @@ app.post('/api/internal/fund-claims', async (c) => {
const emailHashed = await hashEmail(email); const emailHashed = await hashEmail(email);
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days
// Expire any existing pending claims for this email (one active claim per user)
const existingClaims = await getFundClaimsByEmailHash(emailHashed);
for (const ec of existingClaims) {
await expireFundClaim(ec.id);
}
const claim = await createFundClaim({ const claim = await createFundClaim({
id, id,
token, token,