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:
parent
5f3ffcc8d2
commit
6c807afeb0
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue