Skip to content

Commit b0293b6

Browse files
hard: registration wallet-existence checks are TOCTOU — concurrent registers create duplicate identities (#127)
* hard: registration wallet-existence checks are TOCTOU — concurrent registers create duplicate identities * changes made
1 parent ca2f4a7 commit b0293b6

6 files changed

Lines changed: 398 additions & 150 deletions

File tree

context/progress-tracker.md

Lines changed: 8 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ pure chore/docs commits). Direct pushes to main must also be logged here.
2727
event, blocked-user denial within TTL bound, cache expiry re-query,
2828
cleanup job deletes-only-expired.
2929

30+
## 2026-08-26
31+
32+
- Fixed registration race conditions in `AuthService.register()` by eliminating application-side pre-checks (`findByWallet`, `checkUsernameExists`) and relying directly on DB-level UNIQUE constraints (`users.wallet_address`, `users.username`).
33+
- Added idempotent migration `20260826130000_ensure_users_unique_constraints.sql` to ensure unique indexes exist on `users.wallet_address` and `users.username`.
34+
- Updated `UsersRepository.createProfile()` to catch PostgreSQL unique constraint violation error `23505` and map to structured 409 `ConflictException` (`AUTH_WALLET_EXISTS`, `AUTH_USERNAME_TAKEN`).
35+
- Added cleanup handlers (`deleteAvatar`, `deleteUserById`) in `AuthService.register()` and `UsersRepository` to ensure failed registrations do not leave orphaned avatar files or partial user records.
36+
- Added unit tests covering DB unique constraint error mapping, parallel race conditions for duplicate wallet and username registrations, sequential re-registration compatibility, and avatar/user cleanup on failure.
37+
3038
## 2026-07-23
3139

3240
- Added GitHub Actions health check workflow (`health-check.yml`) to ping the Render API every 6 hours to prevent the free tier instance from sleeping. Auto-creates or comments on issues with the `incident` label if the ping fails, preventing silent outages.
@@ -117,120 +125,7 @@ pure chore/docs commits). Direct pushes to main must also be logged here.
117125

118126
---
119127

120-
<<<<<<< Updated upstream
121128
> Note (2026-07-16): this file previously contained StepFi-Contracts
122129
> content copied from the wrong repo. Replaced with real StepFi-API
123130
> history backfilled from `git log`. Entries older than 2026-06-18 are
124131
> in git history but were never tracked here.
125-
=======
126-
## Completed
127-
128-
### Workspace Cleanup
129-
- Removed dead code: `lp-contract` (superseded by `liquidity-pool-contract`)
130-
- Removed empty placeholder: `adapter-trustless-contract`
131-
- Updated `Cargo.toml` workspace members to reflect 5 active contracts
132-
- Removed `[profile]` sections from individual contract `Cargo.toml` files (profiles belong in workspace root only)
133-
134-
### Renaming
135-
- Renamed `merchant-registry-contract``vendor-registry-contract`
136-
- Updated all Rust source references: `merchant_registry_contract``vendor_registry_contract`
137-
- Updated all struct names: `MerchantRegistry*``VendorRegistry*`
138-
- Updated `Cargo.toml` dependency paths in `creditline-contract`
139-
140-
### Critical Fixes
141-
- Added TTL constants (`PERSISTENT_TTL_THRESHOLD`, `PERSISTENT_TTL_EXTEND_TO`) to `creditline-contract/src/storage.rs`
142-
- Added `upgrade()` function to all 5 contracts: reputation, creditline, liquidity-pool, vendor-registry, parameters
143-
- All 5 contracts build cleanly: `cargo build` passes with zero errors (3 minor unused constant warnings — acceptable)
144-
145-
### Deployment
146-
- Created `scripts/deploy-testnet.sh` — full deployment script covering all 5 contracts in correct dependency order
147-
- Script outputs contract IDs and saves to `.env.contracts`
148-
- StepFi-API deployed on Render ✅
149-
- Supabase project created, 24 migrations applied ✅
150-
- Upstash Redis connected ✅
151-
- Swagger docs live ✅
152-
153-
### Documentation
154-
- `README.md` fully rewritten as StepFi-Contracts
155-
156-
### CI Pipeline
157-
- Created `.github/workflows/ci.yml` — runs on push/PR to `main`
158-
- Steps: checkout → setup Node 20 → `npm ci``npm run build``npm test`
159-
- `node_modules` cached via `actions/cache@v4` keyed on `package-lock.json` hash
160-
- CI status badge added to `README.md` pointing at the workflow
161-
162-
### Vendor Approval Lifecycle
163-
- Created database migration `20260817000001_add_vendor_status.sql` adding `status` column constrained to `pending`, `approved`, `suspended`, `rejected`, defaulting to `pending` and backfilling existing rows.
164-
- Added `buildApproveVendorXdr` and `buildSuspendVendorXdr` methods to `VendorRegistryContractClient` and `IVendorRegistryClient` to construct unsigned Soroban transaction XDRs.
165-
- Created `AdminGuard` to enforce allowlisted wallet access via `ADMIN_WALLETS` (401 for unauthenticated, 403 for non-admin).
166-
- Created `AuditAction` decorator and `AuditInterceptor` for audit-logging privileged admin operations.
167-
- Added `POST /vendors/:id/approve` and `POST /vendors/:id/suspend` endpoints returning unsigned XDRs, guarded with `JwtAuthGuard` and `AdminGuard`, decorated with full Swagger annotations and returning HTTP 409 Conflict for invalid vendor status transitions (`VENDOR_NOT_PENDING`, `VENDOR_NOT_APPROVED`).
168-
- Integrated status updates into `TransactionStatusCheckerProcessor` to update local Supabase `vendors` status only after on-chain transaction confirmation.
169-
### Learner Profile Auto-Creation
170-
- Added automatic creation of `learner_profiles` records upon first sign-in in `AuthService.findOrCreateUser()`, ensuring `GET /learners/me` resolves immediately after authentication.
171-
- Updated `auth.service.spec.ts` unit tests to cover table query and insertion handling for `learner_profiles`.
172-
173-
174-
---
175-
176-
## In Progress
177-
178-
- None currently.
179-
180-
---
181-
182-
## Next Up (In Order)
183-
184-
1. **LoanType enum** — Add `LoanType::LearnerInstallment` variant to `creditline-contract/src/types.rs`
185-
2. **Per-installment tracking** — Add `paid: bool` and `paid_at: u64` fields to `RepaymentInstallment` struct
186-
3. **repay_installment()** — New function targeting a specific installment by index (instead of just reducing remaining balance)
187-
4. **Learner grace period** — Make `grace_period_seconds` per-loan (not just global via parameters)
188-
5. **Vouching contract** — New `vouching-contract` crate: `vouch()`, `revoke_vouch()`, `get_vouches()`, `get_vouch_count()`
189-
6. **Reputation rules** — Update `creditline-contract` to call different reputation adjustments for `LoanType::LearnerInstallment`
190-
7. **Testnet deployment** — Deploy all contracts, capture IDs, add to StepFi-API `.env`
191-
8. **End-to-end validation** — Verify loan lifecycle on testnet via Stellar CLI
192-
193-
---
194-
195-
## Open Questions
196-
197-
- What token is used for loans — native XLM or a USDC anchor? (Affects token contract address in `initialize()`)
198-
- Should the vouching contract be a standalone crate or logic added to `creditline-contract`? (Leaning toward standalone for modularity)
199-
- What is the correct `grace_period_seconds` for learner installment loans? (Longer than standard BNPL — possibly 7-14 days per installment)
200-
- Should sponsor pool deposits go through `liquidity-pool-contract` or a new `sponsor-pool-contract`?
201-
202-
---
203-
204-
## Architecture Decisions
205-
206-
- **5 contracts, not 6**`lp-contract` was dead code, removed. `liquidity-pool-contract` is the canonical LP implementation.
207-
- **Vendor over Merchant** — Renamed to reflect StepFi's learning-focused domain.
208-
- **TTL approach** — Using 60-day threshold / 120-day extension constants. Off-chain indexer is responsible for bumping TTL on active loan entries.
209-
- **Upgrade pattern** — All contracts have `upgrade()` gated by admin `require_auth()`. Admin address is set at `initialize()` and transferable via `set_admin()`.
210-
- **Loan sharding** — 32 shards (`loan_id % 32`) in creditline-contract to distribute persistent storage keys and avoid hot-key contention.
211-
- **Reentrancy** — Boolean `LOCKED` flag in instance storage. Cheaper than mutex, sufficient for Soroban's single-threaded execution model.
212-
213-
---
214-
215-
## Contract Deployment Status
216-
217-
| Contract | Testnet Deployed | Contract ID | Last Deployed |
218-
|---|---|---|---|
219-
| `reputation-contract` | ❌ No |||
220-
| `parameters-contract` | ❌ No |||
221-
| `vendor-registry-contract` | ❌ No |||
222-
| `liquidity-pool-contract` | ❌ No |||
223-
| `creditline-contract` | ❌ No |||
224-
225-
> Update this table after running `scripts/deploy-testnet.sh`
226-
227-
---
228-
229-
## Session Notes
230-
231-
- Always run `cargo build` after any contract change before committing.
232-
- Always run `cargo test` before marking any contract feature complete.
233-
- Never modify storage key structures of a contract that has been deployed — it breaks existing data. Use a migration pattern or deploy a new contract.
234-
- The `creditline-contract` depends on all other contracts — it must be initialized last.
235-
- Do not add new workspace members to `Cargo.toml` without creating the full contract file structure first.
236-
>>>>>>> Stashed changes

