Skip to content

Commit f2d4ad6

Browse files
committed
test(api): add env override util and custodial wallet tests
fix(deps): update h3, next, socket.io-parser for security
1 parent cac9404 commit f2d4ad6

File tree

15 files changed

+249
-103
lines changed

15 files changed

+249
-103
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ Run with `pnpm <script>`.
8686
**Misc**
8787
- `update-deps` — Update pnpm and all dependencies
8888

89+
## Spec alignment
90+
91+
Implementation and tests follow [`__dev/dynamic-api.md`](__dev/dynamic-api.md) (Dynamic Take-Home Backend). See [Product Definition](https://vencura-docs.vercel.app/docs/product/overview) for the mapping of spec requirements to features.
92+
8993
## Documentation
9094

9195
Full docs: [vencura-docs.vercel.app](https://vencura-docs.vercel.app/docs)

apps/api/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Uses `framework: "fastify"` in vercel.json. Vercel auto-detects `server.ts` as t
2020

2121
Copy `.env.test.example` to `.env.test` (gitignored) for unit tests. Vitest loads it when present. `ALLOWED_ORIGINS` (default `*`) controls CORS and URL validation for auth callbacks.
2222

23+
**Auth in integration tests:** Wallet route tests use API key auth via `getOrCreateSession`. Dynamic JWT auth is exercised in E2E (web wallets spec with Dynamic sandbox).
24+
2325
## pnpm commands
2426

2527
- `pnpm dev` — Dev server with hot reload (requires db)

apps/api/src/lib/custodial-wallet.spec.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
1+
import { withTestEnvOverride } from '@test/utils/test-env-override.js'
12
import { describe, expect, it } from 'vitest'
3+
import type { CustodialWallet } from '../db/schema/index.js'
24
import {
35
decryptPrivateKey,
46
encryptPrivateKey,
57
generateCustodialWallet,
68
getAddress,
9+
signWalletMessage,
710
} from './custodial-wallet.js'
811

12+
function createMockWallet(overrides: Partial<CustodialWallet> = {}): CustodialWallet {
13+
return {
14+
id: 'test',
15+
userId: 'test',
16+
address: '0x0000000000000000000000000000000000000001',
17+
encryptedPrivateKey: 'x',
18+
chainId: 11155111,
19+
createdAt: new Date(),
20+
updatedAt: new Date(),
21+
...overrides,
22+
}
23+
}
24+
925
describe('custodial-wallet', () => {
1026
describe('generateCustodialWallet', () => {
1127
it('should produce address and privateKey', async () => {
@@ -52,4 +68,64 @@ describe('custodial-wallet', () => {
5268
if (enc2) expect(decryptPrivateKey(enc2)).toBe(privateKey)
5369
})
5470
})
71+
72+
describe('signWalletMessage', () => {
73+
it('should return hex signature for valid wallet', async () => {
74+
const { address, privateKey } = await generateCustodialWallet()
75+
const encrypted = encryptPrivateKey(privateKey)
76+
if (!encrypted) throw new Error('encrypt failed')
77+
78+
const wallet = createMockWallet({ address, encryptedPrivateKey: encrypted })
79+
const signed = await signWalletMessage(wallet, 'Hello')
80+
expect(signed).toMatch(/^0x[a-fA-F0-9]+$/)
81+
expect(signed.length).toBeGreaterThan(130)
82+
})
83+
84+
it('should throw when encrypted key fails to decrypt', async () => {
85+
const wallet = createMockWallet({ encryptedPrivateKey: 'invalid' })
86+
await expect(signWalletMessage(wallet, 'x')).rejects.toThrow('Failed to decrypt')
87+
})
88+
})
89+
90+
describe('getWalletBalance', () => {
91+
it('should return balance string when TEST_BALANCE_OVERRIDE is set', async () => {
92+
await withTestEnvOverride('TEST_BALANCE_OVERRIDE', '1000000000000000000', async () => {
93+
const { getWalletBalance } = await import('./custodial-wallet.js')
94+
const wallet = createMockWallet()
95+
const balance = await getWalletBalance(wallet)
96+
expect(balance).toBe('1000000000000000000')
97+
})
98+
})
99+
})
100+
101+
describe('sendWalletTransaction', () => {
102+
it('should return hash when TEST_SEND_HASH_OVERRIDE is set', async () => {
103+
await withTestEnvOverride('TEST_SEND_HASH_OVERRIDE', '0xabcd1234', async () => {
104+
const { sendWalletTransaction } = await import('./custodial-wallet.js')
105+
const { address, privateKey } = await generateCustodialWallet()
106+
const encrypted = encryptPrivateKey(privateKey)
107+
if (!encrypted) throw new Error('encrypt failed')
108+
109+
const wallet = createMockWallet({ address, encryptedPrivateKey: encrypted })
110+
const hash = await sendWalletTransaction(
111+
wallet,
112+
'0x0000000000000000000000000000000000000001',
113+
'0.001',
114+
)
115+
expect(hash).toBe('0xabcd1234')
116+
})
117+
})
118+
119+
it('should throw INSUFFICIENT_FUNDS when RPC returns insufficient funds', async () => {
120+
const { sendWalletTransaction } = await import('./custodial-wallet.js')
121+
const { address, privateKey } = await generateCustodialWallet()
122+
const encrypted = encryptPrivateKey(privateKey)
123+
if (!encrypted) throw new Error('encrypt failed')
124+
125+
const wallet = createMockWallet({ address, encryptedPrivateKey: encrypted })
126+
await expect(
127+
sendWalletTransaction(wallet, '0x0000000000000000000000000000000000000001', '1'),
128+
).rejects.toMatchObject({ code: 'INSUFFICIENT_FUNDS' })
129+
})
130+
})
55131
})

apps/api/src/lib/custodial-wallet.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export function getPublicClient(chainId: number): PublicClient {
6464
}
6565

6666
export async function getWalletBalance(wallet: CustodialWallet): Promise<string> {
67+
// Test-only: read at call time so tests can set process.env per test; NODE_ENV guard prevents prod misuse
68+
const testBalance = process.env.NODE_ENV === 'test' && process.env.TEST_BALANCE_OVERRIDE // eslint-disable-line no-restricted-properties
69+
if (testBalance) return testBalance
70+
6771
const decrypted = decryptPrivateKey(wallet.encryptedPrivateKey)
6872
if (!decrypted) throw new Error('Failed to decrypt private key')
6973

@@ -86,6 +90,10 @@ export async function sendWalletTransaction(
8690
to: string,
8791
amount: string,
8892
): Promise<Hash> {
93+
// Test-only: read at call time so tests can set process.env per test; NODE_ENV guard prevents prod misuse
94+
const testOverride = process.env.NODE_ENV === 'test' && process.env.TEST_SEND_HASH_OVERRIDE // eslint-disable-line no-restricted-properties
95+
if (testOverride) return testOverride as Hash
96+
8997
const decrypted = decryptPrivateKey(wallet.encryptedPrivateKey)
9098
if (!decrypted) throw new Error('Failed to decrypt private key')
9199

apps/api/src/routes/wallets/send.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22
import { getOrCreateSession } from '../../../test/utils/auth-helper.js'
3+
import { withTestEnvOverride } from '../../../test/utils/test-env-override.js'
34
import { fastify } from './wallets.spec.js'
45

56
describe('POST /wallets/:id/send', () => {
@@ -94,4 +95,32 @@ describe('POST /wallets/:id/send', () => {
9495
})
9596
expect(sendRes.statusCode).toBe(404)
9697
})
98+
99+
it('should return 200 with transactionHash for successful send', async () => {
100+
const fakeHash = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd'
101+
await withTestEnvOverride('TEST_SEND_HASH_OVERRIDE', fakeHash, async () => {
102+
const token = await getOrCreateSession(fastify, 'wallets-send-success@test.ai')
103+
104+
const createRes = await fastify.inject({
105+
method: 'POST',
106+
url: '/wallets',
107+
headers: { Authorization: `Bearer ${token}` },
108+
})
109+
expect(createRes.statusCode).toBe(201)
110+
const { id } = JSON.parse(createRes.body)
111+
112+
const sendRes = await fastify.inject({
113+
method: 'POST',
114+
url: `/wallets/${id}/send`,
115+
headers: { Authorization: `Bearer ${token}` },
116+
payload: {
117+
to: '0x0000000000000000000000000000000000000001',
118+
amount: '0.001',
119+
},
120+
})
121+
expect(sendRes.statusCode).toBe(200)
122+
const body = JSON.parse(sendRes.body)
123+
expect(body.transactionHash).toBe(fakeHash)
124+
})
125+
})
97126
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export async function withTestEnvOverride<T>(
2+
name: string,
3+
value: string,
4+
fn: () => Promise<T>,
5+
): Promise<T> {
6+
const prev = process.env[name]
7+
process.env[name] = value
8+
try {
9+
return await fn()
10+
} finally {
11+
if (prev !== undefined) process.env[name] = prev
12+
else delete process.env[name]
13+
}
14+
}

apps/docu/content/docs/product/overview.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,23 @@ title: "Product Definition"
33
description: "Required and optional features, alignment with Dynamic's Take-Home Backend."
44
---
55

6-
Vencura Wallet aligns with [Dynamic's Take-Home Backend](https://dynamic-labs.notion.site/Dynamic-s-Take-Home-Backend-cb8efc5140154eea83deb25738a04e91). The product delivers custodial wallets for Web3 with backend-only operations.
6+
Vencura Wallet aligns with [Dynamic's Take-Home Backend](https://dynamic-labs.notion.site/Dynamic-s-Take-Home-Backend-cb8efc5140154eea83deb25738a04e91). The canonical spec is [`__dev/dynamic-api.md`](https://github.com/blockmatic/vencura/blob/main/__dev/dynamic-api.md) in the repo. The product delivers custodial wallets for Web3 with backend-only operations.
77

88
## Required Features
99

1010
| Feature | Status | Description |
1111
|---------|--------|-------------|
1212
| **Dynamic auth** | Yes | Magic link, OAuth, Web3 sign-in, API keys |
1313
| **Create account/wallet** | Yes | Authenticated users create at least one custodial wallet |
14-
| **getBalance()** | Yes | `GET /wallets/:id/balance` returns balance (wei) |
15-
| **signMessage(msg)** | Yes | `POST /wallets/:id/sign` with `{ msg }` returns signed message |
16-
| **sendTransaction(to, amount)** | Yes | `POST /wallets/:id/send` with `{ to, amount }` returns transaction hash |
14+
| **getBalance()** | Yes | `GET /wallets/:id/balance` returns `balance` (string, wei) for safe handling of large numbers |
15+
| **signMessage(msg)** | Yes | `POST /wallets/:id/sign` with `{ msg }` returns `signedMessage` (string) |
16+
| **sendTransaction(to, amount)** | Yes | `POST /wallets/:id/send` with `{ to, amount }` `amount` in ETH (string); returns `transactionHash` (string) |
1717
| **Basic UI** | Yes | Web dashboard and Expo app to interact with the API |
1818

1919
All wallet interactions are done on the backend via the API. The UI calls the API with a Bearer token (Dynamic JWT or API key).
2020

21+
**API types:** The spec uses `balance: number` and `amount: number` informally. The implementation uses `string` for both: `balance` in wei (18 decimals), `amount` in ETH (e.g. `"0.001"`). Strings avoid JavaScript number precision issues with large wei values.
22+
2123
## Optional Ideas (Future)
2224

2325
The take-home suggests optional enhancements:

apps/docu/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"fumadocs-ui": "16.6.0",
2323
"lucide-react": "^0.564.0",
2424
"mermaid": "^11.12.2",
25-
"next": "16.1.6",
25+
"next": "16.1.7",
2626
"next-themes": "^0.4.6",
2727
"react": "^19.2.4",
2828
"react-dom": "^19.2.4",

apps/mathler/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"input-otp": "1.4.1",
3939
"lodash-es": "latest",
4040
"lucide-react": "^0.475.0",
41-
"next": "16.1.6",
41+
"next": "16.1.7",
4242
"next-themes": "^0.4.6",
4343
"nuqs": "^2.8.6",
4444
"react": "^19.2.3",

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"import-in-the-middle": "^3.0.0",
5858
"jose": "^6.1.3",
5959
"lucide-react": "^0.564.0",
60-
"next": "16.1.6",
60+
"next": "16.1.7",
6161
"next-themes": "^0.4.6",
6262
"nuqs": "^2.8.8",
6363
"react": "^19.2.4",

0 commit comments

Comments
 (0)