Skip to content

Commit 74d23ff

Browse files
Shawclaude
andcommitted
feat(cloud): let OAuth users attach a wallet to buy crypto credits
The /bsc promo and any other direct-crypto-payments surface require `user.wallet_address` to be set. That field is only assigned when a user signs in via SIWE/SIWS — Google / Discord / GitHub / Magic Link / Passkey signups never get one, and there was no path to add one later. The pay button on /bsc was permanently dead for those accounts. Add `POST /api/users/me/wallet/attach`: takes a SIWE message + signature, validates against the existing nonce store, asserts the address isn't already bound to a different user, then writes wallet_address / wallet_chain_type / wallet_verified onto the authed user. The repository update method already exists; we just route it. Conflict policy: a wallet bound to another account returns 409 `wallet_taken`. The wallet-keyed account wins; OAuth users hitting this must use the existing account. UI: when the authed user has no wallet_address, /bsc swaps the purchase card for an AttachWalletCard that drives connect -> sign -> POST attach -> invalidate user-profile. After the mutation succeeds, the page re-renders with the existing DirectCryptoCreditCard unchanged. Coverage: - 6 backend unit tests on the route (94% line coverage), covering already_attached / 400 / 401 / wallet_taken / happy path / same-user race window. - 3 Playwright e2e tests on /bsc covering purchase, OAuth-no-wallet attach, and HTML-fallback safety. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 497794a commit 74d23ff

7 files changed

Lines changed: 699 additions & 12 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/**
2+
* Tests for POST /api/users/me/wallet/attach.
3+
*
4+
* Mocks every external dep (auth, Redis, SIWE validator, users service) so
5+
* the assertions live entirely in the route module's branching logic:
6+
* - already_attached when the authed user has a wallet
7+
* - 400 on a malformed body
8+
* - 401 when SIWE verification throws
9+
* - 409 wallet_taken when the address belongs to a different user
10+
* - 200 + usersService.update call with normalized fields on the happy path
11+
*
12+
* `bun:test`'s `mock.module` is hoisted-import-aware: register mocks BEFORE
13+
* importing the route module. The route is a Hono app, so we exercise it via
14+
* `app.fetch(new Request(...))`.
15+
*/
16+
17+
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
18+
19+
const validateAndConsumeSIWE = mock<
20+
(
21+
redis: unknown,
22+
message: string,
23+
signature: string,
24+
host: string,
25+
) => Promise<{ address: string }>
26+
>();
27+
28+
const getByWalletAddress = mock<
29+
(address: string) => Promise<{ id: string } | undefined>
30+
>();
31+
32+
const usersServiceUpdate = mock<
33+
(
34+
id: string,
35+
data: Record<string, unknown>,
36+
) => Promise<
37+
| {
38+
id: string;
39+
wallet_address: string;
40+
wallet_chain_type: string;
41+
wallet_verified: boolean;
42+
}
43+
| undefined
44+
>
45+
>();
46+
47+
const requireUser = mock<
48+
(c: unknown) => Promise<{ id: string; wallet_address: string | null }>
49+
>();
50+
51+
const buildRedisClient = mock<(env: unknown) => unknown>();
52+
53+
mock.module("@/lib/auth/workers-hono-auth", () => ({
54+
requireUser,
55+
}));
56+
57+
mock.module("@/lib/cache/redis-factory", () => ({
58+
buildRedisClient,
59+
}));
60+
61+
mock.module("@/lib/utils/siwe-helpers", () => ({
62+
validateAndConsumeSIWE,
63+
}));
64+
65+
mock.module("@/lib/services/users", () => ({
66+
usersService: {
67+
getByWalletAddress,
68+
update: usersServiceUpdate,
69+
},
70+
}));
71+
72+
mock.module("@/lib/utils/app-url", () => ({
73+
getAppHost: () => "elizacloud.ai",
74+
}));
75+
76+
mock.module("@/lib/middleware/rate-limit-hono-cloudflare", () => ({
77+
RateLimitPresets: { STRICT: {} },
78+
rateLimit: () => async (_c: unknown, next: () => Promise<void>) => {
79+
await next();
80+
},
81+
}));
82+
83+
mock.module("@/lib/utils/logger", () => ({
84+
logger: {
85+
info: () => undefined,
86+
warn: () => undefined,
87+
error: () => undefined,
88+
debug: () => undefined,
89+
},
90+
}));
91+
92+
// EIP-55 checksummed mainnet address (any valid checksum works for these tests).
93+
const VALID_ADDRESS = "0x52908400098527886E0F7030069857D2E4169EE7";
94+
const VALID_ADDRESS_LOWER = VALID_ADDRESS.toLowerCase();
95+
const VALID_SIGNATURE = `0x${"a".repeat(130)}` as const;
96+
97+
let attachRoute: { default: { fetch: (req: Request) => Promise<Response> } };
98+
99+
beforeAll(async () => {
100+
attachRoute = (await import(
101+
"../users/me/wallet/attach/route"
102+
)) as typeof attachRoute;
103+
});
104+
105+
function makeRequest(body: unknown) {
106+
return new Request("http://test.local/", {
107+
method: "POST",
108+
headers: { "Content-Type": "application/json" },
109+
body: JSON.stringify(body),
110+
});
111+
}
112+
113+
afterEach(() => {
114+
validateAndConsumeSIWE.mockReset();
115+
getByWalletAddress.mockReset();
116+
usersServiceUpdate.mockReset();
117+
requireUser.mockReset();
118+
buildRedisClient.mockReset();
119+
});
120+
121+
describe("POST /api/users/me/wallet/attach", () => {
122+
test("returns 409 already_attached when the user already has a wallet", async () => {
123+
requireUser.mockResolvedValue({
124+
id: "user-1",
125+
wallet_address: "0xdeadbeef",
126+
});
127+
128+
const res = await attachRoute.default.fetch(
129+
makeRequest({ message: "msg", signature: VALID_SIGNATURE }),
130+
);
131+
132+
expect(res.status).toBe(409);
133+
const body = (await res.json()) as { code?: string };
134+
expect(body.code).toBe("already_attached");
135+
expect(validateAndConsumeSIWE).not.toHaveBeenCalled();
136+
});
137+
138+
test("returns 400 when body is missing message or signature", async () => {
139+
requireUser.mockResolvedValue({ id: "user-1", wallet_address: null });
140+
buildRedisClient.mockReturnValue({});
141+
142+
const res = await attachRoute.default.fetch(makeRequest({ message: "x" }));
143+
144+
expect(res.status).toBe(400);
145+
expect(validateAndConsumeSIWE).not.toHaveBeenCalled();
146+
});
147+
148+
test("returns 401 when SIWE validation throws", async () => {
149+
requireUser.mockResolvedValue({ id: "user-1", wallet_address: null });
150+
buildRedisClient.mockReturnValue({});
151+
validateAndConsumeSIWE.mockRejectedValue(new Error("nonce invalid"));
152+
153+
const res = await attachRoute.default.fetch(
154+
makeRequest({ message: "msg", signature: VALID_SIGNATURE }),
155+
);
156+
157+
expect(res.status).toBe(401);
158+
expect(usersServiceUpdate).not.toHaveBeenCalled();
159+
});
160+
161+
test("returns 409 wallet_taken when address belongs to a different user", async () => {
162+
requireUser.mockResolvedValue({ id: "user-1", wallet_address: null });
163+
buildRedisClient.mockReturnValue({});
164+
validateAndConsumeSIWE.mockResolvedValue({ address: VALID_ADDRESS });
165+
getByWalletAddress.mockResolvedValue({ id: "user-2" });
166+
167+
const res = await attachRoute.default.fetch(
168+
makeRequest({ message: "msg", signature: VALID_SIGNATURE }),
169+
);
170+
171+
expect(res.status).toBe(409);
172+
const body = (await res.json()) as { code?: string };
173+
expect(body.code).toBe("wallet_taken");
174+
expect(usersServiceUpdate).not.toHaveBeenCalled();
175+
});
176+
177+
test("returns 200 and updates the user on the happy path", async () => {
178+
requireUser.mockResolvedValue({ id: "user-1", wallet_address: null });
179+
buildRedisClient.mockReturnValue({});
180+
validateAndConsumeSIWE.mockResolvedValue({ address: VALID_ADDRESS });
181+
getByWalletAddress.mockResolvedValue(undefined);
182+
usersServiceUpdate.mockResolvedValue({
183+
id: "user-1",
184+
wallet_address: VALID_ADDRESS_LOWER,
185+
wallet_chain_type: "evm",
186+
wallet_verified: true,
187+
});
188+
189+
const res = await attachRoute.default.fetch(
190+
makeRequest({ message: "msg", signature: VALID_SIGNATURE }),
191+
);
192+
193+
expect(res.status).toBe(200);
194+
const body = (await res.json()) as {
195+
address: string;
196+
user: { wallet_address: string };
197+
};
198+
expect(body.address).toBe(VALID_ADDRESS);
199+
expect(body.user.wallet_address).toBe(VALID_ADDRESS_LOWER);
200+
expect(usersServiceUpdate).toHaveBeenCalledWith("user-1", {
201+
wallet_address: VALID_ADDRESS_LOWER,
202+
wallet_chain_type: "evm",
203+
wallet_verified: true,
204+
});
205+
});
206+
207+
test("returns 409 wallet_taken when the address is bound to the SAME user (race-safe)", async () => {
208+
// If the conflict-check finds the same user, the prior wallet_address gate
209+
// would have caught it. But cover the edge case where user record state is
210+
// stale between requireUser and getByWalletAddress.
211+
requireUser.mockResolvedValue({ id: "user-1", wallet_address: null });
212+
buildRedisClient.mockReturnValue({});
213+
validateAndConsumeSIWE.mockResolvedValue({ address: VALID_ADDRESS });
214+
getByWalletAddress.mockResolvedValue({ id: "user-1" });
215+
usersServiceUpdate.mockResolvedValue({
216+
id: "user-1",
217+
wallet_address: VALID_ADDRESS_LOWER,
218+
wallet_chain_type: "evm",
219+
wallet_verified: true,
220+
});
221+
222+
const res = await attachRoute.default.fetch(
223+
makeRequest({ message: "msg", signature: VALID_SIGNATURE }),
224+
);
225+
226+
// Same user found: not a conflict, route should proceed to update.
227+
expect(res.status).toBe(200);
228+
expect(usersServiceUpdate).toHaveBeenCalled();
229+
});
230+
});

packages/cloud-api/src/_router.generated.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* AUTO-GENERATED by src/_generate-router.mjs - do not edit by hand.
33
* Re-run `bun run codegen` after adding or removing a route.ts file.
44
*
5-
* 561 routes mounted, 0 skipped (still Next-shaped).
5+
* 562 routes mounted, 0 skipped (still Next-shaped).
66
*/
77

88
/* eslint-disable */
@@ -189,6 +189,7 @@ import _route_training_vertex_assignments_route from "../training/vertex/assignm
189189
import _route_training_vertex_jobs_route from "../training/vertex/jobs/route";
190190
import _route_training_vertex_models_route from "../training/vertex/models/route";
191191
import _route_training_vertex_tune_route from "../training/vertex/tune/route";
192+
import _route_users_me_wallet_attach_route from "../users/me/wallet/attach/route";
192193
import _route_users_me_route from "../users/me/route";
193194
import _route_v1_admin_ai_pricing_route from "../v1/admin/ai-pricing/route";
194195
import _route_v1_admin_cloud_observability_route from "../v1/admin/cloud-observability/route";
@@ -1007,6 +1008,7 @@ export function mountRoutes(app: Hono<AppEnv>): void {
10071008
app.route("/api/training/vertex/jobs", _route_training_vertex_jobs_route);
10081009
app.route("/api/training/vertex/models", _route_training_vertex_models_route);
10091010
app.route("/api/training/vertex/tune", _route_training_vertex_tune_route);
1011+
app.route("/api/users/me/wallet/attach", _route_users_me_wallet_attach_route);
10101012
app.route("/api/users/me", _route_users_me_route);
10111013
app.route("/api/v1/admin/ai-pricing", _route_v1_admin_ai_pricing_route);
10121014
app.route(
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* POST /api/users/me/wallet/attach
3+
*
4+
* Attaches an EVM wallet to the currently-authenticated user by validating a
5+
* SIWE message + signature against the same nonce store used by /api/auth/siwe.
6+
*
7+
* WHY: OAuth signups (Google / Discord / GitHub / Magic Link / Passkey) never
8+
* receive a `wallet_address`, and the direct-crypto-payments endpoint requires
9+
* one. Without this route there is no way for an OAuth user to use the BSC
10+
* promo (or any other wallet-native crypto payment surface).
11+
*
12+
* Conflict policy: if the address is already bound to a different user, we
13+
* return 409 (`wallet_taken`). The pre-existing wallet-keyed account wins;
14+
* the OAuth user must use that account instead. This matches the unique
15+
* index on `users.wallet_address` and avoids any account-merge logic.
16+
*/
17+
18+
import { Hono } from "hono";
19+
import { getAddress } from "viem";
20+
import { requireUser } from "@/lib/auth/workers-hono-auth";
21+
import { buildRedisClient } from "@/lib/cache/redis-factory";
22+
import {
23+
RateLimitPresets,
24+
rateLimit,
25+
} from "@/lib/middleware/rate-limit-hono-cloudflare";
26+
import { usersService } from "@/lib/services/users";
27+
import { getAppHost } from "@/lib/utils/app-url";
28+
import { logger } from "@/lib/utils/logger";
29+
import { validateAndConsumeSIWE } from "@/lib/utils/siwe-helpers";
30+
import type { AppEnv } from "@/types/cloud-worker-env";
31+
32+
interface AttachBody {
33+
message: string;
34+
signature: `0x${string}`;
35+
}
36+
37+
const app = new Hono<AppEnv>();
38+
39+
app.use("*", rateLimit(RateLimitPresets.STRICT));
40+
41+
app.post("/", async (c) => {
42+
const user = await requireUser(c);
43+
44+
if (user.wallet_address) {
45+
return c.json(
46+
{
47+
error: "A wallet is already attached to this account.",
48+
code: "already_attached",
49+
wallet_address: user.wallet_address,
50+
},
51+
409,
52+
);
53+
}
54+
55+
const redis = buildRedisClient(c.env);
56+
if (!redis) {
57+
return c.json({ error: "Service temporarily unavailable" }, 503);
58+
}
59+
60+
const body = (await c.req.json().catch(() => null)) as AttachBody | null;
61+
if (!body?.message || !body?.signature) {
62+
return c.json({ error: "message and signature are required" }, 400);
63+
}
64+
65+
let address: string;
66+
try {
67+
const result = await validateAndConsumeSIWE(
68+
redis,
69+
body.message,
70+
body.signature,
71+
getAppHost(c.env),
72+
);
73+
address = result.address;
74+
} catch (err) {
75+
logger.warn("[Wallet Attach] SIWE validation failed", {
76+
userId: user.id,
77+
error: err instanceof Error ? err.message : String(err),
78+
});
79+
return c.json({ error: "Wallet signature verification failed" }, 401);
80+
}
81+
82+
const checksummed = getAddress(address);
83+
const normalized = checksummed.toLowerCase();
84+
85+
const conflict = await usersService.getByWalletAddress(checksummed);
86+
if (conflict && conflict.id !== user.id) {
87+
logger.warn("[Wallet Attach] Wallet bound to another user", {
88+
userId: user.id,
89+
walletAddress: normalized,
90+
existingUserId: conflict.id,
91+
});
92+
return c.json(
93+
{
94+
error:
95+
"This wallet is already linked to another Eliza Cloud account. Sign in with the wallet-based account instead.",
96+
code: "wallet_taken",
97+
},
98+
409,
99+
);
100+
}
101+
102+
const updated = await usersService.update(user.id, {
103+
wallet_address: normalized,
104+
wallet_chain_type: "evm",
105+
wallet_verified: true,
106+
});
107+
if (!updated) {
108+
logger.error("[Wallet Attach] Update returned no user", {
109+
userId: user.id,
110+
});
111+
return c.json({ error: "Failed to attach wallet" }, 500);
112+
}
113+
114+
logger.info("[Wallet Attach] Wallet attached to user", {
115+
userId: user.id,
116+
walletAddress: normalized,
117+
});
118+
119+
return c.json({
120+
address: checksummed,
121+
user: {
122+
id: updated.id,
123+
wallet_address: updated.wallet_address,
124+
wallet_chain_type: updated.wallet_chain_type,
125+
wallet_verified: updated.wallet_verified,
126+
},
127+
});
128+
});
129+
130+
export default app;

0 commit comments

Comments
 (0)