src/database/repositories/users.repository.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, InternalServerErrorException } from '@nestjs/common';
1+
import { Injectable, InternalServerErrorException, ConflictException } from '@nestjs/common';
22
import { SupabaseService } from '../supabase.client';
33
import { UpdateUserDto } from '../../modules/users/dto/update-user.dto';
44

@@ -260,6 +260,19 @@ export class UsersRepository {
260260
.single();
261261

262262
if (error) {
263+
const combinedErr = `${error.code || ''} ${error.message || ''} ${error.details || ''} ${error.hint || ''}`;
264+
if (error.code === '23505' || combinedErr.includes('duplicate key') || combinedErr.includes('unique constraint')) {
265+
if (combinedErr.includes('username')) {
266+
throw new ConflictException({
267+
code: 'AUTH_USERNAME_TAKEN',
268+
message: 'Username is already taken.',
269+
});
270+
}
271+
throw new ConflictException({
272+
code: 'AUTH_WALLET_EXISTS',
273+
message: 'Wallet address is already registered.',
274+
});
275+
}
263276
throw new InternalServerErrorException({
264277
code: 'DATABASE_INSERT_ERROR',
265278
message: `Failed to create user profile: ${error.message}`,
@@ -292,4 +305,25 @@ export class UsersRepository {
292305
const { data } = client.storage.from('avatars').getPublicUrl(fileName);
293306
return data.publicUrl;
294307
}
308+
309+
async deleteAvatar(avatarUrl: string): Promise<void> {
310+
try {
311+
const fileName = avatarUrl.substring(avatarUrl.lastIndexOf('/') + 1);
312+
if (!fileName) return;
313+
const client = this.supabaseService.getServiceRoleClient();
314+
await client.storage.from('avatars').remove([fileName]);
315+
} catch {
316+
// Ignore cleanup failures
317+
}
318+
}
319+
320+
async deleteUserById(id: string): Promise<void> {
321+
try {
322+
const client = this.supabaseService.getServiceRoleClient();
323+
await client.from('users').delete().eq('id', id);
324+
} catch {
325+
// Ignore cleanup failures
326+
}
327+
}
295328
}
329+

src/modules/auth/auth.service.ts

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -55,36 +55,42 @@ export class AuthService {
5555
) {}
5656

5757
async register(dto: RegisterRequestDto, profileImage?: UploadedAvatarFile): Promise<RegisterResponse> {
58-
const existingWallet = await this.usersRepository.findByWallet(dto.walletAddress);
59-
if (existingWallet) {
60-
throw new ConflictException({ code: 'AUTH_WALLET_EXISTS', message: 'Wallet address is already registered.' });
61-
}
62-
const usernameTaken = await this.usersRepository.checkUsernameExists(dto.username);
63-
if (usernameTaken) {
64-
throw new ConflictException({ code: 'AUTH_USERNAME_TAKEN', message: 'Username is already taken.' });
65-
}
6658
let avatarUrl: string | null = null;
67-
if (profileImage) {
68-
avatarUrl = await this.usersRepository.uploadAvatar(dto.walletAddress, profileImage);
59+
let createdUserId: string | null = null;
60+
try {
61+
if (profileImage) {
62+
avatarUrl = await this.usersRepository.uploadAvatar(dto.walletAddress, profileImage);
63+
}
64+
const user = await this.usersRepository.createProfile({
65+
wallet: dto.walletAddress,
66+
username: dto.username,
67+
displayName: dto.displayName,
68+
avatarUrl,
69+
});
70+
createdUserId = user.id;
71+
72+
const tokens = await this.generateTokens(dto.walletAddress);
73+
74+
return {
75+
user: {
76+
id: user.id,
77+
walletAddress: user.wallet_address,
78+
username: user.username,
79+
displayName: user.display_name,
80+
avatarUrl: user.avatar_url,
81+
createdAt: user.created_at,
82+
},
83+
...tokens,
84+
};
85+
} catch (error) {
86+
if (avatarUrl) {
87+
await this.usersRepository.deleteAvatar(avatarUrl).catch(() => {});
88+
}
89+
if (createdUserId) {
90+
await this.usersRepository.deleteUserById(createdUserId).catch(() => {});
91+
}
92+
throw error;
6993
}
70-
const user = await this.usersRepository.createProfile({
71-
wallet: dto.walletAddress,
72-
username: dto.username,
73-
displayName: dto.displayName,
74-
avatarUrl,
75-
});
76-
const tokens = await this.generateTokens(dto.walletAddress);
77-
return {
78-
user: {
79-
id: user.id,
80-
walletAddress: user.wallet_address,
81-
username: user.username,
82-
displayName: user.display_name,
83-
avatarUrl: user.avatar_url,
84-
createdAt: user.created_at,
85-
},
86-
...tokens,
87-
};
8894
}
8995

9096
async generateNonce(wallet: string): Promise<NonceResponseDto> {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
-- Ensure DB-level UNIQUE indexes exist on users.wallet_address and users.username
2+
3+
-- Keep the oldest row in each duplicate group before adding the constraints.
4+
WITH duplicate_wallets AS (
5+
SELECT id,
6+
ROW_NUMBER() OVER (
7+
PARTITION BY wallet_address
8+
ORDER BY created_at ASC, id ASC
9+
) AS row_number
10+
FROM public.users
11+
WHERE wallet_address IS NOT NULL
12+
), rows_to_delete AS (
13+
SELECT id
14+
FROM duplicate_wallets
15+
WHERE row_number > 1
16+
)
17+
DELETE FROM public.users
18+
WHERE id IN (SELECT id FROM rows_to_delete);
19+
20+
WITH duplicate_usernames AS (
21+
SELECT id,
22+
ROW_NUMBER() OVER (
23+
PARTITION BY username
24+
ORDER BY created_at ASC, id ASC
25+
) AS row_number
26+
FROM public.users
27+
WHERE username IS NOT NULL
28+
), rows_to_delete AS (
29+
SELECT id
30+
FROM duplicate_usernames
31+
WHERE row_number > 1
32+
)
33+
DELETE FROM public.users
34+
WHERE id IN (SELECT id FROM rows_to_delete);
35+
36+
CREATE UNIQUE INDEX IF NOT EXISTS users_wallet_address_idx ON public.users (wallet_address);
37+
CREATE UNIQUE INDEX IF NOT EXISTS users_username_idx ON public.users (username);

0 commit comments

Comments
 (0)