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.
* Each user gets an Openfort "player" with a smart account on Base.
* Find an existing wallet by player name, or create a new one.
* 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 {
// 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({
name: label,
metadata: metadata ?? {},
@ -47,7 +80,7 @@ export class OpenfortProvider {
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 {
playerId: player.id,
@ -56,8 +89,15 @@ export class OpenfortProvider {
chainId: this.config.chainId,
};
} 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;
}
}
/**
* @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
|| (_coinbaseOnramp ? 'coinbase' : 'transak');
// 1. Create Openfort smart wallet for this user
const wallet = await _openfort.createWallet(`user:${email}`, {
// 1. Find or create Openfort smart wallet for this user (one wallet per email)
const wallet = await _openfort.findOrCreateWallet(`user:${email}`, {
type: 'user-onramp',
email,
});

View File

@ -1271,6 +1271,10 @@ export async function acceptFundClaim(token: string, userId: string): Promise<St
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> {
// 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()`;

View File

@ -77,6 +77,7 @@ import {
getFundClaimByToken,
getFundClaimsByEmailHash,
acceptFundClaim,
expireFundClaim,
cleanExpiredFundClaims,
sql,
} from './db.js';
@ -2930,6 +2931,12 @@ app.post('/api/internal/fund-claims', async (c) => {
const emailHashed = await hashEmail(email);
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({
id,
token,