diff --git a/SECURITY_REVIEW_CLAIMS.md b/SECURITY_REVIEW_CLAIMS.md
new file mode 100644
index 0000000..c8ecf91
--- /dev/null
+++ b/SECURITY_REVIEW_CLAIMS.md
@@ -0,0 +1,437 @@
+# Security Review: Claims Transaction Validation
+
+**Date**: 2025-10-28
+**Endpoint**: `/claims/confirm` in `ui/routes/claims.ts`
+**Status**: ✅ Core security strong, with minor enhancement opportunities
+
+---
+
+## Executive Summary
+
+The claims confirmation endpoint implements robust transaction validation with defense-in-depth security principles. The core vulnerability (unauthorized TOKEN_PROGRAM instructions) has been properly mitigated. This review identifies three minor enhancements to achieve complete transaction validation.
+
+**Security Score**: 8.5/10
+
+---
+
+## ✅ Validated Elements (Strong Security)
+
+### 1. Transaction Metadata Validation
+
+| Element | Location | Status |
+|---------|----------|--------|
+| Blockhash presence | Line 606 | ✅ Validated |
+| Blockhash freshness | Lines 613-622 | ✅ Validated via RPC |
+| Replay attack prevention | Lines 605-622 | ✅ Protected |
+
+### 2. Cryptographic Signature Validation
+
+| Element | Location | Status |
+|---------|----------|--------|
+| User signature present | Lines 643-658 | ✅ Verified |
+| Cryptographic validity | Lines 651-656 | ✅ Verified with nacl |
+| Message compilation | Lines 639-640 | ✅ Proper serialization |
+| Signature-message binding | Lines 651-656 | ✅ Verified |
+
+### 3. Instruction-Level Validation (Program Whitelist)
+
+| Program | Purpose | Status |
+|---------|---------|--------|
+| TOKEN_PROGRAM | Mint instructions only | ✅ Validated |
+| ASSOCIATED_TOKEN_PROGRAM | ATA creation only | ✅ Validated |
+| ComputeBudgetProgram | Priority fees | ✅ Whitelisted |
+| Lighthouse | Transaction optimization | ✅ Whitelisted |
+| Unknown programs | N/A | ✅ Rejected |
+
+**First Pass** (Lines 713-753): Validates all instructions
+**Second Pass** (Lines 829-934): Defense-in-depth with redundant checks
+
+### 4. Opcode-Level Validation
+
+| Program | Allowed Opcodes | Location | Status |
+|---------|----------------|----------|--------|
+| TOKEN_PROGRAM | 7 (MintTo) only | Lines 731-739, 923-928 | ✅ Validated |
+| ASSOCIATED_TOKEN_PROGRAM | 1 (CreateIdempotent) | Lines 743-752 | ✅ Validated |
+
+### 5. Mint Instruction Deep Validation
+
+| Element | Location | Status |
+|---------|----------|--------|
+| Mint account pubkey | Line 881 | ✅ Matches expected token |
+| Mint authority | Line 888 | ✅ Is protocol keypair |
+| Recipient accounts | Line 898 | ✅ Match expected recipients |
+| Mint amounts | Line 906 | ✅ Match expected amounts exactly |
+| Instruction count | Lines 777-783 | ✅ Correct number |
+| Complete coverage | Lines 938-947 | ✅ All recipients validated |
+
+### 6. Business Logic Security
+
+| Check | Location | Status |
+|-------|----------|--------|
+| Claim eligibility | Lines 523-539 | ✅ Re-validated at confirm time |
+| Authorization (creator vs designated) | Lines 541-596 | ✅ Enforced |
+| Race condition prevention | Line 476 | ✅ Locking mechanism |
+| Recent claim cooldown | Lines 479-484 | ✅ Enforced |
+
+---
+
+## ⚠️ Security Enhancement Opportunities
+
+### CRITICAL - Fee Payer Validation Missing
+
+**Severity**: MEDIUM
+**Impact**: LOW-MEDIUM (Self-limiting but violates security assumptions)
+
+**Issue**:
+The transaction fee payer is set in `/claims/mint` (line 370) but not validated in `/claims/confirm`. A user could modify the fee payer before signing.
+
+**Current Flow**:
+```javascript
+// /claims/mint - Line 370
+transaction.feePayer = userPublicKey;
+
+// /claims/confirm - MISSING VALIDATION
+// No check that transaction.feePayer still equals authorizedPublicKey
+```
+
+**Risk**:
+- User could change fee payer to any address
+- Transaction would fail if new fee payer doesn't sign
+- Breaks principle of least surprise
+- Could cause confusion in debugging
+
+**Attack Scenario**:
+```
+1. User receives unsigned tx with feePayer=UserWallet
+2. User modifies feePayer=SomeOtherAddress
+3. User signs with their own key
+4. Transaction fails (other address hasn't signed)
+5. Potential confusion or support burden
+```
+
+**Recommended Fix**:
+```typescript
+// Add after line 603 (after transaction deserialization)
+
+if (!transaction.feePayer) {
+ return res.status(400).json({
+ error: 'Invalid transaction: missing fee payer'
+ });
+}
+
+if (!transaction.feePayer.equals(authorizedPublicKey)) {
+ return res.status(400).json({
+ error: 'Invalid transaction: fee payer must be the authorized wallet'
+ });
+}
+```
+
+---
+
+### MEDIUM - Instruction Account Metadata Not Validated
+
+**Severity**: LOW-MEDIUM
+**Impact**: LOW (Solana runtime enforces correctness, but defense-in-depth is best practice)
+
+**Issue**:
+Instruction account keys include metadata flags (`isSigner`, `isWritable`) that are not validated. Only the pubkeys themselves are checked.
+
+**Current Code** (Lines 865-867):
+```typescript
+const mintAccount = instruction.keys[0].pubkey; // Only pubkey extracted
+const recipientAccount = instruction.keys[1].pubkey;
+const mintAuthority = instruction.keys[2].pubkey;
+
+// NOT CHECKED:
+// instruction.keys[0].isWritable // Should be true (mint account)
+// instruction.keys[1].isWritable // Should be true (recipient)
+// instruction.keys[2].isSigner // Should be true (authority)
+```
+
+**Risk**:
+- Incorrect metadata could indicate tampering
+- Solana runtime will reject, but could be part of complex attack chain
+- Violates defense-in-depth principle
+
+**MintTo Instruction Expected Structure**:
+```
+Account 0: Mint account (writable, not signer)
+Account 1: Recipient token account (writable, not signer)
+Account 2: Mint authority (not writable, signer)
+```
+
+**Recommended Fix**:
+```typescript
+// Add after line 867
+
+// Validate account metadata for MintTo instruction
+if (!instruction.keys[0].isWritable || instruction.keys[0].isSigner) {
+ return res.status(400).json({
+ error: 'Invalid transaction: mint account must be writable and not a signer'
+ });
+}
+
+if (!instruction.keys[1].isWritable || instruction.keys[1].isSigner) {
+ return res.status(400).json({
+ error: 'Invalid transaction: recipient account must be writable and not a signer'
+ });
+}
+
+if (instruction.keys[2].isWritable || !instruction.keys[2].isSigner) {
+ return res.status(400).json({
+ error: 'Invalid transaction: mint authority must be a signer and not writable'
+ });
+}
+```
+
+---
+
+### MEDIUM - Signature Count Not Validated
+
+**Severity**: LOW-MEDIUM
+**Impact**: MEDIUM (Extra signatures indicate anomaly)
+
+**Issue**:
+The code validates that the authorized user's signature exists and is cryptographically valid, but doesn't check for unexpected additional signatures.
+
+**Current State** (Lines 643-664):
+- Finds authorized user's signature
+- Verifies it cryptographically
+- ❌ Doesn't check total signature count
+
+**Risk**:
+- Transaction could include unexpected extra signatures
+- Could indicate tampering or preparation for multi-sig attack
+- Violates principle of strictness
+
+**Expected Signatures**:
+```
+Before protocol signs: 1 signature (user)
+After protocol signs: 2 signatures (user + protocol)
+```
+
+**Recommended Fix**:
+```typescript
+// Add after line 664 (after validating authorized signature)
+
+// Validate signature count (should be exactly 1 before we add protocol signature)
+const expectedSignatureCount = 1; // User only, protocol will sign later
+if (transaction.signatures.length !== expectedSignatureCount) {
+ return res.status(400).json({
+ error: `Invalid transaction: expected ${expectedSignatureCount} signature(s), found ${transaction.signatures.length}`,
+ details: 'Transaction may have been tampered with'
+ });
+}
+
+// Verify no other signatures are present
+for (let i = 0; i < transaction.signatures.length; i++) {
+ if (i !== authorizedSignerIndex && transaction.signatures[i].signature) {
+ return res.status(400).json({
+ error: 'Invalid transaction: unexpected additional signature detected'
+ });
+ }
+}
+```
+
+---
+
+### LOW - Transaction Message Header Not Validated
+
+**Severity**: LOW
+**Impact**: LOW (Informational - Solana runtime enforces correctness)
+
+**Issue**:
+The transaction message header contains metadata about the transaction structure that is not explicitly validated:
+- `numRequiredSignatures`: Number of signatures required
+- `numReadonlySignedAccounts`: Number of readonly signed accounts
+- `numReadonlyUnsignedAccounts`: Number of readonly unsigned accounts
+
+**Current State**:
+Message is compiled for signature verification (line 639) but header fields not explicitly checked.
+
+**Risk**:
+Minimal - Solana runtime will reject invalid header values. This is more of a completeness issue than a security risk.
+
+**Recommended Fix** (Optional):
+```typescript
+// Add after line 640 (after message compilation)
+
+// Validate message header
+const header = message.header;
+
+// Expected: 2 signers (user + protocol, but protocol not yet signed)
+// At this point, should be 1 required signature
+if (header.numRequiredSignatures < 1) {
+ return res.status(400).json({
+ error: 'Invalid transaction: insufficient required signatures in header'
+ });
+}
+
+// Log for monitoring
+console.log('Transaction message header:', {
+ numRequiredSignatures: header.numRequiredSignatures,
+ numReadonlySignedAccounts: header.numReadonlySignedAccounts,
+ numReadonlyUnsignedAccounts: header.numReadonlyUnsignedAccounts
+});
+```
+
+---
+
+## Implementation Status
+
+### ✅ Already Implemented (Security Fix Document)
+
+The following security measures from `SECURITY_FIX_CLAIMS_CONFIRM.md` have been successfully implemented:
+
+1. ✅ ComputeBudgetProgram whitelist (lines 707, 720)
+2. ✅ Lighthouse Program whitelist (lines 708, 721)
+3. ✅ Safe program ID definitions (lines 706-708)
+4. ✅ Whitelist checks in validation loops (lines 837-852)
+5. ✅ Rejection of non-MintTo TOKEN_PROGRAM instructions (lines 923-928)
+6. ✅ Rejection of unknown programs (lines 929-934)
+7. ✅ Defense-in-depth two-pass validation
+
+### 🔄 Enhancement Opportunities (This Document)
+
+1. ⚠️ Fee payer validation
+2. ⚠️ Instruction account metadata validation
+3. ⚠️ Signature count validation
+4. ℹ️ Transaction message header validation (optional)
+
+---
+
+## Security Architecture
+
+### Multi-Layer Validation Approach
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 1: Business Logic Validation │
+├─────────────────────────────────────────────────────────────┤
+│ • Claim eligibility check │
+│ • Authorization verification (creator/designated) │
+│ • Race condition prevention (locking) │
+│ • Cooldown enforcement │
+└─────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 2: Transaction Metadata Validation │
+├─────────────────────────────────────────────────────────────┤
+│ • Blockhash presence & freshness │
+│ • Signature verification (cryptographic) │
+│ • [ENHANCEMENT] Fee payer validation │
+│ • [ENHANCEMENT] Signature count validation │
+└─────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 3: First-Pass Instruction Validation (Strict) │
+├─────────────────────────────────────────────────────────────┤
+│ • Program whitelist enforcement │
+│ • Opcode validation (TOKEN_PROGRAM: MintTo only) │
+│ • Opcode validation (ATA_PROGRAM: CreateIdempotent only) │
+│ • Reject unknown programs │
+└─────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────┐
+│ Layer 4: Second-Pass Deep Validation (Defense-in-Depth) │
+├─────────────────────────────────────────────────────────────┤
+│ • Skip safe programs (ComputeBudget, ATA, Lighthouse) │
+│ • Validate mint instruction details: │
+│ - Mint account pubkey │
+│ - Mint authority pubkey │
+│ - Recipient account pubkeys │
+│ - Mint amounts │
+│ - [ENHANCEMENT] Account metadata flags │
+│ • Ensure all expected recipients covered │
+│ • Reject unexpected programs (redundant check) │
+└─────────────────────────────────────────────────────────────┘
+ ↓
+ ✅ Transaction Signed & Sent
+```
+
+---
+
+## Comparison with Presale Endpoint
+
+Both endpoints now follow the same defensive security pattern:
+
+| Security Feature | Presale (`/presale/:token/claims/confirm`) | Claims (`/claims/confirm`) |
+|------------------|---------------------------------------------|----------------------------|
+| Program whitelist | ✅ | ✅ |
+| Opcode validation | ✅ | ✅ |
+| Defense-in-depth (2 passes) | ✅ | ✅ |
+| Cryptographic signature verification | ✅ | ✅ |
+| Blockhash validation | ✅ | ✅ |
+| Account pubkey validation | ✅ | ✅ |
+| Amount validation | ✅ | ✅ |
+| Fee payer validation | ❌ | ❌ (both could be enhanced) |
+| Account metadata validation | ❌ | ❌ (both could be enhanced) |
+
+---
+
+## Testing Recommendations
+
+### Positive Tests (Should Succeed)
+
+1. ✅ Normal claim with compute budget instructions
+2. ✅ Normal claim with Lighthouse instructions
+3. ✅ Claim with ATA creation instructions
+4. ✅ Multiple recipients (emission splits)
+
+### Negative Tests (Should Be Rejected)
+
+1. ✅ Transaction with SetAuthority instruction (opcode 6)
+2. ✅ Transaction with unknown program
+3. ✅ Transaction with expired blockhash
+4. ✅ Transaction without user signature
+5. ✅ Transaction with invalid signature
+6. ✅ Transaction with wrong mint authority
+7. ✅ Transaction with unauthorized recipient
+8. ✅ Transaction with incorrect mint amount
+9. ⚠️ Transaction with modified fee payer (not currently tested)
+10. ⚠️ Transaction with extra signatures (not currently tested)
+
+### Enhancement Tests (If Implemented)
+
+- Transaction with wrong fee payer → Should reject
+- Transaction with extra signatures → Should reject
+- Transaction with incorrect isSigner flags → Should reject
+- Transaction with incorrect isWritable flags → Should reject
+
+---
+
+## References
+
+### Related Files
+
+- Implementation: `ui/routes/claims.ts` (lines 442-1004)
+- Presale comparison: `ui/routes/presale.ts` (lines 339-656)
+- Original security fix: ~~`SECURITY_FIX_CLAIMS_CONFIRM.md`~~ (implemented & removed)
+
+### Solana Documentation
+
+- [Transaction Structure](https://docs.solana.com/developing/programming-model/transactions)
+- [SPL Token Program](https://spl.solana.com/token)
+- [Account Model](https://docs.solana.com/developing/programming-model/accounts)
+
+### Security Principles Applied
+
+1. **Defense-in-Depth**: Multiple validation layers
+2. **Least Privilege**: Only necessary programs whitelisted
+3. **Fail Secure**: Reject by default, explicit allow list
+4. **Cryptographic Verification**: Signature validation using nacl
+5. **Idempotency**: Transaction replay prevention via blockhash
+
+---
+
+## Conclusion
+
+The claims confirmation endpoint demonstrates strong security practices with comprehensive transaction validation. The identified enhancements are **optional improvements** that would achieve 100% transaction parsing coverage and further strengthen the defense-in-depth approach.
+
+**Priority for Implementation:**
+1. **High**: Fee payer validation (user experience and security principle)
+2. **Medium**: Signature count validation (anomaly detection)
+3. **Low**: Account metadata validation (defense-in-depth)
+4. **Optional**: Message header validation (informational)
+
+The current implementation is **production-ready** with its existing security measures. The enhancements would bring it to **best-practice perfect** status.
diff --git a/docs/api-reference/claims/confirm.mdx b/docs/api-reference/claims/confirm.mdx
index 65761ff..464afb2 100644
--- a/docs/api-reference/claims/confirm.mdx
+++ b/docs/api-reference/claims/confirm.mdx
@@ -76,14 +76,17 @@ result = response.json()
The token address that was claimed from
-
- The user's associated token account that received the tokens
-
-
The amount of tokens that were successfully claimed
+
+ Array of recipients who received tokens from this claim, each containing:
+ - `wallet` (string): The recipient's wallet address
+ - `amount` (string): The amount allocated to this recipient
+ - `label` (string, optional): Description of the recipient (e.g., "Developer")
+
+
Blockchain confirmation details
@@ -95,8 +98,14 @@ result = response.json()
"success": true,
"transactionSignature": "5VfYiDvQMBSWxKJLa9NLPQ1x3...",
"tokenAddress": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
- "userTokenAccount": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
"claimAmount": "1000000",
+ "splitRecipients": [
+ {
+ "wallet": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM",
+ "amount": "900000",
+ "label": "Developer"
+ }
+ ],
"confirmation": {
"value": {
"err": null
diff --git a/docs/api-reference/claims/mint.mdx b/docs/api-reference/claims/mint.mdx
index ae05fff..5225744 100644
--- a/docs/api-reference/claims/mint.mdx
+++ b/docs/api-reference/claims/mint.mdx
@@ -83,14 +83,21 @@ result = response.json()
Unique identifier for this transaction (needed for confirmation)
-
- The associated token account address where tokens will be minted
-
-
The amount of tokens that will be claimed
+
+ Array of recipients who will receive tokens from this claim, each containing:
+ - `wallet` (string): The recipient's wallet address
+ - `amount` (string): The amount allocated to this recipient
+ - `label` (string, optional): Description of the recipient (e.g., "Developer")
+
+
+
+ The amount allocated to protocol fees (10% of total claim)
+
+
Number of decimal places for the token
@@ -105,9 +112,16 @@ result = response.json()
{
"success": true,
"transaction": "4MzR7dxJNJRVP1Q6k7Y3j8X...",
- "transactionKey": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v_9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM_1642248400000",
- "userTokenAccount": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
+ "transactionKey": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v_1642248400000_a1b2c3d4e5f6g7h8",
"claimAmount": "1000000",
+ "splitRecipients": [
+ {
+ "wallet": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM",
+ "amount": "900000",
+ "label": "Developer"
+ }
+ ],
+ "adminAmount": "100000",
"mintDecimals": 9,
"message": "Sign this transaction and submit to /claims/confirm"
}
diff --git a/ui/PR_REVIEW_GUIDE.md b/ui/PR_REVIEW_GUIDE.md
new file mode 100644
index 0000000..8a73b8b
--- /dev/null
+++ b/ui/PR_REVIEW_GUIDE.md
@@ -0,0 +1,546 @@
+# PR #3 Review Guide
+
+## Quick Review Checklist
+
+### Part 1: High-Level Review (5 minutes)
+- [ x] Review PR description on GitHub
+- [ x] Check commit messages make sense
+- [ x] Verify file changes look reasonable
+- [x] Check REFACTORING_VERIFICATION.md
+
+### Part 2: Refactoring Verification (10 minutes)
+- [ x] Verify route URLs unchanged
+- [ ] Check code compilation
+- [ ] Review new file structure
+- [ ] Verify no logic changes
+
+### Part 3: Emission Splits Feature Review (15 minutes)
+- [ ] Review emission split logic
+- [ ] Check authorization flow
+- [ ] Verify backwards compatibility
+- [ ] Review security considerations
+
+### Part 4: Testing (20 minutes)
+- [ ] Test with emission splits
+- [ ] Test without emission splits
+- [ ] Test unauthorized access
+- [ ] Verify split percentages
+
+---
+
+## Part 1: High-Level Review
+
+### 1.1 View the PR on GitHub
+```bash
+gh pr view 3 --web
+```
+
+**What to check:**
+- ✅ PR title describes the change
+- ✅ Description is comprehensive
+- ✅ All commits are logical and well-documented
+- ✅ Changes count looks reasonable (2,803 additions, 2,058 deletions)
+
+### 1.2 Review File Changes
+```bash
+# View all changed files
+gh pr diff 3 --name-only
+
+# Expected files:
+# - api-server.ts (modified)
+# - lib/db.ts (modified)
+# - lib/claimService.ts (new)
+# - lib/presaleService.ts (new)
+# - routes/claims.ts (new)
+# - routes/presale.ts (new)
+# - REFACTORING_VERIFICATION.md (new)
+```
+
+### 1.3 Read the Verification Document
+```bash
+cat REFACTORING_VERIFICATION.md
+```
+
+**What to check:**
+- ✅ All route URLs listed and verified
+- ✅ Critical logic patterns verified
+- ✅ Storage sharing documented
+- ✅ Compilation verified
+
+---
+
+## Part 2: Refactoring Verification
+
+### 2.1 Verify Code Compiles
+```bash
+# TypeScript compilation should pass
+npm run build 2>&1 | grep -i error
+
+# If no errors shown, compilation passed
+```
+
+### 2.2 Check Route Mapping
+```bash
+# Extract all routes from new files
+echo "=== CLAIMS ROUTES ==="
+grep "^router\.\(get\|post\)(" routes/claims.ts | sed 's/,.*$//'
+
+echo ""
+echo "=== PRESALE ROUTES ==="
+grep "^router\.\(get\|post\)(" routes/presale.ts | sed 's/,.*$//'
+
+echo ""
+echo "=== API-SERVER MOUNTS ==="
+grep "app.use.*Router" api-server.ts
+```
+
+**Expected output:**
+```
+Claims Routes:
+- router.get('/:tokenAddress' → /claims/:tokenAddress
+- router.post('/mint' → /claims/mint
+- router.post('/confirm' → /claims/confirm
+
+Presale Routes (mounted at /presale):
+- router.get('/:tokenAddress/claims/:wallet'
+- router.post('/:tokenAddress/claims/prepare'
+- router.post('/:tokenAddress/claims/confirm'
+- router.get('/:tokenAddress/stats'
+- router.get('/:tokenAddress/bids'
+- router.post('/:tokenAddress/bids'
+- router.post('/:tokenAddress/launch'
+- router.post('/:tokenAddress/launch-confirm'
+
+API Server Mounts:
+- app.use('/claims', claimsRouter);
+- app.use('/presale', presaleRouter);
+```
+
+### 2.3 Verify Imports/Exports
+```bash
+# Check claimService exports
+echo "=== CLAIM SERVICE EXPORTS ==="
+grep "^export" lib/claimService.ts
+
+# Check presaleService exports
+echo ""
+echo "=== PRESALE SERVICE EXPORTS ==="
+grep "^export" lib/presaleService.ts
+
+# Check claims routes imports from claimService
+echo ""
+echo "=== CLAIMS ROUTES IMPORTS ==="
+grep "from.*claimService" routes/claims.ts
+
+# Check presale routes imports from presaleService
+echo ""
+echo "=== PRESALE ROUTES IMPORTS ==="
+grep "from.*presaleService" routes/presale.ts
+```
+
+**What to verify:**
+- ✅ `claimTransactions` and `acquireClaimLock` exported from claimService
+- ✅ `presaleClaimTransactions` and `acquirePresaleClaimLock` exported from presaleService
+- ✅ All exports imported in respective routes files
+
+### 2.4 Verify No Duplicate Code
+```bash
+# Check api-server.ts doesn't have old handlers
+echo "Checking for leftover claim handlers in api-server.ts:"
+grep -c "'/claims/\(mint\|confirm\)'" api-server.ts || echo "✓ None found (expected)"
+
+echo ""
+echo "Checking for leftover presale handlers in api-server.ts:"
+grep -c "'/presale/:tokenAddress" api-server.ts || echo "✓ None found (expected)"
+```
+
+**Expected:** Both should return 0 or "None found"
+
+---
+
+## Part 3: Emission Splits Feature Review
+
+### 3.1 Review the Core Logic
+
+**File to review:** `routes/claims.ts`
+
+**Key sections to examine:**
+
+#### A. Split Distribution Logic (lines ~300-350)
+```bash
+# View the split calculation logic
+sed -n '290,360p' routes/claims.ts | grep -A20 "Query emission splits"
+```
+
+**What to check:**
+- ✅ Queries `getEmissionSplits()` to fetch splits
+- ✅ Calculates proportional amounts using `BigInt` math
+- ✅ Falls back to 100% creator if no splits
+- ✅ Creates token accounts for all recipients
+- ✅ Admin still gets 10% regardless of splits
+
+#### B. Authorization Logic (lines ~240-260)
+```bash
+# View the authorization check
+sed -n '230,270p' routes/claims.ts | grep -A10 "hasClaimRights"
+```
+
+**What to check:**
+- ✅ Uses `hasClaimRights()` instead of creator-only check
+- ✅ Allows any wallet with emission split OR creator
+- ✅ Rejects wallets without claim rights
+
+#### C. Transaction Creation (lines ~360-400)
+```bash
+# View transaction instruction creation
+sed -n '360,410p' routes/claims.ts | grep -B5 -A5 "createMintToInstruction"
+```
+
+**What to check:**
+- ✅ Loop creates instructions for each recipient
+- ✅ Each recipient gets proportional amount
+- ✅ Admin mint instruction added last
+- ✅ All amounts include decimals
+
+### 3.2 Review Database Functions
+
+**File to review:** `lib/db.ts`
+
+```bash
+# View the new emission split functions
+grep -A15 "export async function getWalletEmissionSplit" lib/db.ts
+grep -A15 "export async function hasRecentClaimByWallet" lib/db.ts
+grep -A15 "export async function getTotalClaimedByWallet" lib/db.ts
+```
+
+**What to check:**
+- ✅ `getWalletEmissionSplit()` - gets specific wallet's split
+- ✅ `hasRecentClaimByWallet()` - per-wallet cooldown check
+- ✅ `getTotalClaimedByWallet()` - per-wallet claim tracking
+
+**Note:** The last two are for future multi-signer work
+
+### 3.3 Check Backwards Compatibility
+
+```bash
+# Search for creator fallback logic
+grep -A10 "No splits configured" routes/claims.ts
+```
+
+**What to verify:**
+- ✅ When no splits exist, 100% goes to creator
+- ✅ Uses same token accounts as before
+- ✅ Same transaction structure for non-split tokens
+
+### 3.4 Security Review
+
+```bash
+# Check for security comments
+grep -n "CRITICAL SECURITY\|SECURITY:" routes/claims.ts | head -10
+```
+
+**Security aspects to verify:**
+- ✅ Only authorized wallets can initiate claims
+- ✅ Split validation happens at database level (PR #1)
+- ✅ Transaction metadata is immutable once created
+- ✅ All recipients receive tokens atomically
+- ✅ Admin always receives 10% protocol fee
+
+---
+
+## Part 4: Testing
+
+### 4.1 Setup Test Environment
+
+```bash
+# Start the API server
+npm run api:watch
+
+# In another terminal, prepare test data
+```
+
+### 4.2 Test Scenario 1: Claim with Emission Splits
+
+**Setup:**
+```sql
+-- Connect to your database
+-- Insert test splits for a token
+INSERT INTO emission_splits (token_address, recipient_wallet, split_percentage, label, created_at)
+VALUES
+ ('YOUR_TEST_TOKEN', 'CREATOR_WALLET_ADDRESS', 70.00, 'Creator', NOW()),
+ ('YOUR_TEST_TOKEN', 'TEAM_WALLET_ADDRESS', 30.00, 'Team Member', NOW());
+```
+
+**Test:**
+```bash
+# 1. Create mint transaction (as creator)
+curl -X POST http://localhost:3001/claims/mint \
+ -H "Content-Type: application/json" \
+ -d '{
+ "tokenAddress": "YOUR_TEST_TOKEN",
+ "userWallet": "CREATOR_WALLET_ADDRESS",
+ "claimAmount": "1000"
+ }'
+
+# Expected response:
+# {
+# "success": true,
+# "transaction": "...",
+# "splitRecipients": [
+# {"wallet": "CREATOR_WALLET_ADDRESS", "amount": "630", "label": "Creator"},
+# {"wallet": "TEAM_WALLET_ADDRESS", "amount": "270", "label": "Team Member"}
+# ],
+# "adminAmount": "100"
+# }
+```
+
+**Verify:**
+- ✅ Response includes `splitRecipients` array
+- ✅ Creator gets 630 tokens (70% of 900)
+- ✅ Team gets 270 tokens (30% of 900)
+- ✅ Admin gets 100 tokens (10% of 1000)
+- ✅ Total = 1000 tokens
+
+**Test variation: Claim initiated by team member**
+```bash
+# 2. Create mint transaction (as team member)
+curl -X POST http://localhost:3001/claims/mint \
+ -H "Content-Type: application/json" \
+ -d '{
+ "tokenAddress": "YOUR_TEST_TOKEN",
+ "userWallet": "TEAM_WALLET_ADDRESS",
+ "claimAmount": "1000"
+ }'
+
+# Expected: Same distribution (70/30 split)
+```
+
+**Verify:**
+- ✅ Team member can initiate claim
+- ✅ Distribution is still 70/30 (not 30/70)
+- ✅ Creator gets 630, team gets 270
+
+### 4.3 Test Scenario 2: Claim WITHOUT Emission Splits
+
+**Setup:**
+```sql
+-- Use a token with NO emission splits
+-- Or delete the splits from test token
+DELETE FROM emission_splits WHERE token_address = 'YOUR_TEST_TOKEN';
+```
+
+**Test:**
+```bash
+curl -X POST http://localhost:3001/claims/mint \
+ -H "Content-Type: application/json" \
+ -d '{
+ "tokenAddress": "YOUR_TEST_TOKEN",
+ "userWallet": "CREATOR_WALLET_ADDRESS",
+ "claimAmount": "1000"
+ }'
+
+# Expected response:
+# {
+# "success": true,
+# "transaction": "...",
+# "splitRecipients": [
+# {"wallet": "CREATOR_WALLET_ADDRESS", "amount": "900", "label": "Creator"}
+# ],
+# "adminAmount": "100"
+# }
+```
+
+**Verify:**
+- ✅ Falls back to 100% creator
+- ✅ Creator gets 900 tokens (90% of 1000)
+- ✅ Admin gets 100 tokens (10% of 1000)
+- ✅ Backwards compatible behavior
+
+### 4.4 Test Scenario 3: Unauthorized Access
+
+**Test:**
+```bash
+# Try to claim with random wallet (not creator, not in splits)
+curl -X POST http://localhost:3001/claims/mint \
+ -H "Content-Type: application/json" \
+ -d '{
+ "tokenAddress": "YOUR_TEST_TOKEN",
+ "userWallet": "RANDOM_WALLET_ADDRESS",
+ "claimAmount": "1000"
+ }'
+
+# Expected response:
+# {
+# "error": "You do not have claim rights for this token"
+# }
+```
+
+**Verify:**
+- ✅ Request is rejected
+- ✅ Returns 403 status code
+- ✅ Error message is clear
+
+### 4.5 Test Scenario 4: Split Percentage Validation
+
+**Setup:**
+```sql
+-- Try to insert splits that exceed 100%
+INSERT INTO emission_splits (token_address, recipient_wallet, split_percentage, label)
+VALUES
+ ('TEST_TOKEN', 'WALLET_A', 70.00, 'A'),
+ ('TEST_TOKEN', 'WALLET_B', 40.00, 'B'); -- Total = 110%, should fail
+```
+
+**Verify:**
+- ✅ Database trigger rejects this (from PR #1)
+- ✅ Error message indicates percentage validation failed
+
+---
+
+## Part 5: Code Quality Review
+
+### 5.1 Check Code Style
+
+```bash
+# Run linter (if available)
+npm run lint 2>&1 | grep -i "error\|warning" | head -20
+```
+
+### 5.2 Check for TODOs or FIXMEs
+```bash
+# Search for unresolved TODOs
+grep -rn "TODO\|FIXME\|XXX\|HACK" lib/claimService.ts lib/presaleService.ts routes/claims.ts routes/presale.ts
+```
+
+**Expected:** None, or only intentional ones
+
+### 5.3 Check Error Handling
+```bash
+# Verify all error responses have proper format
+grep -c "const errorResponse = { error:" routes/claims.ts
+grep -c "res.status.*json(errorResponse)" routes/claims.ts
+```
+
+**Verify:**
+- ✅ All errors have consistent format
+- ✅ All errors return proper status codes
+- ✅ All errors are logged
+
+---
+
+## Part 6: Final Verification
+
+### 6.1 Compare with Original
+```bash
+# Check the original implementation (first commit)
+git show da8deb5:ui/api-server.ts | grep -A20 "emission splits" | head -25
+
+# Compare with current routes/claims.ts
+grep -A20 "emission splits" routes/claims.ts | head -25
+```
+
+**Verify:**
+- ✅ Logic is identical (only moved, not changed)
+
+### 6.2 Check Git History
+```bash
+# View all commits in the PR
+git log --oneline main..emission-split-claim-logic
+
+# Expected:
+# b2757f6 refactor: extract claims and presale routes to separate modules
+# 91a9885 Merge branch 'main' of github.com:zcombinatorio/zcombinator into emission-split-claim-logic
+# da8deb5 feat: implement emission splits in claim logic
+```
+
+### 6.3 Review Commit Messages
+```bash
+# View detailed commit messages
+git log --format=fuller main..emission-split-claim-logic
+```
+
+**What to check:**
+- ✅ Commits follow conventional commit format
+- ✅ Messages are clear and descriptive
+- ✅ Co-authored by Claude (for automated commits)
+
+---
+
+## Review Decision Matrix
+
+### ✅ APPROVE if:
+- [ ] All tests pass
+- [ ] Refactoring verified (no logic changes)
+- [ ] Emission splits work correctly with splits
+- [ ] Backwards compatible (works without splits)
+- [ ] Authorization works (rejects unauthorized)
+- [ ] Code quality is good
+- [ ] No security concerns
+- [ ] Documentation is complete
+
+### ⚠️ REQUEST CHANGES if:
+- [ ] Tests fail
+- [ ] Logic changes detected in refactoring
+- [ ] Security vulnerabilities found
+- [ ] Backwards compatibility broken
+- [ ] Code quality issues
+
+### 💬 COMMENT if:
+- [ ] Minor suggestions
+- [ ] Documentation improvements needed
+- [ ] Questions about implementation choices
+
+---
+
+## Quick Commands Summary
+
+```bash
+# View PR
+gh pr view 3 --web
+
+# Check compilation
+npm run build 2>&1 | grep -i error
+
+# View refactoring verification
+cat REFACTORING_VERIFICATION.md
+
+# Test API
+curl -X POST http://localhost:3001/claims/mint \
+ -H "Content-Type: application/json" \
+ -d '{"tokenAddress":"TOKEN","userWallet":"WALLET","claimAmount":"1000"}'
+
+# View specific sections
+sed -n '290,360p' routes/claims.ts # Split logic
+sed -n '230,270p' routes/claims.ts # Authorization
+
+# Check for issues
+grep -rn "TODO\|FIXME" lib/ routes/
+npm run lint
+```
+
+---
+
+## Time Estimate
+
+- **Quick Review:** 15-20 minutes (checklist + verification doc)
+- **Thorough Review:** 45-60 minutes (includes testing)
+- **Deep Dive Review:** 2-3 hours (includes all testing scenarios)
+
+---
+
+## Need Help?
+
+If you find issues or have questions:
+
+1. **Leave PR comments** on specific lines
+2. **Request changes** with clear explanation
+3. **Ask questions** in PR conversation
+4. **Test locally** if uncertain about behavior
+
+---
+
+## Additional Resources
+
+- **REFACTORING_VERIFICATION.md** - Detailed refactoring verification
+- **PR #1** - Database foundation (already merged)
+- **CONTRIBUTING.md** - Contribution guidelines
diff --git a/ui/REFACTORING_VERIFICATION.md b/ui/REFACTORING_VERIFICATION.md
new file mode 100644
index 0000000..d5494ab
--- /dev/null
+++ b/ui/REFACTORING_VERIFICATION.md
@@ -0,0 +1,237 @@
+# Refactoring Verification Report
+**Date:** 2025-10-24
+**Type:** Claims and Presale Route Extraction
+
+## Executive Summary
+✅ **VERIFIED: NO LOGIC CHANGES**
+- All code moved verbatim from api-server.ts to new modules
+- All route URLs remain identical
+- All business logic preserved exactly
+- TypeScript compilation passes
+- In-memory storage properly shared
+
+---
+
+## File Changes Summary
+
+### Removed from api-server.ts: 2,090 lines
+- ClaimTransaction interface and storage → `lib/claimService.ts`
+- acquireClaimLock function → `lib/claimService.ts`
+- 3 claim route handlers → `routes/claims.ts`
+- PresaleClaimTransaction interfaces → `lib/presaleService.ts`
+- acquirePresaleClaimLock function → `lib/presaleService.ts`
+- 8 presale route handlers → `routes/presale.ts`
+
+### Added to api-server.ts: 13 lines
+```typescript
+import claimsRouter from './routes/claims';
+import presaleRouter from './routes/presale';
+import { hasRecentClaimByWallet, getTotalClaimedByWallet, getWalletEmissionSplit } from './lib/db';
+
+app.use('/claims', claimsRouter);
+app.use('/presale', presaleRouter);
+
+// Claims routes have been moved to routes/claims.ts
+// Presale routes have been moved to routes/presale.ts
+```
+
+### New Files Created
+- `lib/claimService.ts` (182 lines)
+- `lib/presaleService.ts` (139 lines)
+- `routes/claims.ts` (929 lines)
+- `routes/presale.ts` (1,232 lines)
+
+---
+
+## Route Verification
+
+### Claims Routes
+| Original | New | Status |
+|----------|-----|--------|
+| `app.get('/claims/:tokenAddress', getClaimInfo)` | `router.get('/:tokenAddress')` mounted at `/claims` | ✅ IDENTICAL |
+| `app.post('/claims/mint', createMintTransaction)` | `router.post('/mint')` mounted at `/claims` | ✅ IDENTICAL |
+| `app.post('/claims/confirm', confirmClaim)` | `router.post('/confirm')` mounted at `/claims` | ✅ IDENTICAL |
+
+**Final URLs:** `/claims/:tokenAddress`, `/claims/mint`, `/claims/confirm`
+
+### Presale Routes
+| Original | New | Status |
+|----------|-----|--------|
+| `app.get('/presale/:tokenAddress/claims/:wallet')` | `router.get('/:tokenAddress/claims/:wallet')` at `/presale` | ✅ IDENTICAL |
+| `app.post('/presale/:tokenAddress/claims/prepare')` | `router.post('/:tokenAddress/claims/prepare')` at `/presale` | ✅ IDENTICAL |
+| `app.post('/presale/:tokenAddress/claims/confirm')` | `router.post('/:tokenAddress/claims/confirm')` at `/presale` | ✅ IDENTICAL |
+| `app.get('/presale/:tokenAddress/stats')` | `router.get('/:tokenAddress/stats')` at `/presale` | ✅ IDENTICAL |
+| `app.get('/presale/:tokenAddress/bids')` | `router.get('/:tokenAddress/bids')` at `/presale` | ✅ IDENTICAL |
+| `app.post('/presale/:tokenAddress/bids')` | `router.post('/:tokenAddress/bids')` at `/presale` | ✅ IDENTICAL |
+| `app.post('/presale/:tokenAddress/launch')` | `router.post('/:tokenAddress/launch')` at `/presale` | ✅ IDENTICAL |
+| `app.post('/presale/:tokenAddress/launch-confirm')` | `router.post('/:tokenAddress/launch-confirm')` at `/presale` | ✅ IDENTICAL |
+
+---
+
+## Critical Logic Verification
+
+### ✅ 90/10 Split Calculation
+```typescript
+// IDENTICAL in routes/claims.ts
+const claimersTotal = (requestedAmount * BigInt(9)) / BigInt(10);
+const adminAmount = requestedAmount - claimersTotal;
+```
+
+### ✅ Lock Mechanism
+```typescript
+// lib/claimService.ts
+export async function acquireClaimLock(token: string): Promise<() => void> {
+ const key = token.toLowerCase();
+ while (claimLocks.has(key)) {
+ await claimLocks.get(key);
+ }
+ // ... [identical logic]
+}
+```
+
+### ✅ Transaction Signing
+```typescript
+// IDENTICAL in routes/claims.ts
+transaction.partialSign(protocolKeypair);
+```
+
+### ✅ Error Handling
+```typescript
+// IDENTICAL pattern preserved
+const errorResponse = { error: 'RPC_URL not configured' };
+return res.status(500).json(errorResponse);
+```
+
+---
+
+## In-Memory Storage Verification
+
+### Claims Storage
+```typescript
+// lib/claimService.ts
+export const claimTransactions = new Map();
+const claimLocks = new Map>();
+
+// routes/claims.ts
+import { claimTransactions, acquireClaimLock } from '../lib/claimService';
+```
+✅ **VERIFIED:** Same Map instances shared across modules
+
+### Presale Storage
+```typescript
+// lib/presaleService.ts
+export const presaleClaimTransactions = new Map();
+export const presaleLaunchTransactions = new Map();
+
+// routes/presale.ts
+import { presaleClaimTransactions, presaleLaunchTransactions } from '../lib/presaleService';
+```
+✅ **VERIFIED:** Same Map instances shared across modules
+
+---
+
+## Database Function Usage
+
+### Claims Routes Database Calls (Preserved)
+- `getTokenLaunchTime()` ✅
+- `hasRecentClaim()` ✅
+- `preRecordClaim()` ✅
+- `getTokenCreatorWallet()` ✅
+- `getDesignatedClaimByToken()` ✅
+- `getVerifiedClaimWallets()` ✅
+- `getEmissionSplits()` ✅
+- `hasClaimRights()` ✅
+
+### Presale Routes Database Calls (Preserved)
+- `getPresaleByTokenAddress()` ✅
+- `getUserPresaleContribution()` ✅
+- `getPresaleBids()` ✅
+- `getTotalPresaleBids()` ✅
+- `recordPresaleBid()` ✅
+- `getPresaleBidBySignature()` ✅
+- `updatePresaleStatus()` ✅
+
+---
+
+## Environment Variable Usage
+
+✅ **IDENTICAL:** All env vars used in same way:
+- `process.env.RPC_URL` - verified in claims routes
+- `process.env.PROTOCOL_PRIVATE_KEY` - verified in claims routes
+- `process.env.ADMIN_WALLET` - verified in claims routes
+
+---
+
+## TypeScript Compilation
+
+```bash
+$ npx tsc --noEmit
+# ✅ NO ERRORS
+```
+
+---
+
+## Critical Bug Fixed During Refactor
+
+**Issue:** Presale routes had `/presale/` prefix while router mounted at `/presale`
+**Impact:** Would have created `/presale/presale/...` URLs
+**Fix:** Removed `/presale/` prefix from all presale routes
+**Status:** ✅ FIXED - URLs now match original exactly
+
+---
+
+## Remaining Code in api-server.ts
+
+✅ **VERIFIED:** Only non-claim/presale code remains:
+- Health check endpoint
+- Launch endpoints (2)
+- Token verification endpoint
+- Base middleware and setup
+
+The single `/presale/` reference found is in rate limiter skip logic (intentional):
+```typescript
+skip: (req) => req.path.includes('/presale/') && req.path.includes('/claims')
+```
+
+---
+
+## Final Verification Checklist
+
+- [✅] All route URLs identical
+- [✅] All handler logic preserved
+- [✅] All imports/exports correct
+- [✅] In-memory storage shared properly
+- [✅] Lock mechanisms preserved
+- [✅] Error handling identical
+- [✅] Database calls preserved
+- [✅] Environment variables used identically
+- [✅] TypeScript compiles
+- [✅] No handlers left in api-server.ts (except launch/health/verify)
+- [✅] Security comments preserved
+- [✅] Logging logic identical
+- [✅] Transaction signing logic preserved
+
+---
+
+## Conclusion
+
+✅ **REFACTORING IS SAFE AND CORRECT**
+
+**Zero logic changes** - only organizational improvements:
+- Code moved verbatim from api-server.ts to new modules
+- Only changes: function signatures (app → router, added exports)
+- All functionality preserved exactly
+- API behaves identically to before
+- Code is now more maintainable and testable
+
+**Ready for production deployment**
+
+---
+
+## Files Modified
+- `api-server.ts` (2427 → 427 lines, -82%)
+- New: `lib/claimService.ts`
+- New: `lib/presaleService.ts`
+- New: `routes/claims.ts`
+- New: `routes/presale.ts`
+
diff --git a/ui/api-server.ts b/ui/api-server.ts
index c2f5dfa..edb1bb9 100644
--- a/ui/api-server.ts
+++ b/ui/api-server.ts
@@ -50,9 +50,13 @@ import {
confirmAndRecordLaunch,
generateTokenKeypair
} from './lib/launchService';
+import claimsRouter from './routes/claims';
+import presaleRouter from './routes/presale';
import {
getTokenLaunchTime,
hasRecentClaim,
+ hasRecentClaimByWallet,
+ getTotalClaimedByWallet,
preRecordClaim,
getTokenCreatorWallet,
getDesignatedClaimByToken,
@@ -62,7 +66,10 @@ import {
getPresaleBids,
getTotalPresaleBids,
recordPresaleBid,
- getPresaleBidBySignature
+ getPresaleBidBySignature,
+ getEmissionSplits,
+ getWalletEmissionSplit,
+ hasClaimRights
} from './lib/db';
import { calculateClaimEligibility } from './lib/helius';
import {
@@ -90,45 +97,6 @@ const PORT = process.env.API_PORT || 3001;
// Maps baseMint public key -> private key
const baseMintKeypairs = new Map();
-// In-memory storage for claim transactions
-// Maps "token:timestamp" -> claim data (token-based to prevent multi-wallet exploits)
-interface ClaimTransaction {
- tokenAddress: string;
- userWallet: string;
- claimAmount: string;
- userTokenAccount: string;
- mintDecimals: number;
- timestamp: number;
-}
-const claimTransactions = new Map();
-
-// Mutex locks for preventing concurrent claim processing
-// Maps token address -> Promise that resolves when processing is done
-// Lock is per-token since claim eligibility is global per token
-const claimLocks = new Map>();
-
-async function acquireClaimLock(token: string): Promise<() => void> {
- const key = token.toLowerCase();
-
- // Wait for any existing lock to be released
- while (claimLocks.has(key)) {
- await claimLocks.get(key);
- }
-
- // Create a new lock
- let releaseLock: () => void;
- const lockPromise = new Promise((resolve) => {
- releaseLock = resolve;
- });
-
- claimLocks.set(key, lockPromise);
-
- // Return the release function
- return () => {
- claimLocks.delete(key);
- releaseLock();
- };
-}
const limiter = rateLimit({
windowMs: 2 * 60 * 1000, // 2 minutes
@@ -184,6 +152,12 @@ app.get('/health', (_req: Request, res: Response) => {
});
});
+// Mount claims routes
+app.use('/claims', claimsRouter);
+
+// Mount presale routes
+app.use('/presale', presaleRouter);
+
// Launch token endpoint - returns unsigned transaction
app.post('/launch', async (req: Request, res: Response) => {
try {
@@ -319,2023 +293,9 @@ app.post('/confirm-launch', async (req: Request, res: Response) => {
}
});
-// Get claim eligibility info for a wallet and token
-const getClaimInfo = async (req: Request, res: Response) => {
- try {
- const { tokenAddress } = req.params;
- const walletAddress = req.query.wallet as string;
-
- if (!walletAddress) {
- return res.status(400).json({
- error: 'Wallet address is required'
- });
- }
-
- // Get token launch time from database
- const tokenLaunchTime = await getTokenLaunchTime(tokenAddress);
-
- if (!tokenLaunchTime) {
- return res.status(404).json({
- error: 'Token not found'
- });
- }
-
- // Get claim data from on-chain with DB launch time
- const claimData = await calculateClaimEligibility(tokenAddress, tokenLaunchTime);
-
- const timeUntilNextClaim = Math.max(0, claimData.nextInflationTime.getTime() - new Date().getTime());
-
- res.json({
- walletAddress,
- tokenAddress,
- totalClaimed: claimData.totalClaimed.toString(),
- availableToClaim: claimData.availableToClaim.toString(),
- maxClaimableNow: claimData.maxClaimableNow.toString(),
- tokensPerPeriod: '1000000',
- inflationPeriods: claimData.inflationPeriods,
- tokenLaunchTime,
- nextInflationTime: claimData.nextInflationTime,
- canClaimNow: claimData.canClaimNow,
- timeUntilNextClaim,
- });
- } catch (error) {
- console.error('Error fetching claim info:', error);
- res.status(500).json({
- error: 'Failed to fetch claim information'
- });
- }
-};
-
-app.get('/claims/:tokenAddress', getClaimInfo);
-
-// Create unsigned mint transaction for claiming
-const createMintTransaction = async (req: Request, MintClaimResponseBody | ErrorResponseBody, MintClaimRequestBody>, res: Response) => {
- try {
- console.log("claim/mint request body:", req.body);
- const { tokenAddress, userWallet, claimAmount } = req.body;
- console.log("mint request", tokenAddress, userWallet, claimAmount);
-
- // Validate required environment variables
- const RPC_URL = process.env.RPC_URL;
- const PROTOCOL_PRIVATE_KEY = process.env.PROTOCOL_PRIVATE_KEY;
- const ADMIN_WALLET = process.env.ADMIN_WALLET || 'PLACEHOLDER_ADMIN_WALLET';
-
- if (!RPC_URL) {
- const errorResponse = { error: 'RPC_URL not configured' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(500).json(errorResponse);
- }
-
- if (!PROTOCOL_PRIVATE_KEY) {
- const errorResponse = { error: 'PROTOCOL_PRIVATE_KEY not configured' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(500).json(errorResponse);
- }
-
- if (!ADMIN_WALLET || ADMIN_WALLET === 'PLACEHOLDER_ADMIN_WALLET') {
- const errorResponse = { error: 'ADMIN_WALLET not configured' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(500).json(errorResponse);
- }
-
- // Validate required parameters
- if (!tokenAddress || !userWallet || !claimAmount) {
- const errorResponse = { error: 'Missing required parameters' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Initialize connection
- const connection = new Connection(RPC_URL, "confirmed");
- const protocolKeypair = Keypair.fromSecretKey(bs58.decode(PROTOCOL_PRIVATE_KEY));
- const tokenMint = new PublicKey(tokenAddress);
- const userPublicKey = new PublicKey(userWallet);
- const adminPublicKey = new PublicKey(ADMIN_WALLET);
-
- // Get token launch time from database
- const tokenLaunchTime = await getTokenLaunchTime(tokenAddress);
-
- if (!tokenLaunchTime) {
- const errorResponse = { error: 'Token not found' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(404).json(errorResponse);
- }
-
- // Validate claim amount input
- if (!claimAmount || typeof claimAmount !== 'string') {
- const errorResponse = { error: 'Invalid claim amount: must be a string' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- if (!/^\d+$/.test(claimAmount)) {
- const errorResponse = { error: 'Invalid claim amount: must contain only digits' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- const requestedAmount = BigInt(claimAmount);
-
- // Check for valid amount bounds
- if (requestedAmount <= BigInt(0)) {
- const errorResponse = { error: 'Invalid claim amount: must be greater than 0' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- if (requestedAmount > BigInt(Number.MAX_SAFE_INTEGER)) {
- const errorResponse = { error: 'Invalid claim amount: exceeds maximum safe value' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Calculate 90/10 split (developer gets 90%, admin gets 10%)
- const developerAmount = (requestedAmount * BigInt(9)) / BigInt(10);
- const adminAmount = requestedAmount - developerAmount; // Ensures total equals exactly requestedAmount
-
- // Validate claim eligibility from on-chain data
- const claimEligibility = await calculateClaimEligibility(tokenAddress, tokenLaunchTime);
-
- if (requestedAmount > claimEligibility.availableToClaim) {
- const errorResponse = { error: 'Requested amount exceeds available claim amount' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Check if this is a designated token and validate the claimer
- const designatedClaim = await getDesignatedClaimByToken(tokenAddress);
-
- if (designatedClaim) {
- // This is a designated token
- const { verifiedWallet, embeddedWallet, originalLauncher } = await getVerifiedClaimWallets(tokenAddress);
-
- // Block the original launcher
- if (userWallet === originalLauncher) {
- const errorResponse = { error: 'This token has been designated to someone else. The designated user must claim it.' };
- console.log("claim/mint error response: Original launcher blocked from claiming designated token");
- return res.status(403).json(errorResponse);
- }
-
- // Check if the current user is authorized
- if (verifiedWallet || embeddedWallet) {
- if (userWallet !== verifiedWallet && userWallet !== embeddedWallet) {
- const errorResponse = { error: 'Only the verified designated user can claim this token' };
- console.log("claim/mint error response: Unauthorized wallet attempting to claim designated token");
- return res.status(403).json(errorResponse);
- }
- } else {
- const errorResponse = { error: 'The designated user must verify their social accounts before claiming' };
- console.log("claim/mint error response: Designated user not yet verified");
- return res.status(403).json(errorResponse);
- }
- } else {
- // Normal token - only creator can claim
- const creatorWallet = await getTokenCreatorWallet(tokenAddress);
- if (!creatorWallet) {
- const errorResponse = { error: 'Token creator not found' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- if (userWallet !== creatorWallet.trim()) {
- const errorResponse = { error: 'Only the token creator can claim rewards' };
- console.log("claim/mint error response: Non-creator attempting to claim");
- return res.status(403).json(errorResponse);
- }
- }
-
- // User can claim now if they have available tokens to claim
- if (claimEligibility.availableToClaim <= BigInt(0)) {
- const errorResponse = {
- error: 'No tokens available to claim yet',
- nextInflationTime: claimEligibility.nextInflationTime
- };
- console.log("claim/mint error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Get mint info to calculate amount with decimals
- const mintInfo = await getMint(connection, tokenMint);
- const decimals = mintInfo.decimals;
- const developerAmountWithDecimals = developerAmount * BigInt(10 ** decimals);
- const adminAmountWithDecimals = adminAmount * BigInt(10 ** decimals);
-
- // Verify protocol has mint authority
- if (!mintInfo.mintAuthority || !mintInfo.mintAuthority.equals(protocolKeypair.publicKey)) {
- const errorResponse = { error: 'Protocol does not have mint authority for this token' };
- console.log("claim/mint error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Get associated token account addresses (no creation yet)
- const userTokenAccount = await getAssociatedTokenAddress(
- tokenMint,
- userPublicKey
- );
-
- const adminTokenAccount = await getAssociatedTokenAddress(
- tokenMint,
- adminPublicKey,
- true // allowOwnerOffCurve
- );
-
- // Create mint transaction
- const transaction = new Transaction();
-
- // Add idempotent instructions to create token accounts if needed
- // User pays for both accounts
- const createUserAccountInstruction = createAssociatedTokenAccountIdempotentInstruction(
- userPublicKey, // payer (user pays for their own account)
- userTokenAccount,
- userPublicKey, // owner
- tokenMint
- );
-
- const createAdminAccountInstruction = createAssociatedTokenAccountIdempotentInstruction(
- userPublicKey, // payer (user pays for admin account too)
- adminTokenAccount,
- adminPublicKey, // owner
- tokenMint
- );
-
- transaction.add(createUserAccountInstruction);
- transaction.add(createAdminAccountInstruction);
-
- // Add mint instruction for developer (90%)
- const developerMintInstruction = createMintToInstruction(
- tokenMint,
- userTokenAccount,
- protocolKeypair.publicKey,
- developerAmountWithDecimals
- );
-
- // Add mint instruction for admin (10%)
- const adminMintInstruction = createMintToInstruction(
- tokenMint,
- adminTokenAccount,
- protocolKeypair.publicKey,
- adminAmountWithDecimals
- );
-
- transaction.add(developerMintInstruction);
- transaction.add(adminMintInstruction);
-
- // Get latest blockhash and set fee payer to user
- const { blockhash } = await connection.getLatestBlockhash("confirmed");
- transaction.recentBlockhash = blockhash;
- transaction.feePayer = userPublicKey;
-
- // Clean up old transactions FIRST (older than 5 minutes) to prevent race conditions
- const fiveMinutesAgo = Date.now() - (5 * 60 * 1000);
- for (const [key, data] of claimTransactions.entries()) {
- if (data.timestamp < fiveMinutesAgo) {
- claimTransactions.delete(key);
- }
- }
-
- // Create a unique key for this transaction with random component to prevent collisions
- const transactionKey = `${tokenAddress}_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
-
- // Store transaction data for later confirmation
- claimTransactions.set(transactionKey, {
- tokenAddress,
- userWallet,
- claimAmount,
- userTokenAccount: userTokenAccount.toString(),
- mintDecimals: decimals,
- timestamp: Date.now()
- });
-
- // Store split amounts and admin account for validation in confirm endpoint
- const transactionMetadata = {
- developerAmount: developerAmount.toString(),
- adminAmount: adminAmount.toString(),
- adminTokenAccount: adminTokenAccount.toString()
- };
- claimTransactions.set(`${transactionKey}_metadata`, transactionMetadata as any);
-
- // Serialize transaction for user to sign
- const serializedTransaction = transaction.serialize({
- requireAllSignatures: false
- });
-
- const successResponse = {
- success: true as const,
- transaction: bs58.encode(serializedTransaction),
- transactionKey,
- userTokenAccount: userTokenAccount.toString(),
- claimAmount: requestedAmount.toString(),
- developerAmount: developerAmount.toString(),
- adminAmount: adminAmount.toString(),
- mintDecimals: decimals,
- message: 'Sign this transaction and submit to /claims/confirm'
- };
-
- console.log("claim/mint successful response:", successResponse);
- res.json(successResponse);
-
- } catch (error) {
- console.error('Mint transaction creation error:', error);
- const errorResponse = {
- error: 'Failed to create mint transaction',
- details: error instanceof Error ? error.message : 'Unknown error'
- };
- console.log("claim/mint error response:", errorResponse);
- res.status(500).json(errorResponse);
- }
-};
-
-app.post('/claims/mint', createMintTransaction);
-
-// Confirm claim - receives user-signed tx, adds protocol signature, and submits
-const confirmClaim = async (req: Request, ConfirmClaimResponseBody | ErrorResponseBody, ConfirmClaimRequestBody>, res: Response) => {
- let releaseLock: (() => void) | null = null;
-
- try {
- console.log("claim/confirm request body:", req.body);
- const { signedTransaction, transactionKey } = req.body;
-
- // Validate required parameters
- if (!signedTransaction || !transactionKey) {
- const errorResponse = { error: 'Missing required fields: signedTransaction and transactionKey' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Retrieve the transaction data from memory
- const claimData = claimTransactions.get(transactionKey);
- if (!claimData) {
- const errorResponse = { error: 'Transaction data not found. Please call /claims/mint first.' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Retrieve the metadata with split amounts
- const metadata = claimTransactions.get(`${transactionKey}_metadata`) as any;
- if (!metadata) {
- const errorResponse = { error: 'Transaction metadata not found. Please call /claims/mint first.' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Acquire lock IMMEDIATELY after getting claim data to prevent race conditions
- releaseLock = await acquireClaimLock(claimData.tokenAddress);
-
- // Check if ANY user has claimed this token recently
- const hasRecent = await hasRecentClaim(claimData.tokenAddress, 360);
- if (hasRecent) {
- const errorResponse = { error: 'This token has been claimed recently. Please wait before claiming again.' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Pre-record the claim in database for audit trail
- // Global token lock prevents race conditions
- await preRecordClaim(
- claimData.userWallet,
- claimData.tokenAddress,
- claimData.claimAmount
- );
-
- // Validate required environment variables
- const RPC_URL = process.env.RPC_URL;
- const PROTOCOL_PRIVATE_KEY = process.env.PROTOCOL_PRIVATE_KEY;
- const ADMIN_WALLET = process.env.ADMIN_WALLET || 'PLACEHOLDER_ADMIN_WALLET';
-
- if (!RPC_URL || !PROTOCOL_PRIVATE_KEY) {
- const errorResponse = { error: 'Server configuration error' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(500).json(errorResponse);
- }
-
- if (!ADMIN_WALLET || ADMIN_WALLET === 'PLACEHOLDER_ADMIN_WALLET') {
- const errorResponse = { error: 'ADMIN_WALLET not configured' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(500).json(errorResponse);
- }
-
- // Initialize connection and keypair
- const connection = new Connection(RPC_URL, "confirmed");
- const protocolKeypair = Keypair.fromSecretKey(bs58.decode(PROTOCOL_PRIVATE_KEY));
-
- // Re-validate claim eligibility (security check)
- const tokenLaunchTime = await getTokenLaunchTime(claimData.tokenAddress);
- if (!tokenLaunchTime) {
- const errorResponse = { error: 'Token not found' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(404).json(errorResponse);
- }
-
- const claimEligibility = await calculateClaimEligibility(
- claimData.tokenAddress,
- tokenLaunchTime
- );
-
- const requestedAmount = BigInt(claimData.claimAmount);
- if (requestedAmount > claimEligibility.availableToClaim) {
- const errorResponse = { error: 'Claim eligibility has changed. Requested amount exceeds available claim amount.' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- if (claimEligibility.availableToClaim <= BigInt(0)) {
- const errorResponse = { error: 'No tokens available to claim anymore' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Check if this token has a designated claim
- const designatedClaim = await getDesignatedClaimByToken(claimData.tokenAddress);
-
- let authorizedClaimWallet: string | null = null;
- let isDesignated = false;
-
- if (designatedClaim) {
- // This is a designated token
- isDesignated = true;
-
- // Check if the designated user has verified their account
- const { verifiedWallet, embeddedWallet, originalLauncher } = await getVerifiedClaimWallets(claimData.tokenAddress);
-
- // Block the original launcher from claiming designated tokens
- if (claimData.userWallet === originalLauncher) {
- const errorResponse = { error: 'This token has been designated to someone else. The designated user must claim it.' };
- console.log("claim/confirm error response: Original launcher blocked from claiming designated token");
- return res.status(403).json(errorResponse);
- }
-
- // Check if the current user is authorized to claim
- if (verifiedWallet || embeddedWallet) {
- // Allow either the verified wallet or embedded wallet to claim
- if (claimData.userWallet === verifiedWallet || claimData.userWallet === embeddedWallet) {
- authorizedClaimWallet = claimData.userWallet;
- console.log("Designated user authorized to claim:", { userWallet: claimData.userWallet, verifiedWallet, embeddedWallet });
- } else {
- const errorResponse = { error: 'Only the verified designated user can claim this token' };
- console.log("claim/confirm error response: Unauthorized wallet attempting to claim designated token");
- return res.status(403).json(errorResponse);
- }
- } else {
- // Designated user hasn't verified yet
- const errorResponse = { error: 'The designated user must verify their social accounts before claiming' };
- console.log("claim/confirm error response: Designated user not yet verified");
- return res.status(403).json(errorResponse);
- }
- } else {
- // Normal token - only creator can claim
- const rawCreatorWallet = await getTokenCreatorWallet(claimData.tokenAddress);
- console.log("Retrieved creatorWallet from database:", { rawCreatorWallet, type: typeof rawCreatorWallet, length: rawCreatorWallet?.length });
-
- if (!rawCreatorWallet) {
- const errorResponse = { error: 'Token creator not found' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Clean and validate the creator wallet string
- const creatorWallet = rawCreatorWallet.trim();
- console.log("Cleaned creatorWallet:", { creatorWallet, length: creatorWallet.length });
-
- if (!creatorWallet || creatorWallet.length < 32 || creatorWallet.length > 44) {
- const errorResponse = { error: 'Invalid creator wallet format in database' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // For non-designated tokens, only the original creator can claim
- if (claimData.userWallet !== creatorWallet) {
- const errorResponse = { error: 'Only the token creator can claim rewards' };
- console.log("claim/confirm error response: Non-creator attempting to claim");
- return res.status(403).json(errorResponse);
- }
-
- authorizedClaimWallet = creatorWallet;
- }
-
- // At this point, authorizedClaimWallet is set to the wallet allowed to claim
- console.log("Authorized claim wallet:", authorizedClaimWallet);
-
- // Deserialize the user-signed transaction
- const transactionBuffer = bs58.decode(signedTransaction);
- const transaction = Transaction.from(transactionBuffer);
-
- // SECURITY: Validate transaction has recent blockhash to prevent replay attacks
- if (!transaction.recentBlockhash) {
- const errorResponse = { error: 'Invalid transaction: missing blockhash' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Check if blockhash is still valid (within last 150 slots ~60 seconds)
- const isBlockhashValid = await connection.isBlockhashValid(
- transaction.recentBlockhash,
- { commitment: 'confirmed' }
- );
-
- if (!isBlockhashValid) {
- const errorResponse = { error: 'Invalid transaction: blockhash is expired. Please create a new transaction.' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // CRITICAL SECURITY: Verify the transaction is cryptographically signed by the authorized wallet
- console.log("About to create PublicKey from authorizedClaimWallet:", { authorizedClaimWallet });
- let authorizedPublicKey;
- try {
- authorizedPublicKey = new PublicKey(authorizedClaimWallet!);
- console.log("Successfully created authorizedPublicKey:", authorizedPublicKey.toBase58());
- } catch (error) {
- console.error("Error creating PublicKey from authorizedClaimWallet:", error);
- const errorResponse = { error: 'Invalid authorized wallet format' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
- let validAuthorizedSigner = false;
-
- // Compile the transaction message for signature verification
- const message = transaction.compileMessage();
- const messageBytes = message.serialize();
-
- // Find the authorized wallet's signer index
- const authorizedSignerIndex = message.accountKeys.findIndex(key =>
- key.equals(authorizedPublicKey)
- );
-
- if (authorizedSignerIndex >= 0 && authorizedSignerIndex < transaction.signatures.length) {
- const signature = transaction.signatures[authorizedSignerIndex];
- if (signature.signature) {
- // CRITICAL: Verify the signature is cryptographically valid using nacl
- const isValid = nacl.sign.detached.verify(
- messageBytes,
- signature.signature,
- authorizedPublicKey.toBytes()
- );
- validAuthorizedSigner = isValid;
- }
- }
-
- if (!validAuthorizedSigner) {
- const errorResponse = { error: isDesignated ? 'Invalid transaction: must be cryptographically signed by the verified designated wallet' : 'Invalid transaction: must be cryptographically signed by the token creator wallet' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // CRITICAL SECURITY: Derive the creator's Associated Token Account (ATA) address
- console.log("About to create mintPublicKey from tokenAddress:", { tokenAddress: claimData.tokenAddress });
- let mintPublicKey;
- try {
- mintPublicKey = new PublicKey(claimData.tokenAddress);
- console.log("Successfully created mintPublicKey:", mintPublicKey.toBase58());
- } catch (error) {
- console.error("Error creating PublicKey from tokenAddress:", error);
- const errorResponse = { error: 'Invalid token address format' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Mathematically derive the creator's ATA address (no blockchain calls)
- console.log("About to create PDA with program constants");
- console.log("TOKEN_PROGRAM_ID:", TOKEN_PROGRAM_ID.toBase58());
- console.log("ASSOCIATED_TOKEN_PROGRAM_ID:", ASSOCIATED_TOKEN_PROGRAM_ID.toBase58());
-
- const [authorizedTokenAccountAddress] = PublicKey.findProgramAddressSync(
- [
- authorizedPublicKey.toBuffer(),
- TOKEN_PROGRAM_ID.toBuffer(), // SPL Token program
- mintPublicKey.toBuffer()
- ],
- ASSOCIATED_TOKEN_PROGRAM_ID // Associated Token program
- );
- console.log("Successfully created authorizedTokenAccountAddress:", authorizedTokenAccountAddress.toBase58());
-
- // CRITICAL SECURITY: Derive the admin's ATA address
- const adminPublicKey = new PublicKey(ADMIN_WALLET);
- const [adminTokenAccountAddress] = PublicKey.findProgramAddressSync(
- [
- adminPublicKey.toBuffer(),
- TOKEN_PROGRAM_ID.toBuffer(),
- mintPublicKey.toBuffer()
- ],
- ASSOCIATED_TOKEN_PROGRAM_ID
- );
- console.log("Successfully created adminTokenAccountAddress:", adminTokenAccountAddress.toBase58());
-
- // CRITICAL SECURITY: Validate that the transaction has exactly TWO mint instructions with correct amounts
- let mintInstructionCount = 0;
- let validDeveloperMint = false;
- let validAdminMint = false;
-
- console.log("Validating transaction with", transaction.instructions.length, "instructions");
-
- // First pass: count mint instructions
- for (const instruction of transaction.instructions) {
- if (instruction.programId.equals(TOKEN_PROGRAM_ID) &&
- instruction.data.length >= 9 &&
- instruction.data[0] === 7) {
- mintInstructionCount++;
- }
- }
-
- // Reject if not exactly TWO mint instructions
- if (mintInstructionCount === 0) {
- const errorResponse = { error: 'Invalid transaction: no mint instructions found' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- if (mintInstructionCount === 1) {
- const errorResponse = { error: 'Invalid transaction: missing admin mint instruction' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- if (mintInstructionCount > 2) {
- const errorResponse = { error: 'Invalid transaction: only two mint instructions allowed (developer + admin)' };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Get the token decimals to convert claim amounts to base units
- const mintInfo = await getMint(connection, mintPublicKey);
- const expectedDeveloperAmountWithDecimals = BigInt(metadata.developerAmount) * BigInt(10 ** mintInfo.decimals);
- const expectedAdminAmountWithDecimals = BigInt(metadata.adminAmount) * BigInt(10 ** mintInfo.decimals);
+// Claims routes have been moved to routes/claims.ts
- console.log("Expected amounts:", {
- developerAmount: metadata.developerAmount,
- adminAmount: metadata.adminAmount,
- developerAmountWithDecimals: expectedDeveloperAmountWithDecimals.toString(),
- adminAmountWithDecimals: expectedAdminAmountWithDecimals.toString()
- });
-
- // Second pass: validate BOTH mint instructions
- for (let i = 0; i < transaction.instructions.length; i++) {
- const instruction = transaction.instructions[i];
- console.log(`Instruction ${i}:`, {
- programId: instruction.programId.toString(),
- dataLength: instruction.data.length,
- keysLength: instruction.keys.length,
- firstByte: instruction.data.length > 0 ? instruction.data[0] : undefined
- });
-
- // Check if this is a mintTo instruction (SPL Token program)
- if (instruction.programId.equals(TOKEN_PROGRAM_ID)) {
- // Parse mintTo instruction - first byte is instruction type (7 = mintTo)
- if (instruction.data.length >= 9 && instruction.data[0] === 7) {
- console.log("Found mintTo instruction!");
-
- // Validate mint amount (bytes 1-8 are amount as little-endian u64)
- const mintAmount = instruction.data.readBigUInt64LE(1);
-
- // Validate complete mint instruction structure
- if (instruction.keys.length >= 3) {
- const mintAccount = instruction.keys[0].pubkey; // mint account
- const recipientAccount = instruction.keys[1].pubkey; // recipient token account
- const mintAuthority = instruction.keys[2].pubkey; // mint authority
-
- console.log("Mint instruction validation:", {
- mintAccount: mintAccount.toBase58(),
- expectedMint: mintPublicKey.toBase58(),
- mintMatches: mintAccount.equals(mintPublicKey),
- recipientAccount: recipientAccount.toBase58(),
- mintAmount: mintAmount.toString(),
- mintAuthority: mintAuthority.toBase58(),
- expectedAuthority: protocolKeypair.publicKey.toBase58(),
- authorityMatches: mintAuthority.equals(protocolKeypair.publicKey)
- });
-
- // CRITICAL SECURITY: Check if this is the developer mint instruction
- if (mintAccount.equals(mintPublicKey) &&
- recipientAccount.equals(authorizedTokenAccountAddress) &&
- mintAuthority.equals(protocolKeypair.publicKey) &&
- mintAmount === expectedDeveloperAmountWithDecimals) {
- validDeveloperMint = true;
- console.log("✓ Valid developer mint instruction found");
- }
- // CRITICAL SECURITY: Check if this is the admin mint instruction
- else if (mintAccount.equals(mintPublicKey) &&
- recipientAccount.equals(adminTokenAccountAddress) &&
- mintAuthority.equals(protocolKeypair.publicKey) &&
- mintAmount === expectedAdminAmountWithDecimals) {
- validAdminMint = true;
- console.log("✓ Valid admin mint instruction found");
- }
- // SECURITY: Reject any mint instruction that doesn't match expected parameters
- else {
- const errorResponse = { error: 'Invalid transaction: mint instruction contains invalid parameters' };
- console.log("claim/confirm error response:", errorResponse);
- console.log("Rejected mint instruction:", {
- recipientMatches: recipientAccount.equals(authorizedTokenAccountAddress) || recipientAccount.equals(adminTokenAccountAddress),
- amountMatches: mintAmount === expectedDeveloperAmountWithDecimals || mintAmount === expectedAdminAmountWithDecimals,
- mintAmount: mintAmount.toString(),
- expectedDeveloper: expectedDeveloperAmountWithDecimals.toString(),
- expectedAdmin: expectedAdminAmountWithDecimals.toString()
- });
- return res.status(400).json(errorResponse);
- }
- }
- }
- }
- }
-
- // CRITICAL SECURITY: Ensure BOTH mint instructions were found and valid
- if (!validDeveloperMint) {
- const errorResponse = { error: `Invalid transaction: developer mint instruction missing or invalid` };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- if (!validAdminMint) {
- const errorResponse = { error: `Invalid transaction: admin mint instruction missing or invalid` };
- console.log("claim/confirm error response:", errorResponse);
- return res.status(400).json(errorResponse);
- }
-
- // Add protocol signature (mint authority)
- transaction.partialSign(protocolKeypair);
-
- // Send the fully signed transaction with proper configuration
- const signature = await connection.sendRawTransaction(
- transaction.serialize(),
- {
- skipPreflight: false,
- preflightCommitment: 'processed'
- }
- );
-
- // Poll for confirmation status
- const maxAttempts = 20;
- const delayMs = 200; // 200ms between polls
- let attempts = 0;
- let confirmation;
-
- while (attempts < maxAttempts) {
- const result = await connection.getSignatureStatus(signature, {
- searchTransactionHistory: true
- });
-
- console.log(`Attempt ${attempts + 1}: Transaction status:`, JSON.stringify(result, null, 2));
-
- if (!result || !result.value) {
- // Transaction not found yet, wait and retry
- attempts++;
- await new Promise(resolve => setTimeout(resolve, delayMs));
- continue;
- }
-
- if (result.value.err) {
- throw new Error(`Transaction failed: ${JSON.stringify(result.value.err)}`);
- }
-
- // If confirmed or finalized, we're done
- if (result.value.confirmationStatus === 'confirmed' ||
- result.value.confirmationStatus === 'finalized') {
- confirmation = result.value;
- break;
- }
-
- // Still processing, wait and retry
- attempts++;
- await new Promise(resolve => setTimeout(resolve, delayMs));
- }
-
- if (!confirmation) {
- throw new Error('Transaction confirmation timeout');
- }
-
-
- // Clean up the transaction data from memory
- claimTransactions.delete(transactionKey);
- claimTransactions.delete(`${transactionKey}_metadata`);
-
- const successResponse = {
- success: true as const,
- transactionSignature: signature,
- tokenAddress: claimData.tokenAddress,
- userTokenAccount: claimData.userTokenAccount,
- claimAmount: claimData.claimAmount,
- confirmation
- };
-
- console.log("claim/confirm successful response:", successResponse);
- res.json(successResponse);
-
- } catch (error) {
- console.error('Confirm claim error:', error);
- const errorResponse = {
- error: error instanceof Error ? error.message : 'Failed to confirm claim'
- };
- console.log("claim/confirm error response:", errorResponse);
- res.status(500).json(errorResponse);
- } finally {
- // Always release the lock, even if an error occurred
- if (releaseLock) {
- releaseLock();
- }
- }
-};
-
-app.post('/claims/confirm', confirmClaim);
-
-// ===== PRESALE CLAIM ENDPOINTS =====
-
-// In-memory storage for presale claim transactions
-interface PresaleClaimTransaction {
- tokenAddress: string;
- userWallet: string;
- claimAmount: string;
- userTokenAccount: string;
- escrowTokenAccount: string; // Add this to store the actual escrow token account
- mintDecimals: number;
- timestamp: number;
- escrowPublicKey: string;
- encryptedEscrowKey: string; // Store encrypted key, decrypt only when signing
-}
-const presaleClaimTransactions = new Map();
-
-// In-memory storage for presale launch transactions
-interface StoredPresaleLaunchTransaction {
- combinedTx: string;
- tokenAddress: string;
- payerPublicKey: string;
- escrowPublicKey: string;
- baseMintKeypair: string; // Base58 encoded secret key for the base mint
- timestamp: number;
-}
-const presaleLaunchTransactions = new Map();
-
-// Clean up old presale launch transactions (older than 15 minutes)
-const TRANSACTION_EXPIRY_MS = 15 * 60 * 1000;
-setInterval(() => {
- const now = Date.now();
- for (const [id, tx] of presaleLaunchTransactions.entries()) {
- if (now - tx.timestamp > TRANSACTION_EXPIRY_MS) {
- presaleLaunchTransactions.delete(id);
- }
- }
-}, 60 * 1000); // Run cleanup every minute
-
-// Separate mutex locks for presale claims (per-token to prevent double claims)
-const presaleClaimLocks = new Map>();
-
-async function acquirePresaleClaimLock(token: string): Promise<() => void> {
- const key = token.toLowerCase();
-
- // Wait for any existing lock to be released
- while (presaleClaimLocks.has(key)) {
- await presaleClaimLocks.get(key);
- }
-
- // Create a new lock
- let releaseLock: () => void;
- const lockPromise = new Promise((resolve) => {
- releaseLock = resolve;
- });
-
- presaleClaimLocks.set(key, lockPromise);
-
- // Return the release function
- return () => {
- presaleClaimLocks.delete(key);
- releaseLock();
- };
-}
-
-// Get presale claim info endpoint
-app.get('/presale/:tokenAddress/claims/:wallet', presaleClaimLimiter, async (req: Request, res: Response) => {
- try {
- const { tokenAddress, wallet } = req.params;
-
- if (!tokenAddress || !wallet) {
- return res.status(400).json({
- success: false,
- error: 'Token address and wallet are required'
- });
- }
-
- // Validate Solana addresses
- if (!isValidSolanaAddress(tokenAddress)) {
- return res.status(400).json({
- success: false,
- error: 'Invalid token address format'
- });
- }
-
- if (!isValidSolanaAddress(wallet)) {
- return res.status(400).json({
- success: false,
- error: 'Invalid wallet address format'
- });
- }
-
- const vestingInfo: VestingInfo = await calculateVestingInfo(tokenAddress, wallet);
-
- res.json({ success: true, ...vestingInfo });
- } catch (error) {
- console.error('Error fetching presale claim info:', error);
-
- // Handle specific error types
- if (error instanceof Error) {
- if (error.message.includes('No allocation')) {
- return res.status(404).json({
- success: false,
- error: 'No allocation found for this wallet'
- });
- }
- if (error.message.includes('not launched')) {
- return res.status(400).json({
- success: false,
- error: 'Presale not launched yet'
- });
- }
- }
-
- res.status(500).json({
- success: false,
- error: error instanceof Error ? error.message : 'Failed to fetch claim info'
- });
- }
-});
-
-// Create unsigned presale claim transaction
-app.post('/presale/:tokenAddress/claims/prepare', presaleClaimLimiter, async (req: Request, res: Response) => {
- let releaseLock: (() => void) | null = null;
-
- try {
- const { tokenAddress } = req.params;
- const { userWallet } = req.body;
-
- if (!userWallet) {
- return res.status(400).json({ error: 'User wallet is required' });
- }
-
- // Validate Solana addresses
- if (!isValidSolanaAddress(tokenAddress)) {
- return res.status(400).json({ error: 'Invalid token address format' });
- }
-
- if (!isValidSolanaAddress(userWallet)) {
- return res.status(400).json({ error: 'Invalid user wallet address format' });
- }
-
- // Acquire lock for this token (using presale-specific lock)
- releaseLock = await acquirePresaleClaimLock(tokenAddress);
-
- // Get presale and vesting info
- const presale = await getPresaleByTokenAddress(tokenAddress);
- if (!presale || presale.status !== 'launched') {
- return res.status(400).json({ error: 'Presale not found or not launched' });
- }
-
- if (!presale.base_mint_address || !presale.escrow_priv_key) {
- return res.status(400).json({ error: 'Presale configuration incomplete' });
- }
-
- // Calculate claimable amount and validate
- const vestingInfo: VestingInfo = await calculateVestingInfo(tokenAddress, userWallet);
-
- // Validate user has a contribution/allocation
- if (!vestingInfo.totalAllocated || vestingInfo.totalAllocated === '0') {
- return res.status(400).json({ error: 'No token allocation found for this wallet' });
- }
-
- // Validate user's actual contribution exists in the database
- const userContribution = await getUserPresaleContribution(tokenAddress, userWallet);
- if (!userContribution || userContribution === BigInt(0)) {
- return res.status(400).json({ error: 'No contribution found for this wallet' });
- }
-
- // ENFORCE NEXT UNLOCK TIME - Prevent claiming before the next unlock period
- if (vestingInfo.nextUnlockTime && new Date() < vestingInfo.nextUnlockTime) {
- const timeUntilNextUnlock = vestingInfo.nextUnlockTime.getTime() - Date.now();
- const minutesRemaining = Math.ceil(timeUntilNextUnlock / 60000);
- return res.status(400).json({
- error: `Cannot claim yet. Next unlock in ${minutesRemaining} minutes at ${vestingInfo.nextUnlockTime.toISOString()}`,
- nextUnlockTime: vestingInfo.nextUnlockTime.toISOString(),
- minutesRemaining
- });
- }
-
- // The claimableAmount from vestingInfo already accounts for:
- // 1. Vesting schedule (how much has vested so far)
- // 2. Already claimed amounts (subtracts what was previously claimed)
- // So we just need to validate it's positive
- const claimAmount = new BN(vestingInfo.claimableAmount);
-
- if (claimAmount.isZero() || claimAmount.isNeg()) {
- return res.status(400).json({ error: 'No tokens available to claim at this time' });
- }
-
- // Decrypt escrow keypair only to get the public key for transaction building
- const escrowKeypair = decryptEscrowKeypair(presale.escrow_priv_key);
-
- // Setup connection and get token info
- const connection = new Connection(process.env.RPC_URL!, 'confirmed');
- const baseMintPubkey = new PublicKey(presale.base_mint_address);
- const userPubkey = new PublicKey(userWallet);
-
- // Get mint info for decimals
- const mintInfo = await getMint(connection, baseMintPubkey);
-
- // Get user's token account address
- const userTokenAccountAddress = await getAssociatedTokenAddress(
- baseMintPubkey,
- userPubkey,
- true // Allow owner off curve
- );
-
- // Check if account exists
- let userTokenAccountInfo;
- try {
- userTokenAccountInfo = await connection.getAccountInfo(userTokenAccountAddress);
- } catch (err) {
- // Account doesn't exist
- userTokenAccountInfo = null;
- }
-
- // Get escrow's token account address
- const escrowTokenAccountAddress = await getAssociatedTokenAddress(
- baseMintPubkey,
- escrowKeypair.publicKey,
- true // Allow owner off curve
- );
-
- // Check if escrow account exists
- let escrowTokenAccountInfo;
- try {
- escrowTokenAccountInfo = await connection.getAccountInfo(escrowTokenAccountAddress);
- } catch (err) {
- escrowTokenAccountInfo = null;
- }
-
- // Create transaction
- const transaction = new Transaction();
-
- // Add instruction to create user's token account if it doesn't exist (user pays)
- if (!userTokenAccountInfo) {
- const createUserATAInstruction = createAssociatedTokenAccountInstruction(
- userPubkey, // payer (user pays)
- userTokenAccountAddress,
- userPubkey, // owner
- baseMintPubkey
- );
- transaction.add(createUserATAInstruction);
- }
-
- // Add instruction to create escrow's token account if it doesn't exist (user pays)
- if (!escrowTokenAccountInfo) {
- const createEscrowATAInstruction = createAssociatedTokenAccountInstruction(
- userPubkey, // payer (user pays for escrow account too)
- escrowTokenAccountAddress,
- escrowKeypair.publicKey, // owner
- baseMintPubkey
- );
- transaction.add(createEscrowATAInstruction);
- }
-
- // Create transfer instruction from escrow to user
- const transferInstruction = createTransferInstruction(
- escrowTokenAccountAddress,
- userTokenAccountAddress,
- escrowKeypair.publicKey,
- BigInt(claimAmount.toString())
- );
- transaction.add(transferInstruction);
- const { blockhash } = await connection.getLatestBlockhash('confirmed');
- transaction.recentBlockhash = blockhash;
- transaction.feePayer = userPubkey; // User pays for transaction fees
-
- // Store transaction data with encrypted escrow key
- const timestamp = Date.now();
- const claimKey = `${tokenAddress}:${timestamp}`;
- presaleClaimTransactions.set(claimKey, {
- tokenAddress,
- userWallet,
- claimAmount: claimAmount.toString(),
- userTokenAccount: userTokenAccountAddress.toBase58(),
- escrowTokenAccount: escrowTokenAccountAddress.toBase58(), // Store the actual escrow token account
- mintDecimals: mintInfo.decimals,
- timestamp,
- escrowPublicKey: escrowKeypair.publicKey.toBase58(),
- encryptedEscrowKey: presale.escrow_priv_key // Store encrypted key from DB
- });
-
- // Serialize transaction
- const serializedTx = bs58.encode(transaction.serialize({
- requireAllSignatures: false,
- verifySignatures: false
- }));
-
- res.json({
- success: true,
- transaction: serializedTx,
- timestamp,
- claimAmount: claimAmount.toString(),
- decimals: mintInfo.decimals
- });
-
- } catch (error) {
- console.error('Error preparing presale claim:', error);
- res.status(500).json({
- error: error instanceof Error ? error.message : 'Failed to prepare claim'
- });
- } finally {
- if (releaseLock) releaseLock();
- }
-});
-
-// Confirm presale claim transaction
-app.post('/presale/:tokenAddress/claims/confirm', presaleClaimLimiter, async (req: Request, res: Response) => {
- let releaseLock: (() => void) | null = null;
-
- try {
- const { tokenAddress } = req.params;
- const { signedTransaction, timestamp } = req.body;
-
- if (!signedTransaction || !timestamp) {
- return res.status(400).json({ error: 'Missing required parameters' });
- }
-
- // Validate token address
- if (!isValidSolanaAddress(tokenAddress)) {
- return res.status(400).json({ error: 'Invalid token address format' });
- }
-
- // Validate timestamp
- if (typeof timestamp !== 'number' || timestamp < 0 || timestamp > Date.now() + 60000) {
- return res.status(400).json({ error: 'Invalid timestamp' });
- }
-
- // Acquire lock (using presale-specific lock)
- releaseLock = await acquirePresaleClaimLock(tokenAddress);
-
- // Get stored transaction
- const claimKey = `${tokenAddress}:${timestamp}`;
- const storedClaim = presaleClaimTransactions.get(claimKey);
-
- if (!storedClaim) {
- console.error('[PRESALE CLAIM] Stored claim not found for key:', claimKey);
- return res.status(400).json({ error: 'Claim transaction not found or expired' });
- }
-
- // Verify timestamp (5 minute expiry)
- if (Date.now() - storedClaim.timestamp > 5 * 60 * 1000) {
- presaleClaimTransactions.delete(claimKey);
- return res.status(400).json({ error: 'Claim transaction expired' });
- }
-
- // RE-VALIDATE VESTING SCHEDULE - Critical security check
- // Even if a transaction was prepared, we must ensure it's still valid at confirm time
- const vestingInfo: VestingInfo = await calculateVestingInfo(tokenAddress, storedClaim.userWallet);
-
- // Enforce next unlock time
- if (vestingInfo.nextUnlockTime && new Date() < vestingInfo.nextUnlockTime) {
- const timeUntilNextUnlock = vestingInfo.nextUnlockTime.getTime() - Date.now();
- const minutesRemaining = Math.ceil(timeUntilNextUnlock / 60000);
-
- // Clean up the stored transaction since it's no longer valid
- presaleClaimTransactions.delete(claimKey);
-
- return res.status(400).json({
- error: `Cannot claim yet. Next unlock in ${minutesRemaining} minutes at ${vestingInfo.nextUnlockTime.toISOString()}`,
- nextUnlockTime: vestingInfo.nextUnlockTime.toISOString(),
- minutesRemaining
- });
- }
-
- // Verify the claim amount is still valid
- const currentClaimableAmount = new BN(vestingInfo.claimableAmount);
- const storedClaimAmount = new BN(storedClaim.claimAmount);
-
- if (currentClaimableAmount.lt(storedClaimAmount)) {
- // The claimable amount has decreased (shouldn't happen, but check for safety)
- presaleClaimTransactions.delete(claimKey);
- return res.status(400).json({
- error: 'Claim amount is no longer valid. Please prepare a new transaction.',
- currentClaimable: currentClaimableAmount.toString(),
- requestedAmount: storedClaimAmount.toString()
- });
- }
-
- // Deserialize the user-signed transaction
- const connection = new Connection(process.env.RPC_URL!, 'confirmed');
- const txBuffer = bs58.decode(signedTransaction);
- const transaction = Transaction.from(txBuffer);
-
- // SECURITY: Validate transaction has recent blockhash to prevent replay attacks
- if (!transaction.recentBlockhash) {
- return res.status(400).json({ error: 'Invalid transaction: missing blockhash' });
- }
-
- // Check if blockhash is still valid (within last 150 slots ~60 seconds)
- const isBlockhashValid = await connection.isBlockhashValid(
- transaction.recentBlockhash,
- { commitment: 'confirmed' }
- );
-
- if (!isBlockhashValid) {
- return res.status(400).json({
- error: 'Invalid transaction: blockhash is expired. Please create a new transaction.'
- });
- }
-
- // CRITICAL SECURITY: Verify the transaction is signed by the claiming wallet
- const userPubkey = new PublicKey(storedClaim.userWallet);
- let validUserSigner = false;
-
- // Compile the transaction message for signature verification
- const message = transaction.compileMessage();
- const messageBytes = message.serialize();
-
- // Find the user wallet's signer index
- const userSignerIndex = message.accountKeys.findIndex(key =>
- key.equals(userPubkey)
- );
-
- if (userSignerIndex >= 0 && userSignerIndex < transaction.signatures.length) {
- const signature = transaction.signatures[userSignerIndex];
- if (signature.signature) {
- // CRITICAL: Verify the signature is cryptographically valid using nacl
- const isValid = nacl.sign.detached.verify(
- messageBytes,
- signature.signature,
- userPubkey.toBytes()
- );
- validUserSigner = isValid;
- }
- }
-
- if (!validUserSigner) {
- return res.status(400).json({
- error: 'Invalid transaction: must be cryptographically signed by the claiming wallet'
- });
- }
-
- // CRITICAL SECURITY: Validate transaction structure
- // Check that it only contains expected instructions (transfer from escrow to user)
- let transferInstructionCount = 0;
- let validTransfer = false;
- const escrowPubkey = new PublicKey(storedClaim.escrowPublicKey);
- const userTokenAccount = new PublicKey(storedClaim.userTokenAccount);
- const mintPubkey = new PublicKey(tokenAddress);
-
- // Get the Compute Budget Program ID
- const COMPUTE_BUDGET_PROGRAM_ID = ComputeBudgetProgram.programId;
- const LIGHTHOUSE_PROGRAM_ID = new PublicKey("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95");
-
- for (const instruction of transaction.instructions) {
- // Check if it's a Compute Budget instruction (optional, for setting compute units)
- if (instruction.programId.equals(COMPUTE_BUDGET_PROGRAM_ID)) {
- // This is fine, it's a compute budget instruction for optimizing transaction fees
- continue;
- }
-
- // Check if it's an ATA creation instruction (optional, only if account doesn't exist)
- if (instruction.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) {
- // This is fine, it's creating the user's token account
- continue;
- }
-
- // Check if it's a Lighthouse instruction
- if (instruction.programId.equals(LIGHTHOUSE_PROGRAM_ID)) {
- // This is fine, it's a Lighthouse instruction for optimizing transaction fees
- continue;
- }
-
- // Check if it's a transfer instruction
- if (instruction.programId.equals(TOKEN_PROGRAM_ID)) {
- // Transfer instruction has opcode 3 or 12 (Transfer or TransferChecked)
- const opcode = instruction.data[0];
-
- if (opcode === 3 || opcode === 12) {
- transferInstructionCount++;
-
- // Validate the transfer is from escrow to user
- // For Transfer (opcode 3): accounts are [source, destination, authority]
- // For TransferChecked (opcode 12): accounts are [source, mint, destination, authority]
- const sourceIndex = 0;
- const destIndex = opcode === 3 ? 1 : 2;
- const authorityIndex = opcode === 3 ? 2 : 3;
-
- if (instruction.keys.length > authorityIndex) {
- const source = instruction.keys[sourceIndex].pubkey;
- const destination = instruction.keys[destIndex].pubkey;
- const authority = instruction.keys[authorityIndex].pubkey;
-
- // For presale claims, we need to validate:
- // 1. The authority MUST be the escrow
- // 2. The destination MUST be the user's token account
- // 3. The source MUST be owned by the escrow (but might not be the ATA)
-
- const authorityMatchesEscrow = authority.equals(escrowPubkey);
- const destMatchesUser = destination.equals(userTokenAccount);
-
- // Since the source might not be an ATA, we should verify it's owned by the escrow
- // by checking the transaction itself or trusting that the escrow signature validates ownership
- // For now, we'll accept any source as long as the escrow is signing
-
- // Validate: authority is escrow and destination is user's account
- // We trust the source because only the escrow can sign for its accounts
- if (destMatchesUser && authorityMatchesEscrow) {
-
- // Validate transfer amount
- const amountBytes = opcode === 3
- ? instruction.data.slice(1, 9) // Transfer: 8 bytes starting at index 1
- : instruction.data.slice(1, 9); // TransferChecked: 8 bytes starting at index 1
-
- const amount = new BN(amountBytes, 'le');
- const expectedAmount = new BN(storedClaim.claimAmount);
-
- if (amount.eq(expectedAmount)) {
- validTransfer = true;
- }
- }
- }
- } else {
- // Unexpected SPL Token instruction
- return res.status(400).json({
- error: 'Invalid transaction: unexpected token program instruction'
- });
- }
- } else if (!instruction.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID) &&
- !instruction.programId.equals(COMPUTE_BUDGET_PROGRAM_ID) &&
- !instruction.programId.equals(LIGHTHOUSE_PROGRAM_ID)) {
- console.log("instruction", instruction);
- // Unknown program - reject
- return res.status(400).json({
- error: 'Invalid transaction: contains unexpected instructions'
- });
- }
- }
-
- if (transferInstructionCount === 0) {
- return res.status(400).json({ error: 'Invalid transaction: no transfer instruction found' });
- }
-
- if (transferInstructionCount > 1) {
- return res.status(400).json({ error: 'Invalid transaction: only one transfer allowed' });
- }
-
- if (!validTransfer) {
- return res.status(400).json({
- error: 'Invalid transaction: transfer details do not match claim'
- });
- }
-
- // Now decrypt and add the escrow signature after all validations pass
- const escrowKeypair = decryptEscrowKeypair(storedClaim.encryptedEscrowKey);
- transaction.partialSign(escrowKeypair);
-
- // Send the fully signed transaction
- const fullySignedTxBuffer = transaction.serialize();
- const signature = await connection.sendRawTransaction(fullySignedTxBuffer, {
- skipPreflight: false,
- preflightCommitment: 'confirmed',
- maxRetries: 3
- });
-
- // Wait for confirmation using polling
- let confirmed = false;
- let retries = 0;
- const maxRetries = 60; // 60 seconds max
-
- while (!confirmed && retries < maxRetries) {
- try {
- const status = await connection.getSignatureStatus(signature);
-
- if (status?.value?.confirmationStatus === 'confirmed' || status?.value?.confirmationStatus === 'finalized') {
- confirmed = true;
- break;
- }
-
- if (status?.value?.err) {
- throw new Error(`Transaction failed: ${JSON.stringify(status.value.err)}`);
- }
-
- await new Promise(resolve => setTimeout(resolve, 1000));
- retries++;
- } catch (statusError) {
- console.error('Status check error:', statusError);
- retries++;
- await new Promise(resolve => setTimeout(resolve, 1000));
- }
- }
-
- if (!confirmed) {
- throw new Error('Transaction confirmation timeout after 60 seconds');
- }
-
- // Get transaction details for verification
- const txDetails = await connection.getParsedTransaction(signature, {
- commitment: 'confirmed',
- maxSupportedTransactionVersion: 0
- });
-
- // Record the claim in database
- await recordPresaleClaim(
- tokenAddress,
- storedClaim.userWallet,
- storedClaim.claimAmount,
- signature,
- txDetails?.blockTime || undefined,
- txDetails?.slot ? BigInt(txDetails.slot) : undefined
- );
-
- // Clean up stored transaction
- presaleClaimTransactions.delete(claimKey);
-
- const responseData = {
- success: true,
- signature,
- claimedAmount: storedClaim.claimAmount,
- decimals: storedClaim.mintDecimals
- };
-
- res.json(responseData);
-
- } catch (error) {
- console.error('[PRESALE CLAIM] Error confirming claim:', error);
-
- res.status(500).json({
- error: error instanceof Error ? error.message : 'Failed to confirm claim'
- });
- } finally {
- if (releaseLock) releaseLock();
- }
-});
-
-// Get presale stats endpoint
-app.get('/presale/:tokenAddress/stats', async (req: Request, res: Response) => {
- try {
- const { tokenAddress } = req.params;
-
- // Validate token address
- if (!isValidSolanaAddress(tokenAddress)) {
- return res.status(400).json({
- error: 'Invalid token address format'
- });
- }
-
- const stats = await getPresaleStats(tokenAddress);
-
- res.json({ success: true, ...stats });
- } catch (error) {
- console.error('Error fetching presale stats:', error);
- res.status(500).json({
- error: error instanceof Error ? error.message : 'Failed to fetch stats'
- });
- }
-});
-
-// ===== PRESALE BID ENDPOINTS =====
-
-// In-memory lock to prevent concurrent processing of the same transaction
-const transactionLocks = new Map>();
-
-async function acquireTransactionLock(signature: string): Promise<() => void> {
- const key = signature.toLowerCase();
-
- // Wait for any existing lock to be released
- while (transactionLocks.has(key)) {
- await transactionLocks.get(key);
- }
-
- // Create a new lock
- let releaseLock: () => void;
- const lockPromise = new Promise((resolve) => {
- releaseLock = resolve;
- });
-
- transactionLocks.set(key, lockPromise);
-
- // Return the release function
- return () => {
- transactionLocks.delete(key);
- releaseLock();
- };
-}
-
-const ZC_TOKEN_MINT = 'GVvPZpC6ymCoiHzYJ7CWZ8LhVn9tL2AUpRjSAsLh6jZC';
-const ZC_DECIMALS = 6;
-const ZC_PER_TOKEN = Math.pow(10, ZC_DECIMALS);
-
-// Get presale bids endpoint
-app.get('/presale/:tokenAddress/bids', async (req: Request, res: Response) => {
- try {
- const { tokenAddress } = req.params;
-
- if (!tokenAddress) {
- return res.status(400).json({
- error: 'Token address is required'
- });
- }
-
- // Validate token address
- if (!isValidSolanaAddress(tokenAddress)) {
- return res.status(400).json({
- error: 'Invalid token address format'
- });
- }
-
- // Fetch all bids and totals
- const [bids, totals] = await Promise.all([
- getPresaleBids(tokenAddress),
- getTotalPresaleBids(tokenAddress)
- ]);
-
- // Convert smallest units to $ZC for frontend display (6 decimals)
- const contributions = bids.map(bid => ({
- wallet: bid.wallet_address,
- amount: Number(bid.amount_lamports) / ZC_PER_TOKEN, // Now in $ZC
- transactionSignature: bid.transaction_signature,
- createdAt: bid.created_at
- }));
-
- const totalRaisedZC = Number(totals.totalAmount) / ZC_PER_TOKEN; // Now in $ZC
-
- res.json({
- totalRaised: totalRaisedZC,
- totalBids: totals.totalBids,
- contributions
- });
-
- } catch (error) {
- console.error('Error fetching presale bids:', error);
- res.status(500).json({
- error: error instanceof Error ? error.message : 'Failed to fetch presale bids'
- });
- }
-});
-
-// Record presale bid endpoint
-app.post('/presale/:tokenAddress/bids', async (req: Request, res: Response) => {
- let releaseLock: (() => void) | null = null;
-
- try {
- const { tokenAddress } = req.params;
- const { transactionSignature, walletAddress, amountTokens, tokenMint } = req.body;
-
- // Validate required fields
- if (!tokenAddress || !transactionSignature || !walletAddress || !amountTokens) {
- return res.status(400).json({
- error: 'Missing required fields'
- });
- }
-
- // Validate token mint is $ZC
- if (!tokenMint || tokenMint !== ZC_TOKEN_MINT) {
- return res.status(400).json({
- error: 'Invalid token mint. Only $ZC tokens are accepted'
- });
- }
-
- // Validate Solana addresses
- if (!isValidSolanaAddress(tokenAddress)) {
- return res.status(400).json({
- error: 'Invalid token address format'
- });
- }
-
- if (!isValidSolanaAddress(walletAddress)) {
- return res.status(400).json({
- error: 'Invalid wallet address format'
- });
- }
-
- // Validate transaction signature
- if (!isValidTransactionSignature(transactionSignature)) {
- return res.status(400).json({
- error: 'Invalid transaction signature format'
- });
- }
-
- // Validate amount (now in token units with 6 decimals)
- if (!amountTokens || typeof amountTokens !== 'number' || amountTokens <= 0) {
- return res.status(400).json({
- error: 'Invalid amount: must be a positive number of tokens'
- });
- }
-
- // Acquire lock for this transaction to prevent concurrent processing
- releaseLock = await acquireTransactionLock(transactionSignature);
-
- // Fetch presale from database
- const presale = await getPresaleByTokenAddress(tokenAddress);
-
- if (!presale) {
- return res.status(404).json({
- error: 'Presale not found'
- });
- }
-
- // Verify escrow address exists
- if (!presale.escrow_pub_key) {
- return res.status(400).json({
- error: 'Presale escrow not configured'
- });
- }
-
- // CRITICAL: Check if transaction already exists BEFORE expensive verification
- let existingBid = await getPresaleBidBySignature(transactionSignature);
- if (existingBid) {
- console.log(`Transaction ${transactionSignature} already recorded`);
- return res.status(400).json({
- error: 'Transaction already recorded'
- });
- }
-
- // Now verify the $ZC token transaction on-chain
- console.log(`Verifying $ZC token transaction ${transactionSignature} for presale ${tokenAddress}`);
-
- const verification = await verifyPresaleTokenTransaction(
- transactionSignature,
- walletAddress, // sender owner
- presale.escrow_pub_key, // recipient owner
- ZC_TOKEN_MINT, // token mint
- BigInt(amountTokens), // amount in smallest units (6 decimals)
- 300 // 5 minutes max age
- );
-
- if (!verification.valid) {
- console.error(`Token transaction verification failed: ${verification.error}`);
- return res.status(400).json({
- error: `Transaction verification failed: ${verification.error}`
- });
- }
-
- console.log(`Transaction ${transactionSignature} verified successfully`);
-
- // Double-check one more time after verification (belt and suspenders)
- existingBid = await getPresaleBidBySignature(transactionSignature);
- if (existingBid) {
- console.log(`Transaction ${transactionSignature} was recorded by another request during verification`);
- return res.status(400).json({
- error: 'Transaction already recorded'
- });
- }
-
- // Record the verified bid in the database
- // Note: We're keeping the database field as amount_lamports for backward compatibility
- // but now it represents smallest units of $ZC (6 decimals)
- try {
- const bid = await recordPresaleBid({
- presale_id: presale.id!,
- token_address: tokenAddress,
- wallet_address: walletAddress,
- amount_lamports: BigInt(amountTokens), // Now represents $ZC smallest units
- transaction_signature: transactionSignature,
- block_time: verification.details?.blockTime,
- slot: verification.details?.slot ? BigInt(verification.details.slot) : undefined,
- verified_at: new Date()
- });
-
- res.json({
- success: true,
- bid: {
- transactionSignature: bid.transaction_signature,
- amountZC: Number(bid.amount_lamports) / ZC_PER_TOKEN, // Convert to $ZC
- },
- verification: {
- blockTime: verification.details?.blockTime,
- slot: verification.details?.slot,
- verified: true
- }
- });
-
- } catch (error) {
- // Check if it's a duplicate transaction error
- if (error instanceof Error && error.message.includes('already recorded')) {
- return res.status(400).json({
- error: 'Transaction already recorded'
- });
- }
-
- console.error('Error recording bid:', error);
- return res.status(500).json({
- error: 'Failed to record bid'
- });
- }
-
- } catch (error) {
- console.error('Error saving presale bid:', error);
- res.status(500).json({
- error: error instanceof Error ? error.message : 'Failed to save bid'
- });
- } finally {
- // Always release the lock
- if (releaseLock) {
- releaseLock();
- }
- }
-});
-
-// Create presale launch transaction
-app.post('/presale/:tokenAddress/launch', async (req: Request, res: Response) => {
- try {
- const { tokenAddress } = req.params;
- const { payerPublicKey } = req.body;
-
- if (!tokenAddress) {
- return res.status(400).json({ error: 'Token address is required' });
- }
-
- if (!payerPublicKey) {
- return res.status(400).json({ error: 'Payer public key is required' });
- }
-
- const RPC_URL = process.env.RPC_URL;
- const CONFIG_ADDRESS = process.env.FLYWHEEL_CONFIG_ADDRESS;
- const ZC_TOKEN_MINT = new PublicKey("GVvPZpC6ymCoiHzYJ7CWZ8LhVn9tL2AUpRjSAsLh6jZC");
- const ZC_DECIMALS = 6;
- const ZC_PER_TOKEN = Math.pow(10, ZC_DECIMALS);
-
- if (!RPC_URL || !CONFIG_ADDRESS) {
- throw new Error('RPC_URL and CONFIG_ADDRESS must be configured');
- }
-
- // Fetch presale from database
- const presale = await getPresaleByTokenAddress(tokenAddress);
-
- if (!presale) {
- throw new Error('Presale not found');
- }
-
- // Verify caller is the creator
- if (presale.creator_wallet !== payerPublicKey) {
- throw new Error('Only the presale creator can launch');
- }
-
- // Check if already launched
- if (presale.status !== 'pending') {
- throw new Error('Presale has already been launched or is not pending');
- }
-
- // Verify escrow keys exist
- if (!presale.escrow_pub_key || !presale.escrow_priv_key) {
- throw new Error('Escrow keypair not found for this presale');
- }
-
- // Decrypt escrow keypair
- const escrowKeypair = decryptEscrowKeypair(presale.escrow_priv_key);
-
- // Verify escrow public key matches
- if (escrowKeypair.publicKey.toBase58() !== presale.escrow_pub_key) {
- throw new Error('Escrow keypair verification failed');
- }
-
- // Verify base mint key exists
- if (!presale.base_mint_priv_key) {
- throw new Error('Base mint keypair not found');
- }
-
- // Decrypt base mint keypair (stored as encrypted base58 string, not JSON array)
- const decryptedBase58 = decrypt(presale.base_mint_priv_key);
- const baseMintKeypair = Keypair.fromSecretKey(bs58.decode(decryptedBase58));
-
- // Verify base mint keypair by checking if we can recreate the same base58 string
- if (bs58.encode(baseMintKeypair.secretKey) !== decryptedBase58) {
- throw new Error('Base mint keypair verification failed');
- }
-
- // Get escrow's $ZC token balance
- const connection = new Connection(RPC_URL, "confirmed");
-
- // Get escrow's $ZC token account
- const escrowTokenAccount = await getAssociatedTokenAddress(
- ZC_TOKEN_MINT,
- escrowKeypair.publicKey,
- true
- );
-
- let escrowZCBalance = 0;
- try {
- const escrowTokenAccountInfo = await getAccount(connection, escrowTokenAccount);
- escrowZCBalance = Number(escrowTokenAccountInfo.amount);
- } catch (err) {
- throw new Error('Escrow $ZC token account not found or has no balance');
- }
-
- if (escrowZCBalance === 0) {
- throw new Error('Escrow wallet has no $ZC tokens');
- }
-
- // Use full escrow balance for the buy (no buffer needed for $ZC)
- const buyAmountTokens = escrowZCBalance;
-
- // Initialize Meteora client
- const client = new DynamicBondingCurveClient(connection, "confirmed");
-
- const baseMint = baseMintKeypair.publicKey;
- const payer = new PublicKey(payerPublicKey);
- const config = new PublicKey(CONFIG_ADDRESS);
-
- // Create pool with first buy using Meteora SDK - using $ZC as quote
- const { createPoolTx, swapBuyTx } = await client.pool.createPoolWithFirstBuy({
- createPoolParam: {
- baseMint,
- config, // This config must be configured for $ZC as quote token
- name: presale.token_name || '',
- symbol: presale.token_symbol || '',
- uri: presale.token_metadata_url,
- payer,
- poolCreator: payer
- },
- firstBuyParam: {
- buyer: escrowKeypair.publicKey,
- receiver: escrowKeypair.publicKey,
- buyAmount: new BN(buyAmountTokens), // Amount in $ZC smallest units (6 decimals)
- minimumAmountOut: new BN(0), // Accept any amount (no slippage protection for first buy)
- referralTokenAccount: null
- }
- });
-
- // Combine transactions into a single atomic transaction
- const combinedTx = new Transaction();
-
- // First, transfer SOL to escrow for token account creation and transaction fees
- // 0.005 SOL should cover rent exemption (~0.002 SOL) plus transaction fees
- const transferAmount = 5000000; // 0.005 SOL in lamports
- const transferSolInstruction = SystemProgram.transfer({
- fromPubkey: payer,
- toPubkey: escrowKeypair.publicKey,
- lamports: transferAmount,
- });
-
- // Add SOL transfer first
- combinedTx.add(transferSolInstruction);
-
- // Add all instructions from createPoolTx (this creates the mint first)
- combinedTx.add(...createPoolTx.instructions);
-
- // Add swap instructions if they exist
- if (swapBuyTx && swapBuyTx.instructions.length > 0) {
- combinedTx.add(...swapBuyTx.instructions);
- }
-
- // Set recent blockhash and fee payer
- const { blockhash } = await connection.getLatestBlockhash("confirmed");
- combinedTx.recentBlockhash = blockhash;
- combinedTx.feePayer = payer;
-
- // Serialize the combined transaction
- const combinedTxSerialized = bs58.encode(
- combinedTx.serialize({
- requireAllSignatures: false,
- verifySignatures: false
- })
- );
-
- // Generate a unique transaction ID
- const transactionId = crypto.randomBytes(16).toString('hex');
-
- // Store transaction details for later verification
- presaleLaunchTransactions.set(transactionId, {
- combinedTx: combinedTxSerialized,
- tokenAddress,
- payerPublicKey,
- escrowPublicKey: escrowKeypair.publicKey.toBase58(),
- baseMintKeypair: bs58.encode(baseMintKeypair.secretKey), // Store the keypair for signing later
- timestamp: Date.now()
- });
-
- res.json({
- combinedTx: combinedTxSerialized,
- transactionId
- });
-
- } catch (error) {
- console.error('Presale launch error:', error);
- res.status(500).json({
- error: error instanceof Error ? error.message : 'Failed to create presale launch transaction'
- });
- }
-});
-
-// Confirm presale launch transaction
-app.post('/presale/:tokenAddress/launch-confirm', async (req: Request, res: Response) => {
- try {
- const { tokenAddress } = req.params;
- const { signedTransaction, transactionId } = req.body;
-
- if (!tokenAddress) {
- return res.status(400).json({ error: 'Token address is required' });
- }
-
- if (!signedTransaction) {
- return res.status(400).json({ error: 'Signed transaction is required' });
- }
-
- if (!transactionId) {
- return res.status(400).json({ error: 'Transaction ID is required' });
- }
-
- const RPC_URL = process.env.RPC_URL;
-
- if (!RPC_URL) {
- throw new Error('RPC_URL must be configured');
- }
-
- // Retrieve stored transaction
- const storedTx = presaleLaunchTransactions.get(transactionId);
-
- if (!storedTx) {
- throw new Error('Transaction not found or expired. Please restart the launch process.');
- }
-
- // Verify this is for the correct token
- if (storedTx.tokenAddress !== tokenAddress) {
- throw new Error('Transaction token mismatch');
- }
-
- // Clean up stored transaction (one-time use)
- presaleLaunchTransactions.delete(transactionId);
-
- // Fetch presale from database to get escrow keypair
- const presale = await getPresaleByTokenAddress(tokenAddress);
-
- if (!presale) {
- throw new Error('Presale not found');
- }
-
- if (!presale.escrow_priv_key) {
- throw new Error('Escrow keypair not found');
- }
-
- // Decrypt escrow keypair
- const escrowKeypair = decryptEscrowKeypair(presale.escrow_priv_key);
-
- // Verify escrow public key matches
- if (escrowKeypair.publicKey.toBase58() !== storedTx.escrowPublicKey) {
- throw new Error('Escrow keypair mismatch');
- }
-
- // Reconstruct baseMint keypair from stored data (declare it in outer scope)
- if (!storedTx.baseMintKeypair) {
- throw new Error('BaseMint keypair not found in transaction data');
- }
- const baseMintKeypair = Keypair.fromSecretKey(bs58.decode(storedTx.baseMintKeypair));
-
- // Deserialize the signed transaction
- const transaction = Transaction.from(bs58.decode(signedTransaction));
-
- // Add escrow and baseMint signatures
- transaction.partialSign(escrowKeypair);
- transaction.partialSign(baseMintKeypair);
-
- // Send the fully signed transaction
- const connection = new Connection(RPC_URL, "confirmed");
-
- const signature = await connection.sendRawTransaction(
- transaction.serialize(),
- {
- skipPreflight: false,
- preflightCommitment: 'confirmed'
- }
- );
-
- // Wait for confirmation
- await connection.confirmTransaction(signature, 'confirmed');
-
- // Calculate tokens bought by escrow after the swap
- let tokensBought = '0';
- try {
- // Use the baseMint from the generated keypair
- const baseMintPubKey = baseMintKeypair.publicKey;
-
- // Get escrow's token account address for the launched token
- const escrowTokenAccount = await getAssociatedTokenAddress(
- baseMintPubKey,
- escrowKeypair.publicKey
- );
-
- // Get the token account to read balance
- const tokenAccount = await getAccount(connection, escrowTokenAccount);
- tokensBought = tokenAccount.amount.toString();
-
- // Initialize presale claims with vesting (using the generated baseMint address)
- await initializePresaleClaims(tokenAddress, baseMintPubKey.toBase58(), tokensBought);
-
- console.log(`Presale ${tokenAddress}: ${tokensBought} tokens bought, claims initialized`);
- } catch (error) {
- console.error('Error initializing presale claims:', error);
- // Don't fail the launch if we can't initialize claims
- }
-
- // Update presale status with base mint address and tokens bought
- await updatePresaleStatus(tokenAddress, 'launched', baseMintKeypair.publicKey.toBase58(), tokensBought);
-
- res.json({
- success: true,
- signature,
- message: 'Presale launched successfully!'
- });
-
- } catch (error) {
- console.error('Presale launch confirmation error:', error);
- res.status(500).json({
- error: error instanceof Error ? error.message : 'Failed to confirm presale launch'
- });
- }
-});
+// Presale routes have been moved to routes/presale.ts
// Cache for token verification - since token existence doesn't change, cache forever
const tokenVerificationCache = new Map();
diff --git a/ui/lib/claimService.ts b/ui/lib/claimService.ts
new file mode 100644
index 0000000..c82468d
--- /dev/null
+++ b/ui/lib/claimService.ts
@@ -0,0 +1,182 @@
+/*
+ * Z Combinator - Solana Token Launchpad
+ * Copyright (C) 2025 Z Combinator
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { calculateClaimEligibility } from './helius';
+import {
+ getWalletEmissionSplit,
+ getTokenCreatorWallet,
+ getEmissionSplits,
+ getTotalClaimedByWallet
+} from './db';
+
+/**
+ * Claim Service
+ *
+ * Core business logic for token emission claims with emission splits support.
+ * Handles per-wallet claim calculations, eligibility checks, and claim tracking.
+ */
+
+// ============================================================================
+// In-Memory Storage
+// ============================================================================
+
+/**
+ * Claim transaction storage for pending claims
+ * Maps transactionKey -> claim data
+ */
+export interface ClaimTransaction {
+ tokenAddress: string;
+ userWallet: string;
+ claimAmount: string;
+ mintDecimals: number;
+ timestamp: number;
+}
+
+export const claimTransactions = new Map();
+
+/**
+ * Mutex locks for preventing concurrent claim processing
+ * Maps token address -> Promise that resolves when processing is done
+ * Lock is per-token since claim eligibility is global per token
+ */
+const claimLocks = new Map>();
+
+// ============================================================================
+// Lock Management
+// ============================================================================
+
+/**
+ * Acquire a claim lock for a specific token
+ * Prevents race conditions during claim processing
+ *
+ * @param token - The token address to lock
+ * @returns A function to release the lock
+ */
+export async function acquireClaimLock(token: string): Promise<() => void> {
+ const key = token.toLowerCase();
+
+ // Wait for any existing lock to be released
+ while (claimLocks.has(key)) {
+ await claimLocks.get(key);
+ }
+
+ // Create a new lock
+ let releaseLock: () => void;
+ const lockPromise = new Promise((resolve) => {
+ releaseLock = resolve;
+ });
+
+ claimLocks.set(key, lockPromise);
+
+ // Return the release function
+ return () => {
+ claimLocks.delete(key);
+ releaseLock!();
+ };
+}
+
+// ============================================================================
+// Claim Eligibility Calculations
+// ============================================================================
+
+/**
+ * Calculate claim eligibility for a specific wallet
+ * Takes into account:
+ * - Global emission limits (calculateClaimEligibility)
+ * - Wallet's emission split percentage
+ * - Amount already claimed by this wallet
+ *
+ * Security: Prevents wallets from claiming more than their allocated percentage
+ *
+ * @param tokenAddress - The token address
+ * @param walletAddress - The wallet address
+ * @param tokenLaunchTime - The token launch timestamp
+ * @returns Object containing wallet-specific claim eligibility data
+ */
+export async function calculateWalletClaimEligibility(
+ tokenAddress: string,
+ walletAddress: string,
+ tokenLaunchTime: Date
+): Promise<{
+ availableToClaimForWallet: bigint;
+ walletSplitPercentage: number;
+ totalAlreadyClaimedByWallet: bigint;
+ globalAvailableToClaim: bigint;
+ globalMaxClaimableNow: bigint;
+}> {
+ // Get global claim eligibility (total emissions available across all wallets)
+ const globalEligibility = await calculateClaimEligibility(tokenAddress, tokenLaunchTime);
+
+ // Get wallet's emission split percentage
+ const walletSplit = await getWalletEmissionSplit(tokenAddress, walletAddress);
+ let splitPercentage = 0;
+
+ if (walletSplit && walletSplit.split_percentage > 0) {
+ // Wallet has a configured split
+ splitPercentage = walletSplit.split_percentage;
+ } else {
+ // Check if wallet is the creator (fallback for tokens without splits)
+ const creatorWallet = await getTokenCreatorWallet(tokenAddress);
+ if (creatorWallet && creatorWallet.trim() === walletAddress.trim()) {
+ // Creator gets 100% when no splits configured
+ const emissionSplits = await getEmissionSplits(tokenAddress);
+ if (emissionSplits.length === 0) {
+ splitPercentage = 100;
+ } else {
+ // Creator has no explicit split and others exist - they get 0%
+ splitPercentage = 0;
+ }
+ }
+ }
+
+ if (splitPercentage === 0) {
+ // Wallet has no claim rights
+ return {
+ availableToClaimForWallet: BigInt(0),
+ walletSplitPercentage: 0,
+ totalAlreadyClaimedByWallet: BigInt(0),
+ globalAvailableToClaim: globalEligibility.availableToClaim,
+ globalMaxClaimableNow: globalEligibility.maxClaimableNow
+ };
+ }
+
+ // Get total already claimed by this wallet
+ const totalClaimedByWallet = await getTotalClaimedByWallet(tokenAddress, walletAddress);
+
+ // Calculate this wallet's allocation of the TOTAL emissions (not just available)
+ // The 90% claimer portion applies to the global max
+ const claimersTotal = (globalEligibility.maxClaimableNow * BigInt(9)) / BigInt(10);
+ const walletMaxAllocation = (claimersTotal * BigInt(Math.floor(splitPercentage * 100))) / BigInt(10000);
+
+ // Calculate how much this wallet can still claim
+ const availableForWallet = walletMaxAllocation > totalClaimedByWallet
+ ? walletMaxAllocation - totalClaimedByWallet
+ : BigInt(0);
+
+ // Also respect the global available limit (can't claim more than globally available)
+ const walletShareOfGlobalAvailable = (globalEligibility.availableToClaim * BigInt(9) / BigInt(10) * BigInt(Math.floor(splitPercentage * 100))) / BigInt(10000);
+ const finalAvailable = availableForWallet < walletShareOfGlobalAvailable ? availableForWallet : walletShareOfGlobalAvailable;
+
+ return {
+ availableToClaimForWallet: finalAvailable,
+ walletSplitPercentage: splitPercentage,
+ totalAlreadyClaimedByWallet: totalClaimedByWallet,
+ globalAvailableToClaim: globalEligibility.availableToClaim,
+ globalMaxClaimableNow: globalEligibility.maxClaimableNow
+ };
+}
diff --git a/ui/lib/db.ts b/ui/lib/db.ts
index e5deaef..7e89baf 100644
--- a/ui/lib/db.ts
+++ b/ui/lib/db.ts
@@ -734,6 +734,63 @@ export async function hasRecentClaim(
}
}
+/**
+ * Check if a SPECIFIC wallet has claimed this token within the specified time window
+ * Used for per-wallet claim cooldowns with emission splits
+ * Returns true if wallet has a recent claim, false otherwise
+ */
+export async function hasRecentClaimByWallet(
+ tokenAddress: string,
+ walletAddress: string,
+ minutesAgo: number = 360
+): Promise {
+ const pool = getPool();
+
+ const query = `
+ SELECT COUNT(*) as count
+ FROM claim_records
+ WHERE token_address = $1
+ AND wallet_address = $2
+ AND confirmed_at > NOW() - INTERVAL '${minutesAgo} minutes'
+ `;
+
+ try {
+ const result = await pool.query(query, [tokenAddress, walletAddress]);
+ return parseInt(result.rows[0].count) > 0;
+ } catch (error) {
+ console.error('Error checking recent claims by wallet:', error);
+ // Fail safe - if we can't check, assume they have claimed
+ return true;
+ }
+}
+
+/**
+ * Get total amount claimed by a specific wallet for a token
+ * Used to calculate remaining claimable amount with emission splits
+ */
+export async function getTotalClaimedByWallet(
+ tokenAddress: string,
+ walletAddress: string
+): Promise {
+ const pool = getPool();
+
+ const query = `
+ SELECT COALESCE(SUM(CAST(amount AS BIGINT)), 0) as total
+ FROM claim_records
+ WHERE token_address = $1
+ AND wallet_address = $2
+ AND confirmed_at IS NOT NULL
+ `;
+
+ try {
+ const result = await pool.query(query, [tokenAddress, walletAddress]);
+ return BigInt(result.rows[0].total);
+ } catch (error) {
+ console.error('Error getting total claimed by wallet:', error);
+ throw error;
+ }
+}
+
/**
* Pre-record a claim attempt in the database with a placeholder signature
* This prevents double-claiming by creating the DB record BEFORE signing
diff --git a/ui/lib/presaleService.ts b/ui/lib/presaleService.ts
new file mode 100644
index 0000000..b85b09a
--- /dev/null
+++ b/ui/lib/presaleService.ts
@@ -0,0 +1,139 @@
+/*
+ * Z Combinator - Solana Token Launchpad
+ * Copyright (C) 2025 Z Combinator
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+/**
+ * Presale Service
+ *
+ * Core business logic for token presales including:
+ * - Presale claim transaction management
+ * - Presale launch transaction management
+ * - Lock management for concurrency control
+ */
+
+// ============================================================================
+// Types and Interfaces
+// ============================================================================
+
+/**
+ * Presale claim transaction storage
+ * Used to track pending presale claims before blockchain confirmation
+ */
+export interface PresaleClaimTransaction {
+ tokenAddress: string;
+ userWallet: string;
+ claimAmount: string;
+ userTokenAccount: string;
+ escrowTokenAccount: string;
+ mintDecimals: number;
+ timestamp: number;
+ escrowPublicKey: string;
+ encryptedEscrowKey: string; // Store encrypted key, decrypt only when signing
+}
+
+/**
+ * Presale launch transaction storage
+ * Used to track pending presale launches before blockchain confirmation
+ */
+export interface StoredPresaleLaunchTransaction {
+ combinedTx: string;
+ tokenAddress: string;
+ payerPublicKey: string;
+ escrowPublicKey: string;
+ baseMintKeypair: string; // Base58 encoded secret key for the base mint
+ timestamp: number;
+}
+
+// ============================================================================
+// In-Memory Storage
+// ============================================================================
+
+/**
+ * In-memory storage for presale claim transactions
+ * Maps transactionKey -> presale claim data
+ */
+export const presaleClaimTransactions = new Map();
+
+/**
+ * In-memory storage for presale launch transactions
+ * Maps transactionId -> presale launch data
+ */
+export const presaleLaunchTransactions = new Map();
+
+/**
+ * Mutex locks for presale claims (per-token to prevent double claims)
+ * Maps token address -> Promise that resolves when processing is done
+ */
+const presaleClaimLocks = new Map>();
+
+// ============================================================================
+// Transaction Cleanup
+// ============================================================================
+
+/**
+ * Transaction expiry time in milliseconds (15 minutes)
+ */
+export const TRANSACTION_EXPIRY_MS = 15 * 60 * 1000;
+
+/**
+ * Clean up old presale launch transactions periodically
+ * Runs every minute and removes transactions older than 15 minutes
+ */
+export const startPresaleTransactionCleanup = () => {
+ setInterval(() => {
+ const now = Date.now();
+ for (const [id, tx] of presaleLaunchTransactions.entries()) {
+ if (now - tx.timestamp > TRANSACTION_EXPIRY_MS) {
+ presaleLaunchTransactions.delete(id);
+ }
+ }
+ }, 60 * 1000); // Run cleanup every minute
+};
+
+// ============================================================================
+// Lock Management
+// ============================================================================
+
+/**
+ * Acquire a presale claim lock for a specific token
+ * Prevents race conditions during presale claim processing
+ *
+ * @param token - The token address to lock
+ * @returns A function to release the lock
+ */
+export async function acquirePresaleClaimLock(token: string): Promise<() => void> {
+ const key = token.toLowerCase();
+
+ // Wait for any existing lock to be released
+ while (presaleClaimLocks.has(key)) {
+ await presaleClaimLocks.get(key);
+ }
+
+ // Create a new lock
+ let releaseLock: () => void;
+ const lockPromise = new Promise((resolve) => {
+ releaseLock = resolve;
+ });
+
+ presaleClaimLocks.set(key, lockPromise);
+
+ // Return the release function
+ return () => {
+ presaleClaimLocks.delete(key);
+ releaseLock!();
+ };
+}
diff --git a/ui/routes/claims.ts b/ui/routes/claims.ts
new file mode 100644
index 0000000..f102677
--- /dev/null
+++ b/ui/routes/claims.ts
@@ -0,0 +1,1038 @@
+/*
+ * Z Combinator - Solana Token Launchpad
+ * Copyright (C) 2025 Z Combinator
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { Router, Request, Response } from 'express';
+import * as crypto from 'crypto';
+import nacl from 'tweetnacl';
+import { Connection, Keypair, Transaction, PublicKey, ComputeBudgetProgram } from '@solana/web3.js';
+import {
+ createAssociatedTokenAccountIdempotentInstruction,
+ createMintToInstruction,
+ getMint,
+ TOKEN_PROGRAM_ID,
+ ASSOCIATED_TOKEN_PROGRAM_ID,
+ getAssociatedTokenAddress
+} from '@solana/spl-token';
+import bs58 from 'bs58';
+import type {
+ MintClaimRequestBody,
+ ConfirmClaimRequestBody,
+ MintClaimResponseBody,
+ ConfirmClaimResponseBody,
+ ClaimInfoResponseBody,
+ ErrorResponseBody
+} from '../types/server';
+import {
+ getTokenLaunchTime,
+ hasRecentClaim,
+ preRecordClaim,
+ getTokenCreatorWallet,
+ getDesignatedClaimByToken,
+ getVerifiedClaimWallets
+} from '../lib/db';
+import { calculateClaimEligibility } from '../lib/helius';
+import {
+ claimTransactions,
+ acquireClaimLock
+} from '../lib/claimService';
+
+/**
+ * Claim Routes
+ *
+ * Express router for token emission claim endpoints
+ */
+
+const router = Router();
+
+// ============================================================================
+// GET /claims/:tokenAddress - Get claim eligibility info
+// ============================================================================
+
+router.get('/:tokenAddress', async (
+ req: Request,
+ res: Response
+) => {
+ try {
+ const { tokenAddress } = req.params;
+ const walletAddress = req.query.wallet as string;
+
+ if (!walletAddress) {
+ return res.status(400).json({
+ error: 'Wallet address is required'
+ });
+ }
+
+ // Get token launch time from database
+ const tokenLaunchTime = await getTokenLaunchTime(tokenAddress);
+
+ if (!tokenLaunchTime) {
+ return res.status(404).json({
+ error: 'Token not found'
+ });
+ }
+
+ // Get claim data from on-chain with DB launch time
+ const claimData = await calculateClaimEligibility(tokenAddress, tokenLaunchTime);
+
+ const timeUntilNextClaim = Math.max(0, claimData.nextInflationTime.getTime() - new Date().getTime());
+
+ res.json({
+ walletAddress,
+ tokenAddress,
+ totalClaimed: claimData.totalClaimed.toString(),
+ availableToClaim: claimData.availableToClaim.toString(),
+ maxClaimableNow: claimData.maxClaimableNow.toString(),
+ tokensPerPeriod: '1000000',
+ inflationPeriods: claimData.inflationPeriods,
+ tokenLaunchTime,
+ nextInflationTime: claimData.nextInflationTime,
+ canClaimNow: claimData.canClaimNow,
+ timeUntilNextClaim,
+ });
+ } catch (error) {
+ console.error('Error fetching claim info:', error);
+ res.status(500).json({
+ error: 'Failed to fetch claim information'
+ });
+ }
+});
+
+// ============================================================================
+// POST /claims/mint - Create unsigned mint transaction for claiming
+// ============================================================================
+
+router.post('/mint', async (
+ req: Request, MintClaimResponseBody | ErrorResponseBody, MintClaimRequestBody>,
+ res: Response
+) => {
+ try {
+ console.log("claim/mint request body:", req.body);
+ const { tokenAddress, userWallet, claimAmount } = req.body;
+ console.log("mint request", tokenAddress, userWallet, claimAmount);
+
+ // Validate required environment variables
+ const RPC_URL = process.env.RPC_URL;
+ const PROTOCOL_PRIVATE_KEY = process.env.PROTOCOL_PRIVATE_KEY;
+ const ADMIN_WALLET = process.env.ADMIN_WALLET || 'PLACEHOLDER_ADMIN_WALLET';
+
+ if (!RPC_URL) {
+ const errorResponse = { error: 'RPC_URL not configured' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(500).json(errorResponse);
+ }
+
+ if (!PROTOCOL_PRIVATE_KEY) {
+ const errorResponse = { error: 'PROTOCOL_PRIVATE_KEY not configured' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(500).json(errorResponse);
+ }
+
+ if (!ADMIN_WALLET || ADMIN_WALLET === 'PLACEHOLDER_ADMIN_WALLET') {
+ const errorResponse = { error: 'ADMIN_WALLET not configured' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(500).json(errorResponse);
+ }
+
+ // Validate required parameters
+ if (!tokenAddress || !userWallet || !claimAmount) {
+ const errorResponse = { error: 'Missing required parameters' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Initialize connection
+ const connection = new Connection(RPC_URL, "confirmed");
+ const protocolKeypair = Keypair.fromSecretKey(bs58.decode(PROTOCOL_PRIVATE_KEY));
+ const tokenMint = new PublicKey(tokenAddress);
+ const userPublicKey = new PublicKey(userWallet);
+ const adminPublicKey = new PublicKey(ADMIN_WALLET);
+
+ // Get token launch time from database
+ const tokenLaunchTime = await getTokenLaunchTime(tokenAddress);
+
+ if (!tokenLaunchTime) {
+ const errorResponse = { error: 'Token not found' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(404).json(errorResponse);
+ }
+
+ // Validate claim amount input
+ if (!claimAmount || typeof claimAmount !== 'string') {
+ const errorResponse = { error: 'Invalid claim amount: must be a string' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ if (!/^\d+$/.test(claimAmount)) {
+ const errorResponse = { error: 'Invalid claim amount: must contain only digits' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ const requestedAmount = BigInt(claimAmount);
+
+ // Check for valid amount bounds
+ if (requestedAmount <= BigInt(0)) {
+ const errorResponse = { error: 'Invalid claim amount: must be greater than 0' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ if (requestedAmount > BigInt(Number.MAX_SAFE_INTEGER)) {
+ const errorResponse = { error: 'Invalid claim amount: exceeds maximum safe value' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Calculate 90/10 split (claimers get 90%, admin gets 10%)
+ const claimersTotal = (requestedAmount * BigInt(9)) / BigInt(10);
+ const adminAmount = requestedAmount - claimersTotal; // Ensures total equals exactly requestedAmount
+
+ // Validate claim eligibility from on-chain data
+ const claimEligibility = await calculateClaimEligibility(tokenAddress, tokenLaunchTime);
+
+ if (requestedAmount > claimEligibility.availableToClaim) {
+ const errorResponse = { error: 'Requested amount exceeds available claim amount' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Check if this is a designated token and validate the claimer
+ const designatedClaim = await getDesignatedClaimByToken(tokenAddress);
+
+ if (designatedClaim) {
+ // This is a designated token
+ const { verifiedWallet, embeddedWallet, originalLauncher } = await getVerifiedClaimWallets(tokenAddress);
+
+ // Block the original launcher
+ if (userWallet === originalLauncher) {
+ const errorResponse = { error: 'This token has been designated to someone else. The designated user must claim it.' };
+ console.log("claim/mint error response: Original launcher blocked from claiming designated token");
+ return res.status(403).json(errorResponse);
+ }
+
+ // Check if the current user is authorized
+ if (verifiedWallet || embeddedWallet) {
+ if (userWallet !== verifiedWallet && userWallet !== embeddedWallet) {
+ const errorResponse = { error: 'Only the verified designated user can claim this token' };
+ console.log("claim/mint error response: Unauthorized wallet attempting to claim designated token");
+ return res.status(403).json(errorResponse);
+ }
+ } else {
+ const errorResponse = { error: 'The designated user must verify their social accounts before claiming' };
+ console.log("claim/mint error response: Designated user not yet verified");
+ return res.status(403).json(errorResponse);
+ }
+ } else {
+ // Normal token - only creator can claim
+ const creatorWallet = await getTokenCreatorWallet(tokenAddress);
+ if (!creatorWallet) {
+ const errorResponse = { error: 'Token creator not found' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ if (userWallet !== creatorWallet.trim()) {
+ const errorResponse = { error: 'Only the token creator can claim rewards' };
+ console.log("claim/mint error response: Non-creator attempting to claim");
+ return res.status(403).json(errorResponse);
+ }
+ }
+
+ // User can claim now if they have available tokens to claim
+ if (claimEligibility.availableToClaim <= BigInt(0)) {
+ const errorResponse = {
+ error: 'No tokens available to claim yet',
+ nextInflationTime: claimEligibility.nextInflationTime
+ };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Get mint info to calculate amount with decimals
+ const mintInfo = await getMint(connection, tokenMint);
+ const decimals = mintInfo.decimals;
+ const adminAmountWithDecimals = adminAmount * BigInt(10 ** decimals);
+
+ // Verify protocol has mint authority
+ if (!mintInfo.mintAuthority || !mintInfo.mintAuthority.equals(protocolKeypair.publicKey)) {
+ const errorResponse = { error: 'Protocol does not have mint authority for this token' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Hardcoded emission splits - supports N participants
+ // Currently configured for 2 participants: Developer (90%) + Admin fee (10%)
+
+ // Get the creator wallet (developer)
+ const creatorWallet = await getTokenCreatorWallet(tokenAddress);
+ if (!creatorWallet) {
+ const errorResponse = { error: 'Token creator not found' };
+ console.log("claim/mint error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Calculate split amounts and prepare recipients
+ interface SplitRecipient {
+ wallet: string;
+ amount: bigint;
+ amountWithDecimals: bigint;
+ label?: string;
+ }
+
+ // Hardcoded split configuration
+ // claimersTotal represents the 90% portion for claimers (excluding 10% admin fee)
+ // For now: 100% of claimersTotal goes to the developer/creator
+ const splitRecipients: SplitRecipient[] = [
+ {
+ wallet: creatorWallet.trim(),
+ amount: claimersTotal, // 100% of the 90% claimers portion = 90% total
+ amountWithDecimals: claimersTotal * BigInt(10 ** decimals),
+ label: 'Developer'
+ }
+ ];
+
+ console.log(`Hardcoded emission split: 100% of claimers portion (90% total) to creator ${creatorWallet}`)
+
+ // Get admin token account address
+ const adminTokenAccount = await getAssociatedTokenAddress(
+ tokenMint,
+ adminPublicKey,
+ true // allowOwnerOffCurve
+ );
+
+ // Create mint transaction
+ const transaction = new Transaction();
+
+ // Add idempotent instruction to create admin account (user pays)
+ const createAdminAccountInstruction = createAssociatedTokenAccountIdempotentInstruction(
+ userPublicKey, // payer
+ adminTokenAccount,
+ adminPublicKey, // owner
+ tokenMint
+ );
+ transaction.add(createAdminAccountInstruction);
+
+ // Create token accounts and mint instructions for each split recipient
+ for (const recipient of splitRecipients) {
+ const recipientPublicKey = new PublicKey(recipient.wallet);
+ const recipientTokenAccount = await getAssociatedTokenAddress(
+ tokenMint,
+ recipientPublicKey
+ );
+
+ // Add idempotent instruction to create recipient account (user pays)
+ const createRecipientAccountInstruction = createAssociatedTokenAccountIdempotentInstruction(
+ userPublicKey, // payer
+ recipientTokenAccount,
+ recipientPublicKey, // owner
+ tokenMint
+ );
+ transaction.add(createRecipientAccountInstruction);
+
+ // Add mint instruction for this recipient
+ const recipientMintInstruction = createMintToInstruction(
+ tokenMint,
+ recipientTokenAccount,
+ protocolKeypair.publicKey,
+ recipient.amountWithDecimals
+ );
+ transaction.add(recipientMintInstruction);
+ }
+
+ // Add mint instruction for admin (10%)
+ const adminMintInstruction = createMintToInstruction(
+ tokenMint,
+ adminTokenAccount,
+ protocolKeypair.publicKey,
+ adminAmountWithDecimals
+ );
+ transaction.add(adminMintInstruction);
+
+ // Get latest blockhash and set fee payer to user
+ const { blockhash } = await connection.getLatestBlockhash("confirmed");
+ transaction.recentBlockhash = blockhash;
+ transaction.feePayer = userPublicKey;
+
+ // Clean up old transactions FIRST (older than 5 minutes) to prevent race conditions
+ const fiveMinutesAgo = Date.now() - (5 * 60 * 1000);
+ for (const [key, data] of claimTransactions.entries()) {
+ if (data.timestamp < fiveMinutesAgo) {
+ claimTransactions.delete(key);
+ }
+ }
+
+ // Create a unique key for this transaction with random component to prevent collisions
+ const transactionKey = `${tokenAddress}_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
+
+ // Store transaction data for later confirmation
+ claimTransactions.set(transactionKey, {
+ tokenAddress,
+ userWallet,
+ claimAmount,
+ mintDecimals: decimals,
+ timestamp: Date.now()
+ });
+
+ // Store split recipients and admin info for validation in confirm endpoint
+ const transactionMetadata = {
+ splitRecipients: splitRecipients.map(r => ({
+ wallet: r.wallet,
+ amount: r.amount.toString(),
+ label: r.label
+ })),
+ adminAmount: adminAmount.toString(),
+ adminTokenAccount: adminTokenAccount.toString()
+ };
+ claimTransactions.set(`${transactionKey}_metadata`, transactionMetadata as any);
+
+ // Serialize transaction for user to sign
+ const serializedTransaction = transaction.serialize({
+ requireAllSignatures: false
+ });
+
+ const successResponse = {
+ success: true as const,
+ transaction: bs58.encode(serializedTransaction),
+ transactionKey,
+ claimAmount: requestedAmount.toString(),
+ splitRecipients: splitRecipients.map(r => ({
+ wallet: r.wallet,
+ amount: r.amount.toString(),
+ label: r.label
+ })),
+ adminAmount: adminAmount.toString(),
+ mintDecimals: decimals,
+ message: 'Sign this transaction and submit to /claims/confirm'
+ };
+
+ console.log("claim/mint successful response:", successResponse);
+ res.json(successResponse);
+
+ } catch (error) {
+ console.error('Mint transaction creation error:', error);
+ const errorResponse = {
+ error: 'Failed to create mint transaction',
+ details: error instanceof Error ? error.message : 'Unknown error'
+ };
+ console.log("claim/mint error response:", errorResponse);
+ res.status(500).json(errorResponse);
+ }
+});
+
+// ============================================================================
+// POST /claims/confirm - Confirm claim transaction
+// ============================================================================
+
+router.post('/confirm', async (
+ req: Request, ConfirmClaimResponseBody | ErrorResponseBody, ConfirmClaimRequestBody>,
+ res: Response
+) => {
+ let releaseLock: (() => void) | null = null;
+
+ try {
+ console.log("claim/confirm request body:", req.body);
+ const { signedTransaction, transactionKey } = req.body;
+
+ // Validate required parameters
+ if (!signedTransaction || !transactionKey) {
+ const errorResponse = { error: 'Missing required fields: signedTransaction and transactionKey' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Retrieve the transaction data from memory
+ const claimData = claimTransactions.get(transactionKey);
+ if (!claimData) {
+ const errorResponse = { error: 'Transaction data not found. Please call /claims/mint first.' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Retrieve the metadata with split amounts
+ const metadata = claimTransactions.get(`${transactionKey}_metadata`) as any;
+ if (!metadata) {
+ const errorResponse = { error: 'Transaction metadata not found. Please call /claims/mint first.' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Acquire lock IMMEDIATELY after getting claim data to prevent race conditions
+ releaseLock = await acquireClaimLock(claimData.tokenAddress);
+
+ // Check if ANY user has claimed this token recently
+ const hasRecent = await hasRecentClaim(claimData.tokenAddress, 360);
+ if (hasRecent) {
+ const errorResponse = { error: 'This token has been claimed recently. Please wait before claiming again.' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Pre-record the claim in database for audit trail
+ // Global token lock prevents race conditions
+ await preRecordClaim(
+ claimData.userWallet,
+ claimData.tokenAddress,
+ claimData.claimAmount
+ );
+
+ // Validate required environment variables
+ const RPC_URL = process.env.RPC_URL;
+ const PROTOCOL_PRIVATE_KEY = process.env.PROTOCOL_PRIVATE_KEY;
+ const ADMIN_WALLET = process.env.ADMIN_WALLET || 'PLACEHOLDER_ADMIN_WALLET';
+
+ if (!RPC_URL || !PROTOCOL_PRIVATE_KEY) {
+ const errorResponse = { error: 'Server configuration error' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(500).json(errorResponse);
+ }
+
+ if (!ADMIN_WALLET || ADMIN_WALLET === 'PLACEHOLDER_ADMIN_WALLET') {
+ const errorResponse = { error: 'ADMIN_WALLET not configured' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(500).json(errorResponse);
+ }
+
+ // Initialize connection and keypair
+ const connection = new Connection(RPC_URL, "confirmed");
+ const protocolKeypair = Keypair.fromSecretKey(bs58.decode(PROTOCOL_PRIVATE_KEY));
+
+ // Re-validate claim eligibility (security check)
+ const tokenLaunchTime = await getTokenLaunchTime(claimData.tokenAddress);
+ if (!tokenLaunchTime) {
+ const errorResponse = { error: 'Token not found' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(404).json(errorResponse);
+ }
+
+ const claimEligibility = await calculateClaimEligibility(
+ claimData.tokenAddress,
+ tokenLaunchTime
+ );
+
+ const requestedAmount = BigInt(claimData.claimAmount);
+ if (requestedAmount > claimEligibility.availableToClaim) {
+ const errorResponse = { error: 'Claim eligibility has changed. Requested amount exceeds available claim amount.' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ if (claimEligibility.availableToClaim <= BigInt(0)) {
+ const errorResponse = { error: 'No tokens available to claim anymore' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Check if this token has a designated claim
+ const designatedClaim = await getDesignatedClaimByToken(claimData.tokenAddress);
+
+ let authorizedClaimWallet: string | null = null;
+ let isDesignated = false;
+
+ if (designatedClaim) {
+ // This is a designated token
+ isDesignated = true;
+
+ // Check if the designated user has verified their account
+ const { verifiedWallet, embeddedWallet, originalLauncher } = await getVerifiedClaimWallets(claimData.tokenAddress);
+
+ // Block the original launcher from claiming designated tokens
+ if (claimData.userWallet === originalLauncher) {
+ const errorResponse = { error: 'This token has been designated to someone else. The designated user must claim it.' };
+ console.log("claim/confirm error response: Original launcher blocked from claiming designated token");
+ return res.status(403).json(errorResponse);
+ }
+
+ // Check if the current user is authorized to claim
+ if (verifiedWallet || embeddedWallet) {
+ // Allow either the verified wallet or embedded wallet to claim
+ if (claimData.userWallet === verifiedWallet || claimData.userWallet === embeddedWallet) {
+ authorizedClaimWallet = claimData.userWallet;
+ console.log("Designated user authorized to claim:", { userWallet: claimData.userWallet, verifiedWallet, embeddedWallet });
+ } else {
+ const errorResponse = { error: 'Only the verified designated user can claim this token' };
+ console.log("claim/confirm error response: Unauthorized wallet attempting to claim designated token");
+ return res.status(403).json(errorResponse);
+ }
+ } else {
+ // Designated user hasn't verified yet
+ const errorResponse = { error: 'The designated user must verify their social accounts before claiming' };
+ console.log("claim/confirm error response: Designated user not yet verified");
+ return res.status(403).json(errorResponse);
+ }
+ } else {
+ // Normal token - only creator can claim
+ const rawCreatorWallet = await getTokenCreatorWallet(claimData.tokenAddress);
+ if (!rawCreatorWallet) {
+ const errorResponse = { error: 'Token creator not found' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ const creatorWallet = rawCreatorWallet.trim();
+ if (claimData.userWallet !== creatorWallet) {
+ const errorResponse = { error: 'Only the token creator can claim rewards' };
+ console.log("claim/confirm error response: Non-creator attempting to claim");
+ return res.status(403).json(errorResponse);
+ }
+
+ authorizedClaimWallet = claimData.userWallet;
+ console.log("User is the token creator:", claimData.userWallet);
+ }
+
+ // At this point, authorizedClaimWallet is set to the wallet allowed to claim
+ console.log("Authorized claim wallet:", authorizedClaimWallet);
+
+ // Deserialize the user-signed transaction
+ const transactionBuffer = bs58.decode(signedTransaction);
+ const transaction = Transaction.from(transactionBuffer);
+
+ // SECURITY: Validate transaction has recent blockhash to prevent replay attacks
+ if (!transaction.recentBlockhash) {
+ const errorResponse = { error: 'Invalid transaction: missing blockhash' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Check if blockhash is still valid (within last 150 slots ~60 seconds)
+ const isBlockhashValid = await connection.isBlockhashValid(
+ transaction.recentBlockhash,
+ { commitment: 'confirmed' }
+ );
+
+ if (!isBlockhashValid) {
+ const errorResponse = { error: 'Invalid transaction: blockhash is expired. Please create a new transaction.' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // CRITICAL SECURITY: Verify the transaction is cryptographically signed by the authorized wallet
+ console.log("About to create PublicKey from authorizedClaimWallet:", { authorizedClaimWallet });
+ let authorizedPublicKey;
+ try {
+ authorizedPublicKey = new PublicKey(authorizedClaimWallet!);
+ console.log("Successfully created authorizedPublicKey:", authorizedPublicKey.toBase58());
+ } catch (error) {
+ console.error("Error creating PublicKey from authorizedClaimWallet:", error);
+ const errorResponse = { error: 'Invalid authorized wallet format' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+ let validAuthorizedSigner = false;
+
+ // Compile the transaction message for signature verification
+ const message = transaction.compileMessage();
+ const messageBytes = message.serialize();
+
+ // Find the authorized wallet's signer index
+ const authorizedSignerIndex = message.accountKeys.findIndex(key =>
+ key.equals(authorizedPublicKey)
+ );
+
+ if (authorizedSignerIndex >= 0 && authorizedSignerIndex < transaction.signatures.length) {
+ const signature = transaction.signatures[authorizedSignerIndex];
+ if (signature.signature) {
+ // CRITICAL: Verify the signature is cryptographically valid using nacl
+ const isValid = nacl.sign.detached.verify(
+ messageBytes,
+ signature.signature,
+ authorizedPublicKey.toBytes()
+ );
+ validAuthorizedSigner = isValid;
+ }
+ }
+
+ if (!validAuthorizedSigner) {
+ const errorResponse = { error: isDesignated ? 'Invalid transaction: must be cryptographically signed by the verified designated wallet' : 'Invalid transaction: must be cryptographically signed by the token creator wallet' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // CRITICAL SECURITY: Derive the creator's Associated Token Account (ATA) address
+ console.log("About to create mintPublicKey from tokenAddress:", { tokenAddress: claimData.tokenAddress });
+ let mintPublicKey;
+ try {
+ mintPublicKey = new PublicKey(claimData.tokenAddress);
+ console.log("Successfully created mintPublicKey:", mintPublicKey.toBase58());
+ } catch (error) {
+ console.error("Error creating PublicKey from tokenAddress:", error);
+ const errorResponse = { error: 'Invalid token address format' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Mathematically derive the creator's ATA address (no blockchain calls)
+ console.log("About to create PDA with program constants");
+ console.log("TOKEN_PROGRAM_ID:", TOKEN_PROGRAM_ID.toBase58());
+ console.log("ASSOCIATED_TOKEN_PROGRAM_ID:", ASSOCIATED_TOKEN_PROGRAM_ID.toBase58());
+
+ const [authorizedTokenAccountAddress] = PublicKey.findProgramAddressSync(
+ [
+ authorizedPublicKey.toBuffer(),
+ TOKEN_PROGRAM_ID.toBuffer(), // SPL Token program
+ mintPublicKey.toBuffer()
+ ],
+ ASSOCIATED_TOKEN_PROGRAM_ID // Associated Token program
+ );
+ console.log("Successfully created authorizedTokenAccountAddress:", authorizedTokenAccountAddress.toBase58());
+
+ // CRITICAL SECURITY: Derive the admin's ATA address
+ const adminPublicKey = new PublicKey(ADMIN_WALLET);
+ const [adminTokenAccountAddress] = PublicKey.findProgramAddressSync(
+ [
+ adminPublicKey.toBuffer(),
+ TOKEN_PROGRAM_ID.toBuffer(),
+ mintPublicKey.toBuffer()
+ ],
+ ASSOCIATED_TOKEN_PROGRAM_ID
+ );
+ console.log("Successfully created adminTokenAccountAddress:", adminTokenAccountAddress.toBase58());
+
+ // Define safe program IDs that wallets may add for optimization
+ const COMPUTE_BUDGET_PROGRAM_ID = ComputeBudgetProgram.programId;
+ const LIGHTHOUSE_PROGRAM_ID = new PublicKey("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95");
+
+ // CRITICAL SECURITY: Validate ONLY allowed instruction types are present
+ // This prevents injection of malicious instructions that would receive protocol signature
+ console.log("Validating transaction instruction types...");
+ for (let i = 0; i < transaction.instructions.length; i++) {
+ const instruction = transaction.instructions[i];
+ const programId = instruction.programId;
+
+ // Allow safe programs: TOKEN_PROGRAM, ASSOCIATED_TOKEN_PROGRAM, ComputeBudget, and Lighthouse
+ if (!programId.equals(TOKEN_PROGRAM_ID) &&
+ !programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID) &&
+ !programId.equals(COMPUTE_BUDGET_PROGRAM_ID) &&
+ !programId.equals(LIGHTHOUSE_PROGRAM_ID)) {
+ const errorResponse = {
+ error: 'Invalid transaction: unauthorized program instruction detected',
+ details: `Instruction ${i} uses unauthorized program: ${programId.toBase58()}`
+ };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Validate TOKEN_PROGRAM instructions are only MintTo (opcode 7)
+ if (programId.equals(TOKEN_PROGRAM_ID)) {
+ if (instruction.data.length < 1 || instruction.data[0] !== 7) {
+ const errorResponse = {
+ error: 'Invalid transaction: unauthorized token instruction detected',
+ details: `Instruction ${i} has invalid opcode: ${instruction.data[0]}`
+ };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+ }
+
+ // Validate ASSOCIATED_TOKEN_PROGRAM instructions are only CreateIdempotent (opcode 1)
+ if (programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) {
+ if (instruction.data.length < 1 || instruction.data[0] !== 1) {
+ const errorResponse = {
+ error: 'Invalid transaction: unauthorized ATA instruction detected',
+ details: `Instruction ${i} has invalid ATA opcode: ${instruction.data[0]}`
+ };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+ }
+ }
+ console.log("✓ All instruction types validated - only authorized programs and opcodes");
+
+ // CRITICAL SECURITY: Validate mint instructions match expected split recipients + admin
+ const expectedSplitRecipients = metadata.splitRecipients || [];
+ const expectedRecipientCount = expectedSplitRecipients.length + 1; // splits + admin
+ let mintInstructionCount = 0;
+
+ console.log("Validating transaction with", transaction.instructions.length, "instructions");
+ console.log("Expected recipients:", {
+ splitRecipients: expectedSplitRecipients.length,
+ admin: 1,
+ total: expectedRecipientCount
+ });
+
+ // First pass: count mint instructions
+ for (const instruction of transaction.instructions) {
+ if (instruction.programId.equals(TOKEN_PROGRAM_ID) &&
+ instruction.data.length >= 9 &&
+ instruction.data[0] === 7) {
+ mintInstructionCount++;
+ }
+ }
+
+ // Validate correct number of mint instructions
+ if (mintInstructionCount === 0) {
+ const errorResponse = { error: 'Invalid transaction: no mint instructions found' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ if (mintInstructionCount !== expectedRecipientCount) {
+ const errorResponse = {
+ error: `Invalid transaction: expected ${expectedRecipientCount} mint instructions (${expectedSplitRecipients.length} recipients + 1 admin), found ${mintInstructionCount}`
+ };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // Get the token decimals to convert claim amounts to base units
+ const mintInfo = await getMint(connection, mintPublicKey);
+ const expectedAdminAmountWithDecimals = BigInt(metadata.adminAmount) * BigInt(10 ** mintInfo.decimals);
+
+ // Create expected recipient map with token account addresses and amounts
+ const expectedRecipients = new Map();
+
+ // Add all split recipients
+ for (const recipient of expectedSplitRecipients) {
+ const recipientPublicKey = new PublicKey(recipient.wallet);
+ const recipientTokenAccount = await getAssociatedTokenAddress(
+ mintPublicKey,
+ recipientPublicKey
+ );
+ const expectedAmount = BigInt(recipient.amount) * BigInt(10 ** mintInfo.decimals);
+ expectedRecipients.set(recipientTokenAccount.toBase58(), expectedAmount);
+ }
+
+ // Add admin recipient
+ expectedRecipients.set(adminTokenAccountAddress.toBase58(), expectedAdminAmountWithDecimals);
+
+ console.log("Expected recipients with amounts:", {
+ splitRecipients: expectedSplitRecipients.map((r: any) => ({
+ wallet: r.wallet,
+ amount: r.amount,
+ amountWithDecimals: (BigInt(r.amount) * BigInt(10 ** mintInfo.decimals)).toString()
+ })),
+ admin: {
+ wallet: ADMIN_WALLET,
+ amount: metadata.adminAmount,
+ amountWithDecimals: expectedAdminAmountWithDecimals.toString()
+ }
+ });
+
+ // Track which recipients have been validated
+ const validatedRecipients = new Set();
+
+ // Second pass: validate ALL mint instructions match expected recipients
+ for (let i = 0; i < transaction.instructions.length; i++) {
+ const instruction = transaction.instructions[i];
+ console.log(`Instruction ${i}:`, {
+ programId: instruction.programId.toString(),
+ dataLength: instruction.data.length,
+ keysLength: instruction.keys.length,
+ firstByte: instruction.data.length > 0 ? instruction.data[0] : undefined
+ });
+
+ // Allow Compute Budget instructions (for priority fees and compute units)
+ if (instruction.programId.equals(COMPUTE_BUDGET_PROGRAM_ID)) {
+ continue;
+ }
+
+ // Allow ATA creation instructions (created by server in /claims/mint)
+ if (instruction.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) {
+ continue;
+ }
+
+ // Allow Lighthouse instructions (for transaction optimization)
+ if (instruction.programId.equals(LIGHTHOUSE_PROGRAM_ID)) {
+ continue;
+ }
+
+ // Check if this is a mintTo instruction (SPL Token program)
+ if (instruction.programId.equals(TOKEN_PROGRAM_ID)) {
+ // Parse mintTo instruction - first byte is instruction type (7 = mintTo)
+ if (instruction.data.length >= 9 && instruction.data[0] === 7) {
+ console.log("Found mintTo instruction!");
+
+ // Validate mint amount (bytes 1-8 are amount as little-endian u64)
+ const mintAmount = instruction.data.readBigUInt64LE(1);
+
+ // Validate complete mint instruction structure
+ if (instruction.keys.length >= 3) {
+ const mintAccount = instruction.keys[0].pubkey; // mint account
+ const recipientAccount = instruction.keys[1].pubkey; // recipient token account
+ const mintAuthority = instruction.keys[2].pubkey; // mint authority
+
+ console.log("Mint instruction validation:", {
+ mintAccount: mintAccount.toBase58(),
+ expectedMint: mintPublicKey.toBase58(),
+ mintMatches: mintAccount.equals(mintPublicKey),
+ recipientAccount: recipientAccount.toBase58(),
+ mintAmount: mintAmount.toString(),
+ mintAuthority: mintAuthority.toBase58(),
+ expectedAuthority: protocolKeypair.publicKey.toBase58(),
+ authorityMatches: mintAuthority.equals(protocolKeypair.publicKey)
+ });
+
+ // CRITICAL SECURITY: Validate mint account is correct
+ if (!mintAccount.equals(mintPublicKey)) {
+ const errorResponse = { error: 'Invalid transaction: mint instruction has wrong token mint' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // CRITICAL SECURITY: Validate mint authority is protocol keypair
+ if (!mintAuthority.equals(protocolKeypair.publicKey)) {
+ const errorResponse = { error: 'Invalid transaction: mint authority must be protocol wallet' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+
+ // CRITICAL SECURITY: Validate recipient and amount match expected
+ const recipientKey = recipientAccount.toBase58();
+ const expectedAmount = expectedRecipients.get(recipientKey);
+
+ if (expectedAmount === undefined) {
+ const errorResponse = { error: 'Invalid transaction: mint instruction has unauthorized recipient' };
+ console.log("claim/confirm error response:", errorResponse);
+ console.log("Unauthorized recipient:", {
+ recipientAccount: recipientKey,
+ expectedRecipients: Array.from(expectedRecipients.keys())
+ });
+ return res.status(400).json(errorResponse);
+ }
+
+ if (mintAmount !== expectedAmount) {
+ const errorResponse = { error: 'Invalid transaction: mint instruction has incorrect amount' };
+ console.log("claim/confirm error response:", errorResponse);
+ console.log("Amount mismatch:", {
+ recipientAccount: recipientKey,
+ actualAmount: mintAmount.toString(),
+ expectedAmount: expectedAmount.toString()
+ });
+ return res.status(400).json(errorResponse);
+ }
+
+ // Mark this recipient as validated
+ validatedRecipients.add(recipientKey);
+ console.log("✓ Valid mint instruction found for recipient:", recipientKey);
+ }
+ } else {
+ // SECURITY: Reject any TOKEN_PROGRAM instruction that is not mintTo (opcode 7)
+ const errorResponse = { error: 'Invalid transaction: contains unauthorized token program instructions' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+ } else {
+ // SECURITY: Reject any unknown program instruction (defense-in-depth)
+ const errorResponse = { error: 'Invalid transaction: contains unexpected instructions' };
+ console.log("claim/confirm error response:", errorResponse);
+ return res.status(400).json(errorResponse);
+ }
+ }
+
+ // CRITICAL SECURITY: Ensure ALL expected recipients were validated
+ if (validatedRecipients.size !== expectedRecipients.size) {
+ const errorResponse = { error: 'Invalid transaction: missing mint instructions for some recipients' };
+ console.log("claim/confirm error response:", errorResponse);
+ console.log("Validation incomplete:", {
+ validated: validatedRecipients.size,
+ expected: expectedRecipients.size,
+ missing: Array.from(expectedRecipients.keys()).filter(k => !validatedRecipients.has(k))
+ });
+ return res.status(400).json(errorResponse);
+ }
+
+ console.log("✓ All mint instructions validated successfully");
+
+ // Add protocol signature (mint authority)
+ transaction.partialSign(protocolKeypair);
+
+ // Send the fully signed transaction with proper configuration
+ const signature = await connection.sendRawTransaction(
+ transaction.serialize(),
+ {
+ skipPreflight: false,
+ preflightCommitment: 'processed'
+ }
+ );
+
+ // Poll for confirmation status
+ const maxAttempts = 20;
+ const delayMs = 200; // 200ms between polls
+ let attempts = 0;
+ let confirmation;
+
+ while (attempts < maxAttempts) {
+ const result = await connection.getSignatureStatus(signature, {
+ searchTransactionHistory: true
+ });
+
+ console.log(`Attempt ${attempts + 1}: Transaction status:`, JSON.stringify(result, null, 2));
+
+ if (!result || !result.value) {
+ // Transaction not found yet, wait and retry
+ attempts++;
+ await new Promise(resolve => setTimeout(resolve, delayMs));
+ continue;
+ }
+
+ if (result.value.err) {
+ throw new Error(`Transaction failed: ${JSON.stringify(result.value.err)}`);
+ }
+
+ // If confirmed or finalized, we're done
+ if (result.value.confirmationStatus === 'confirmed' ||
+ result.value.confirmationStatus === 'finalized') {
+ confirmation = result.value;
+ break;
+ }
+
+ // Still processing, wait and retry
+ attempts++;
+ await new Promise(resolve => setTimeout(resolve, delayMs));
+ }
+
+ if (!confirmation) {
+ throw new Error('Transaction confirmation timeout');
+ }
+
+
+ // Get split recipients from metadata before cleanup
+ const splitRecipients = metadata.splitRecipients || [];
+
+ // Clean up the transaction data from memory
+ claimTransactions.delete(transactionKey);
+ claimTransactions.delete(`${transactionKey}_metadata`);
+
+ const successResponse = {
+ success: true as const,
+ transactionSignature: signature,
+ tokenAddress: claimData.tokenAddress,
+ claimAmount: claimData.claimAmount,
+ splitRecipients,
+ confirmation
+ };
+
+ console.log("claim/confirm successful response:", successResponse);
+ res.json(successResponse);
+
+ } catch (error) {
+ console.error('Confirm claim error:', error);
+ const errorResponse = {
+ error: error instanceof Error ? error.message : 'Failed to confirm claim'
+ };
+ console.log("claim/confirm error response:", errorResponse);
+ res.status(500).json(errorResponse);
+ } finally {
+ // Always release the lock, even if an error occurred
+ if (releaseLock) {
+ releaseLock();
+ }
+ }
+});
+
+export default router;
diff --git a/ui/routes/presale.ts b/ui/routes/presale.ts
new file mode 100644
index 0000000..d70cde7
--- /dev/null
+++ b/ui/routes/presale.ts
@@ -0,0 +1,1232 @@
+/*
+ * Z Combinator - Solana Token Launchpad
+ * Copyright (C) 2025 Z Combinator
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { Router, Request, Response } from 'express';
+import { Connection, Keypair, Transaction, PublicKey, ComputeBudgetProgram, SystemProgram } from '@solana/web3.js';
+import {
+ getAssociatedTokenAddress,
+ createTransferInstruction,
+ getMint,
+ getAccount,
+ TOKEN_PROGRAM_ID,
+ ASSOCIATED_TOKEN_PROGRAM_ID,
+ createAssociatedTokenAccountInstruction
+} from '@solana/spl-token';
+import bs58 from 'bs58';
+import BN from 'bn.js';
+import nacl from 'tweetnacl';
+import { DynamicBondingCurveClient } from "@meteora-ag/dynamic-bonding-curve-sdk";
+import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
+import * as crypto from 'crypto';
+import {
+ getPresaleByTokenAddress,
+ getUserPresaleContribution,
+ getPresaleBids,
+ getTotalPresaleBids,
+ recordPresaleBid,
+ getPresaleBidBySignature,
+ updatePresaleStatus
+} from '../lib/db';
+import {
+ calculateVestingInfo,
+ recordPresaleClaim,
+ getPresaleStats,
+ initializePresaleClaims,
+ type VestingInfo
+} from '../lib/presaleVestingService';
+import { decryptEscrowKeypair } from '../lib/presale-escrow';
+import { decrypt } from '../lib/crypto';
+import {
+ isValidSolanaAddress,
+ isValidTransactionSignature
+} from '../lib/validation';
+import { verifyPresaleTokenTransaction } from '../lib/solana-verification';
+import {
+ presaleClaimTransactions,
+ presaleLaunchTransactions,
+ acquirePresaleClaimLock,
+ startPresaleTransactionCleanup
+} from '../lib/presaleService';
+
+/**
+ * Presale Routes
+ *
+ * Express router for presale-related endpoints including:
+ * - Presale claims (prepare, confirm, info)
+ * - Presale stats and bids
+ * - Presale launch
+ */
+
+const router = Router();
+
+// Presale claim rate limiter (more lenient for claim operations)
+const presaleClaimLimiter = rateLimit({
+ windowMs: 1 * 60 * 1000, // 1 minute
+ max: 30, // 30 requests per minute
+ keyGenerator: (req) => {
+ const cfIp = req.headers['cf-connecting-ip'];
+ if (typeof cfIp === 'string') return ipKeyGenerator(cfIp);
+ if (Array.isArray(cfIp)) return ipKeyGenerator(cfIp[0]);
+ return ipKeyGenerator(req.ip || 'unknown');
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: 'Too many claim requests, please wait a moment.'
+});
+
+// Start transaction cleanup
+startPresaleTransactionCleanup();
+
+// Get presale claim info endpoint
+router.get('/:tokenAddress/claims/:wallet', presaleClaimLimiter, async (req: Request, res: Response) => {
+ try {
+ const { tokenAddress, wallet } = req.params;
+
+ if (!tokenAddress || !wallet) {
+ return res.status(400).json({
+ success: false,
+ error: 'Token address and wallet are required'
+ });
+ }
+
+ // Validate Solana addresses
+ if (!isValidSolanaAddress(tokenAddress)) {
+ return res.status(400).json({
+ success: false,
+ error: 'Invalid token address format'
+ });
+ }
+
+ if (!isValidSolanaAddress(wallet)) {
+ return res.status(400).json({
+ success: false,
+ error: 'Invalid wallet address format'
+ });
+ }
+
+ const vestingInfo: VestingInfo = await calculateVestingInfo(tokenAddress, wallet);
+
+ res.json({ success: true, ...vestingInfo });
+ } catch (error) {
+ console.error('Error fetching presale claim info:', error);
+
+ // Handle specific error types
+ if (error instanceof Error) {
+ if (error.message.includes('No allocation')) {
+ return res.status(404).json({
+ success: false,
+ error: 'No allocation found for this wallet'
+ });
+ }
+ if (error.message.includes('not launched')) {
+ return res.status(400).json({
+ success: false,
+ error: 'Presale not launched yet'
+ });
+ }
+ }
+
+ res.status(500).json({
+ success: false,
+ error: error instanceof Error ? error.message : 'Failed to fetch claim info'
+ });
+ }
+});
+
+// Create unsigned presale claim transaction
+router.post('/:tokenAddress/claims/prepare', presaleClaimLimiter, async (req: Request, res: Response) => {
+ let releaseLock: (() => void) | null = null;
+
+ try {
+ const { tokenAddress } = req.params;
+ const { userWallet } = req.body;
+
+ if (!userWallet) {
+ return res.status(400).json({ error: 'User wallet is required' });
+ }
+
+ // Validate Solana addresses
+ if (!isValidSolanaAddress(tokenAddress)) {
+ return res.status(400).json({ error: 'Invalid token address format' });
+ }
+
+ if (!isValidSolanaAddress(userWallet)) {
+ return res.status(400).json({ error: 'Invalid user wallet address format' });
+ }
+
+ // Acquire lock for this token (using presale-specific lock)
+ releaseLock = await acquirePresaleClaimLock(tokenAddress);
+
+ // Get presale and vesting info
+ const presale = await getPresaleByTokenAddress(tokenAddress);
+ if (!presale || presale.status !== 'launched') {
+ return res.status(400).json({ error: 'Presale not found or not launched' });
+ }
+
+ if (!presale.base_mint_address || !presale.escrow_priv_key) {
+ return res.status(400).json({ error: 'Presale configuration incomplete' });
+ }
+
+ // Calculate claimable amount and validate
+ const vestingInfo: VestingInfo = await calculateVestingInfo(tokenAddress, userWallet);
+
+ // Validate user has a contribution/allocation
+ if (!vestingInfo.totalAllocated || vestingInfo.totalAllocated === '0') {
+ return res.status(400).json({ error: 'No token allocation found for this wallet' });
+ }
+
+ // Validate user's actual contribution exists in the database
+ const userContribution = await getUserPresaleContribution(tokenAddress, userWallet);
+ if (!userContribution || userContribution === BigInt(0)) {
+ return res.status(400).json({ error: 'No contribution found for this wallet' });
+ }
+
+ // ENFORCE NEXT UNLOCK TIME - Prevent claiming before the next unlock period
+ if (vestingInfo.nextUnlockTime && new Date() < vestingInfo.nextUnlockTime) {
+ const timeUntilNextUnlock = vestingInfo.nextUnlockTime.getTime() - Date.now();
+ const minutesRemaining = Math.ceil(timeUntilNextUnlock / 60000);
+ return res.status(400).json({
+ error: `Cannot claim yet. Next unlock in ${minutesRemaining} minutes at ${vestingInfo.nextUnlockTime.toISOString()}`,
+ nextUnlockTime: vestingInfo.nextUnlockTime.toISOString(),
+ minutesRemaining
+ });
+ }
+
+ // The claimableAmount from vestingInfo already accounts for:
+ // 1. Vesting schedule (how much has vested so far)
+ // 2. Already claimed amounts (subtracts what was previously claimed)
+ // So we just need to validate it's positive
+ const claimAmount = new BN(vestingInfo.claimableAmount);
+
+ if (claimAmount.isZero() || claimAmount.isNeg()) {
+ return res.status(400).json({ error: 'No tokens available to claim at this time' });
+ }
+
+ // Decrypt escrow keypair only to get the public key for transaction building
+ const escrowKeypair = decryptEscrowKeypair(presale.escrow_priv_key);
+
+ // Setup connection and get token info
+ const connection = new Connection(process.env.RPC_URL!, 'confirmed');
+ const baseMintPubkey = new PublicKey(presale.base_mint_address);
+ const userPubkey = new PublicKey(userWallet);
+
+ // Get mint info for decimals
+ const mintInfo = await getMint(connection, baseMintPubkey);
+
+ // Get user's token account address
+ const userTokenAccountAddress = await getAssociatedTokenAddress(
+ baseMintPubkey,
+ userPubkey,
+ true // Allow owner off curve
+ );
+
+ // Check if account exists
+ let userTokenAccountInfo;
+ try {
+ userTokenAccountInfo = await connection.getAccountInfo(userTokenAccountAddress);
+ } catch (err) {
+ // Account doesn't exist
+ userTokenAccountInfo = null;
+ }
+
+ // Get escrow's token account address
+ const escrowTokenAccountAddress = await getAssociatedTokenAddress(
+ baseMintPubkey,
+ escrowKeypair.publicKey,
+ true // Allow owner off curve
+ );
+
+ // Check if escrow account exists
+ let escrowTokenAccountInfo;
+ try {
+ escrowTokenAccountInfo = await connection.getAccountInfo(escrowTokenAccountAddress);
+ } catch (err) {
+ escrowTokenAccountInfo = null;
+ }
+
+ // Create transaction
+ const transaction = new Transaction();
+
+ // Add instruction to create user's token account if it doesn't exist (user pays)
+ if (!userTokenAccountInfo) {
+ const createUserATAInstruction = createAssociatedTokenAccountInstruction(
+ userPubkey, // payer (user pays)
+ userTokenAccountAddress,
+ userPubkey, // owner
+ baseMintPubkey
+ );
+ transaction.add(createUserATAInstruction);
+ }
+
+ // Add instruction to create escrow's token account if it doesn't exist (user pays)
+ if (!escrowTokenAccountInfo) {
+ const createEscrowATAInstruction = createAssociatedTokenAccountInstruction(
+ userPubkey, // payer (user pays for escrow account too)
+ escrowTokenAccountAddress,
+ escrowKeypair.publicKey, // owner
+ baseMintPubkey
+ );
+ transaction.add(createEscrowATAInstruction);
+ }
+
+ // Create transfer instruction from escrow to user
+ const transferInstruction = createTransferInstruction(
+ escrowTokenAccountAddress,
+ userTokenAccountAddress,
+ escrowKeypair.publicKey,
+ BigInt(claimAmount.toString())
+ );
+ transaction.add(transferInstruction);
+ const { blockhash } = await connection.getLatestBlockhash('confirmed');
+ transaction.recentBlockhash = blockhash;
+ transaction.feePayer = userPubkey; // User pays for transaction fees
+
+ // Store transaction data with encrypted escrow key
+ const timestamp = Date.now();
+ const claimKey = `${tokenAddress}:${timestamp}`;
+ presaleClaimTransactions.set(claimKey, {
+ tokenAddress,
+ userWallet,
+ claimAmount: claimAmount.toString(),
+ userTokenAccount: userTokenAccountAddress.toBase58(),
+ escrowTokenAccount: escrowTokenAccountAddress.toBase58(), // Store the actual escrow token account
+ mintDecimals: mintInfo.decimals,
+ timestamp,
+ escrowPublicKey: escrowKeypair.publicKey.toBase58(),
+ encryptedEscrowKey: presale.escrow_priv_key // Store encrypted key from DB
+ });
+
+ // Serialize transaction
+ const serializedTx = bs58.encode(transaction.serialize({
+ requireAllSignatures: false,
+ verifySignatures: false
+ }));
+
+ res.json({
+ success: true,
+ transaction: serializedTx,
+ timestamp,
+ claimAmount: claimAmount.toString(),
+ decimals: mintInfo.decimals
+ });
+
+ } catch (error) {
+ console.error('Error preparing presale claim:', error);
+ res.status(500).json({
+ error: error instanceof Error ? error.message : 'Failed to prepare claim'
+ });
+ } finally {
+ if (releaseLock) releaseLock();
+ }
+});
+
+// Confirm presale claim transaction
+router.post('/:tokenAddress/claims/confirm', presaleClaimLimiter, async (req: Request, res: Response) => {
+ let releaseLock: (() => void) | null = null;
+
+ try {
+ const { tokenAddress } = req.params;
+ const { signedTransaction, timestamp } = req.body;
+
+ if (!signedTransaction || !timestamp) {
+ return res.status(400).json({ error: 'Missing required parameters' });
+ }
+
+ // Validate token address
+ if (!isValidSolanaAddress(tokenAddress)) {
+ return res.status(400).json({ error: 'Invalid token address format' });
+ }
+
+ // Validate timestamp
+ if (typeof timestamp !== 'number' || timestamp < 0 || timestamp > Date.now() + 60000) {
+ return res.status(400).json({ error: 'Invalid timestamp' });
+ }
+
+ // Acquire lock (using presale-specific lock)
+ releaseLock = await acquirePresaleClaimLock(tokenAddress);
+
+ // Get stored transaction
+ const claimKey = `${tokenAddress}:${timestamp}`;
+ const storedClaim = presaleClaimTransactions.get(claimKey);
+
+ if (!storedClaim) {
+ console.error('[PRESALE CLAIM] Stored claim not found for key:', claimKey);
+ return res.status(400).json({ error: 'Claim transaction not found or expired' });
+ }
+
+ // Verify timestamp (5 minute expiry)
+ if (Date.now() - storedClaim.timestamp > 5 * 60 * 1000) {
+ presaleClaimTransactions.delete(claimKey);
+ return res.status(400).json({ error: 'Claim transaction expired' });
+ }
+
+ // RE-VALIDATE VESTING SCHEDULE - Critical security check
+ // Even if a transaction was prepared, we must ensure it's still valid at confirm time
+ const vestingInfo: VestingInfo = await calculateVestingInfo(tokenAddress, storedClaim.userWallet);
+
+ // Enforce next unlock time
+ if (vestingInfo.nextUnlockTime && new Date() < vestingInfo.nextUnlockTime) {
+ const timeUntilNextUnlock = vestingInfo.nextUnlockTime.getTime() - Date.now();
+ const minutesRemaining = Math.ceil(timeUntilNextUnlock / 60000);
+
+ // Clean up the stored transaction since it's no longer valid
+ presaleClaimTransactions.delete(claimKey);
+
+ return res.status(400).json({
+ error: `Cannot claim yet. Next unlock in ${minutesRemaining} minutes at ${vestingInfo.nextUnlockTime.toISOString()}`,
+ nextUnlockTime: vestingInfo.nextUnlockTime.toISOString(),
+ minutesRemaining
+ });
+ }
+
+ // Verify the claim amount is still valid
+ const currentClaimableAmount = new BN(vestingInfo.claimableAmount);
+ const storedClaimAmount = new BN(storedClaim.claimAmount);
+
+ if (currentClaimableAmount.lt(storedClaimAmount)) {
+ // The claimable amount has decreased (shouldn't happen, but check for safety)
+ presaleClaimTransactions.delete(claimKey);
+ return res.status(400).json({
+ error: 'Claim amount is no longer valid. Please prepare a new transaction.',
+ currentClaimable: currentClaimableAmount.toString(),
+ requestedAmount: storedClaimAmount.toString()
+ });
+ }
+
+ // Deserialize the user-signed transaction
+ const connection = new Connection(process.env.RPC_URL!, 'confirmed');
+ const txBuffer = bs58.decode(signedTransaction);
+ const transaction = Transaction.from(txBuffer);
+
+ // SECURITY: Validate transaction has recent blockhash to prevent replay attacks
+ if (!transaction.recentBlockhash) {
+ return res.status(400).json({ error: 'Invalid transaction: missing blockhash' });
+ }
+
+ // Check if blockhash is still valid (within last 150 slots ~60 seconds)
+ const isBlockhashValid = await connection.isBlockhashValid(
+ transaction.recentBlockhash,
+ { commitment: 'confirmed' }
+ );
+
+ if (!isBlockhashValid) {
+ return res.status(400).json({
+ error: 'Invalid transaction: blockhash is expired. Please create a new transaction.'
+ });
+ }
+
+ // CRITICAL SECURITY: Verify the transaction is signed by the claiming wallet
+ const userPubkey = new PublicKey(storedClaim.userWallet);
+ let validUserSigner = false;
+
+ // Compile the transaction message for signature verification
+ const message = transaction.compileMessage();
+ const messageBytes = message.serialize();
+
+ // Find the user wallet's signer index
+ const userSignerIndex = message.accountKeys.findIndex(key =>
+ key.equals(userPubkey)
+ );
+
+ if (userSignerIndex >= 0 && userSignerIndex < transaction.signatures.length) {
+ const signature = transaction.signatures[userSignerIndex];
+ if (signature.signature) {
+ // CRITICAL: Verify the signature is cryptographically valid using nacl
+ const isValid = nacl.sign.detached.verify(
+ messageBytes,
+ signature.signature,
+ userPubkey.toBytes()
+ );
+ validUserSigner = isValid;
+ }
+ }
+
+ if (!validUserSigner) {
+ return res.status(400).json({
+ error: 'Invalid transaction: must be cryptographically signed by the claiming wallet'
+ });
+ }
+
+ // CRITICAL SECURITY: Validate transaction structure
+ // Check that it only contains expected instructions (transfer from escrow to user)
+ let transferInstructionCount = 0;
+ let validTransfer = false;
+ const escrowPubkey = new PublicKey(storedClaim.escrowPublicKey);
+ const userTokenAccount = new PublicKey(storedClaim.userTokenAccount);
+ const mintPubkey = new PublicKey(tokenAddress);
+
+ // Get the Compute Budget Program ID
+ const COMPUTE_BUDGET_PROGRAM_ID = ComputeBudgetProgram.programId;
+ const LIGHTHOUSE_PROGRAM_ID = new PublicKey("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95");
+
+ for (const instruction of transaction.instructions) {
+ // Check if it's a Compute Budget instruction (optional, for setting compute units)
+ if (instruction.programId.equals(COMPUTE_BUDGET_PROGRAM_ID)) {
+ // This is fine, it's a compute budget instruction for optimizing transaction fees
+ continue;
+ }
+
+ // Check if it's an ATA creation instruction (optional, only if account doesn't exist)
+ if (instruction.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID)) {
+ // This is fine, it's creating the user's token account
+ continue;
+ }
+
+ // Check if it's a Lighthouse instruction
+ if (instruction.programId.equals(LIGHTHOUSE_PROGRAM_ID)) {
+ // This is fine, it's a Lighthouse instruction for optimizing transaction fees
+ continue;
+ }
+
+ // Check if it's a transfer instruction
+ if (instruction.programId.equals(TOKEN_PROGRAM_ID)) {
+ // Transfer instruction has opcode 3 or 12 (Transfer or TransferChecked)
+ const opcode = instruction.data[0];
+
+ if (opcode === 3 || opcode === 12) {
+ transferInstructionCount++;
+
+ // Validate the transfer is from escrow to user
+ // For Transfer (opcode 3): accounts are [source, destination, authority]
+ // For TransferChecked (opcode 12): accounts are [source, mint, destination, authority]
+ const sourceIndex = 0;
+ const destIndex = opcode === 3 ? 1 : 2;
+ const authorityIndex = opcode === 3 ? 2 : 3;
+
+ if (instruction.keys.length > authorityIndex) {
+ const source = instruction.keys[sourceIndex].pubkey;
+ const destination = instruction.keys[destIndex].pubkey;
+ const authority = instruction.keys[authorityIndex].pubkey;
+
+ // For presale claims, we need to validate:
+ // 1. The authority MUST be the escrow
+ // 2. The destination MUST be the user's token account
+ // 3. The source MUST be owned by the escrow (but might not be the ATA)
+
+ const authorityMatchesEscrow = authority.equals(escrowPubkey);
+ const destMatchesUser = destination.equals(userTokenAccount);
+
+ // Since the source might not be an ATA, we should verify it's owned by the escrow
+ // by checking the transaction itself or trusting that the escrow signature validates ownership
+ // For now, we'll accept any source as long as the escrow is signing
+
+ // Validate: authority is escrow and destination is user's account
+ // We trust the source because only the escrow can sign for its accounts
+ if (destMatchesUser && authorityMatchesEscrow) {
+
+ // Validate transfer amount
+ const amountBytes = opcode === 3
+ ? instruction.data.slice(1, 9) // Transfer: 8 bytes starting at index 1
+ : instruction.data.slice(1, 9); // TransferChecked: 8 bytes starting at index 1
+
+ const amount = new BN(amountBytes, 'le');
+ const expectedAmount = new BN(storedClaim.claimAmount);
+
+ if (amount.eq(expectedAmount)) {
+ validTransfer = true;
+ }
+ }
+ }
+ } else {
+ // Unexpected SPL Token instruction
+ return res.status(400).json({
+ error: 'Invalid transaction: unexpected token program instruction'
+ });
+ }
+ } else if (!instruction.programId.equals(ASSOCIATED_TOKEN_PROGRAM_ID) &&
+ !instruction.programId.equals(COMPUTE_BUDGET_PROGRAM_ID) &&
+ !instruction.programId.equals(LIGHTHOUSE_PROGRAM_ID)) {
+ console.log("instruction", instruction);
+ // Unknown program - reject
+ return res.status(400).json({
+ error: 'Invalid transaction: contains unexpected instructions'
+ });
+ }
+ }
+
+ if (transferInstructionCount === 0) {
+ return res.status(400).json({ error: 'Invalid transaction: no transfer instruction found' });
+ }
+
+ if (transferInstructionCount > 1) {
+ return res.status(400).json({ error: 'Invalid transaction: only one transfer allowed' });
+ }
+
+ if (!validTransfer) {
+ return res.status(400).json({
+ error: 'Invalid transaction: transfer details do not match claim'
+ });
+ }
+
+ // Now decrypt and add the escrow signature after all validations pass
+ const escrowKeypair = decryptEscrowKeypair(storedClaim.encryptedEscrowKey);
+ transaction.partialSign(escrowKeypair);
+
+ // Send the fully signed transaction
+ const fullySignedTxBuffer = transaction.serialize();
+ const signature = await connection.sendRawTransaction(fullySignedTxBuffer, {
+ skipPreflight: false,
+ preflightCommitment: 'confirmed',
+ maxRetries: 3
+ });
+
+ // Wait for confirmation using polling
+ let confirmed = false;
+ let retries = 0;
+ const maxRetries = 60; // 60 seconds max
+
+ while (!confirmed && retries < maxRetries) {
+ try {
+ const status = await connection.getSignatureStatus(signature);
+
+ if (status?.value?.confirmationStatus === 'confirmed' || status?.value?.confirmationStatus === 'finalized') {
+ confirmed = true;
+ break;
+ }
+
+ if (status?.value?.err) {
+ throw new Error(`Transaction failed: ${JSON.stringify(status.value.err)}`);
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ retries++;
+ } catch (statusError) {
+ console.error('Status check error:', statusError);
+ retries++;
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ }
+ }
+
+ if (!confirmed) {
+ throw new Error('Transaction confirmation timeout after 60 seconds');
+ }
+
+ // Get transaction details for verification
+ const txDetails = await connection.getParsedTransaction(signature, {
+ commitment: 'confirmed',
+ maxSupportedTransactionVersion: 0
+ });
+
+ // Record the claim in database
+ await recordPresaleClaim(
+ tokenAddress,
+ storedClaim.userWallet,
+ storedClaim.claimAmount,
+ signature,
+ txDetails?.blockTime || undefined,
+ txDetails?.slot ? BigInt(txDetails.slot) : undefined
+ );
+
+ // Clean up stored transaction
+ presaleClaimTransactions.delete(claimKey);
+
+ const responseData = {
+ success: true,
+ signature,
+ claimedAmount: storedClaim.claimAmount,
+ decimals: storedClaim.mintDecimals
+ };
+
+ res.json(responseData);
+
+ } catch (error) {
+ console.error('[PRESALE CLAIM] Error confirming claim:', error);
+
+ res.status(500).json({
+ error: error instanceof Error ? error.message : 'Failed to confirm claim'
+ });
+ } finally {
+ if (releaseLock) releaseLock();
+ }
+});
+
+// Get presale stats endpoint
+router.get('/:tokenAddress/stats', async (req: Request, res: Response) => {
+ try {
+ const { tokenAddress } = req.params;
+
+ // Validate token address
+ if (!isValidSolanaAddress(tokenAddress)) {
+ return res.status(400).json({
+ error: 'Invalid token address format'
+ });
+ }
+
+ const stats = await getPresaleStats(tokenAddress);
+
+ res.json({ success: true, ...stats });
+ } catch (error) {
+ console.error('Error fetching presale stats:', error);
+ res.status(500).json({
+ error: error instanceof Error ? error.message : 'Failed to fetch stats'
+ });
+ }
+});
+
+// ===== PRESALE BID ENDPOINTS =====
+
+// In-memory lock to prevent concurrent processing of the same transaction
+const transactionLocks = new Map>();
+
+async function acquireTransactionLock(signature: string): Promise<() => void> {
+ const key = signature.toLowerCase();
+
+ // Wait for any existing lock to be released
+ while (transactionLocks.has(key)) {
+ await transactionLocks.get(key);
+ }
+
+ // Create a new lock
+ let releaseLock: () => void;
+ const lockPromise = new Promise((resolve) => {
+ releaseLock = resolve;
+ });
+
+ transactionLocks.set(key, lockPromise);
+
+ // Return the release function
+ return () => {
+ transactionLocks.delete(key);
+ releaseLock();
+ };
+}
+
+const ZC_TOKEN_MINT = 'GVvPZpC6ymCoiHzYJ7CWZ8LhVn9tL2AUpRjSAsLh6jZC';
+const ZC_DECIMALS = 6;
+const ZC_PER_TOKEN = Math.pow(10, ZC_DECIMALS);
+
+// Get presale bids endpoint
+router.get('/:tokenAddress/bids', async (req: Request, res: Response) => {
+ try {
+ const { tokenAddress } = req.params;
+
+ if (!tokenAddress) {
+ return res.status(400).json({
+ error: 'Token address is required'
+ });
+ }
+
+ // Validate token address
+ if (!isValidSolanaAddress(tokenAddress)) {
+ return res.status(400).json({
+ error: 'Invalid token address format'
+ });
+ }
+
+ // Fetch all bids and totals
+ const [bids, totals] = await Promise.all([
+ getPresaleBids(tokenAddress),
+ getTotalPresaleBids(tokenAddress)
+ ]);
+
+ // Convert smallest units to $ZC for frontend display (6 decimals)
+ const contributions = bids.map(bid => ({
+ wallet: bid.wallet_address,
+ amount: Number(bid.amount_lamports) / ZC_PER_TOKEN, // Now in $ZC
+ transactionSignature: bid.transaction_signature,
+ createdAt: bid.created_at
+ }));
+
+ const totalRaisedZC = Number(totals.totalAmount) / ZC_PER_TOKEN; // Now in $ZC
+
+ res.json({
+ totalRaised: totalRaisedZC,
+ totalBids: totals.totalBids,
+ contributions
+ });
+
+ } catch (error) {
+ console.error('Error fetching presale bids:', error);
+ res.status(500).json({
+ error: error instanceof Error ? error.message : 'Failed to fetch presale bids'
+ });
+ }
+});
+
+// Record presale bid endpoint
+router.post('/:tokenAddress/bids', async (req: Request, res: Response) => {
+ let releaseLock: (() => void) | null = null;
+
+ try {
+ const { tokenAddress } = req.params;
+ const { transactionSignature, walletAddress, amountTokens, tokenMint } = req.body;
+
+ // Validate required fields
+ if (!tokenAddress || !transactionSignature || !walletAddress || !amountTokens) {
+ return res.status(400).json({
+ error: 'Missing required fields'
+ });
+ }
+
+ // Validate token mint is $ZC
+ if (!tokenMint || tokenMint !== ZC_TOKEN_MINT) {
+ return res.status(400).json({
+ error: 'Invalid token mint. Only $ZC tokens are accepted'
+ });
+ }
+
+ // Validate Solana addresses
+ if (!isValidSolanaAddress(tokenAddress)) {
+ return res.status(400).json({
+ error: 'Invalid token address format'
+ });
+ }
+
+ if (!isValidSolanaAddress(walletAddress)) {
+ return res.status(400).json({
+ error: 'Invalid wallet address format'
+ });
+ }
+
+ // Validate transaction signature
+ if (!isValidTransactionSignature(transactionSignature)) {
+ return res.status(400).json({
+ error: 'Invalid transaction signature format'
+ });
+ }
+
+ // Validate amount (now in token units with 6 decimals)
+ if (!amountTokens || typeof amountTokens !== 'number' || amountTokens <= 0) {
+ return res.status(400).json({
+ error: 'Invalid amount: must be a positive number of tokens'
+ });
+ }
+
+ // Acquire lock for this transaction to prevent concurrent processing
+ releaseLock = await acquireTransactionLock(transactionSignature);
+
+ // Fetch presale from database
+ const presale = await getPresaleByTokenAddress(tokenAddress);
+
+ if (!presale) {
+ return res.status(404).json({
+ error: 'Presale not found'
+ });
+ }
+
+ // Verify escrow address exists
+ if (!presale.escrow_pub_key) {
+ return res.status(400).json({
+ error: 'Presale escrow not configured'
+ });
+ }
+
+ // CRITICAL: Check if transaction already exists BEFORE expensive verification
+ let existingBid = await getPresaleBidBySignature(transactionSignature);
+ if (existingBid) {
+ console.log(`Transaction ${transactionSignature} already recorded`);
+ return res.status(400).json({
+ error: 'Transaction already recorded'
+ });
+ }
+
+ // Now verify the $ZC token transaction on-chain
+ console.log(`Verifying $ZC token transaction ${transactionSignature} for presale ${tokenAddress}`);
+
+ const verification = await verifyPresaleTokenTransaction(
+ transactionSignature,
+ walletAddress, // sender owner
+ presale.escrow_pub_key, // recipient owner
+ ZC_TOKEN_MINT, // token mint
+ BigInt(amountTokens), // amount in smallest units (6 decimals)
+ 300 // 5 minutes max age
+ );
+
+ if (!verification.valid) {
+ console.error(`Token transaction verification failed: ${verification.error}`);
+ return res.status(400).json({
+ error: `Transaction verification failed: ${verification.error}`
+ });
+ }
+
+ console.log(`Transaction ${transactionSignature} verified successfully`);
+
+ // Double-check one more time after verification (belt and suspenders)
+ existingBid = await getPresaleBidBySignature(transactionSignature);
+ if (existingBid) {
+ console.log(`Transaction ${transactionSignature} was recorded by another request during verification`);
+ return res.status(400).json({
+ error: 'Transaction already recorded'
+ });
+ }
+
+ // Record the verified bid in the database
+ // Note: We're keeping the database field as amount_lamports for backward compatibility
+ // but now it represents smallest units of $ZC (6 decimals)
+ try {
+ const bid = await recordPresaleBid({
+ presale_id: presale.id!,
+ token_address: tokenAddress,
+ wallet_address: walletAddress,
+ amount_lamports: BigInt(amountTokens), // Now represents $ZC smallest units
+ transaction_signature: transactionSignature,
+ block_time: verification.details?.blockTime,
+ slot: verification.details?.slot ? BigInt(verification.details.slot) : undefined,
+ verified_at: new Date()
+ });
+
+ res.json({
+ success: true,
+ bid: {
+ transactionSignature: bid.transaction_signature,
+ amountZC: Number(bid.amount_lamports) / ZC_PER_TOKEN, // Convert to $ZC
+ },
+ verification: {
+ blockTime: verification.details?.blockTime,
+ slot: verification.details?.slot,
+ verified: true
+ }
+ });
+
+ } catch (error) {
+ // Check if it's a duplicate transaction error
+ if (error instanceof Error && error.message.includes('already recorded')) {
+ return res.status(400).json({
+ error: 'Transaction already recorded'
+ });
+ }
+
+ console.error('Error recording bid:', error);
+ return res.status(500).json({
+ error: 'Failed to record bid'
+ });
+ }
+
+ } catch (error) {
+ console.error('Error saving presale bid:', error);
+ res.status(500).json({
+ error: error instanceof Error ? error.message : 'Failed to save bid'
+ });
+ } finally {
+ // Always release the lock
+ if (releaseLock) {
+ releaseLock();
+ }
+ }
+});
+
+// Create presale launch transaction
+router.post('/:tokenAddress/launch', async (req: Request, res: Response) => {
+ try {
+ const { tokenAddress } = req.params;
+ const { payerPublicKey } = req.body;
+
+ if (!tokenAddress) {
+ return res.status(400).json({ error: 'Token address is required' });
+ }
+
+ if (!payerPublicKey) {
+ return res.status(400).json({ error: 'Payer public key is required' });
+ }
+
+ const RPC_URL = process.env.RPC_URL;
+ const CONFIG_ADDRESS = process.env.FLYWHEEL_CONFIG_ADDRESS;
+ const ZC_TOKEN_MINT = new PublicKey("GVvPZpC6ymCoiHzYJ7CWZ8LhVn9tL2AUpRjSAsLh6jZC");
+ const ZC_DECIMALS = 6;
+ const ZC_PER_TOKEN = Math.pow(10, ZC_DECIMALS);
+
+ if (!RPC_URL || !CONFIG_ADDRESS) {
+ throw new Error('RPC_URL and CONFIG_ADDRESS must be configured');
+ }
+
+ // Fetch presale from database
+ const presale = await getPresaleByTokenAddress(tokenAddress);
+
+ if (!presale) {
+ throw new Error('Presale not found');
+ }
+
+ // Verify caller is the creator
+ if (presale.creator_wallet !== payerPublicKey) {
+ throw new Error('Only the presale creator can launch');
+ }
+
+ // Check if already launched
+ if (presale.status !== 'pending') {
+ throw new Error('Presale has already been launched or is not pending');
+ }
+
+ // Verify escrow keys exist
+ if (!presale.escrow_pub_key || !presale.escrow_priv_key) {
+ throw new Error('Escrow keypair not found for this presale');
+ }
+
+ // Decrypt escrow keypair
+ const escrowKeypair = decryptEscrowKeypair(presale.escrow_priv_key);
+
+ // Verify escrow public key matches
+ if (escrowKeypair.publicKey.toBase58() !== presale.escrow_pub_key) {
+ throw new Error('Escrow keypair verification failed');
+ }
+
+ // Verify base mint key exists
+ if (!presale.base_mint_priv_key) {
+ throw new Error('Base mint keypair not found');
+ }
+
+ // Decrypt base mint keypair (stored as encrypted base58 string, not JSON array)
+ const decryptedBase58 = decrypt(presale.base_mint_priv_key);
+ const baseMintKeypair = Keypair.fromSecretKey(bs58.decode(decryptedBase58));
+
+ // Verify base mint keypair by checking if we can recreate the same base58 string
+ if (bs58.encode(baseMintKeypair.secretKey) !== decryptedBase58) {
+ throw new Error('Base mint keypair verification failed');
+ }
+
+ // Get escrow's $ZC token balance
+ const connection = new Connection(RPC_URL, "confirmed");
+
+ // Get escrow's $ZC token account
+ const escrowTokenAccount = await getAssociatedTokenAddress(
+ ZC_TOKEN_MINT,
+ escrowKeypair.publicKey,
+ true
+ );
+
+ let escrowZCBalance = 0;
+ try {
+ const escrowTokenAccountInfo = await getAccount(connection, escrowTokenAccount);
+ escrowZCBalance = Number(escrowTokenAccountInfo.amount);
+ } catch (err) {
+ throw new Error('Escrow $ZC token account not found or has no balance');
+ }
+
+ if (escrowZCBalance === 0) {
+ throw new Error('Escrow wallet has no $ZC tokens');
+ }
+
+ // Use full escrow balance for the buy (no buffer needed for $ZC)
+ const buyAmountTokens = escrowZCBalance;
+
+ // Initialize Meteora client
+ const client = new DynamicBondingCurveClient(connection, "confirmed");
+
+ const baseMint = baseMintKeypair.publicKey;
+ const payer = new PublicKey(payerPublicKey);
+ const config = new PublicKey(CONFIG_ADDRESS);
+
+ // Create pool with first buy using Meteora SDK - using $ZC as quote
+ const { createPoolTx, swapBuyTx } = await client.pool.createPoolWithFirstBuy({
+ createPoolParam: {
+ baseMint,
+ config, // This config must be configured for $ZC as quote token
+ name: presale.token_name || '',
+ symbol: presale.token_symbol || '',
+ uri: presale.token_metadata_url,
+ payer,
+ poolCreator: payer
+ },
+ firstBuyParam: {
+ buyer: escrowKeypair.publicKey,
+ receiver: escrowKeypair.publicKey,
+ buyAmount: new BN(buyAmountTokens), // Amount in $ZC smallest units (6 decimals)
+ minimumAmountOut: new BN(0), // Accept any amount (no slippage protection for first buy)
+ referralTokenAccount: null
+ }
+ });
+
+ // Combine transactions into a single atomic transaction
+ const combinedTx = new Transaction();
+
+ // First, transfer SOL to escrow for token account creation and transaction fees
+ // 0.005 SOL should cover rent exemption (~0.002 SOL) plus transaction fees
+ const transferAmount = 5000000; // 0.005 SOL in lamports
+ const transferSolInstruction = SystemProgram.transfer({
+ fromPubkey: payer,
+ toPubkey: escrowKeypair.publicKey,
+ lamports: transferAmount,
+ });
+
+ // Add SOL transfer first
+ combinedTx.add(transferSolInstruction);
+
+ // Add all instructions from createPoolTx (this creates the mint first)
+ combinedTx.add(...createPoolTx.instructions);
+
+ // Add swap instructions if they exist
+ if (swapBuyTx && swapBuyTx.instructions.length > 0) {
+ combinedTx.add(...swapBuyTx.instructions);
+ }
+
+ // Set recent blockhash and fee payer
+ const { blockhash } = await connection.getLatestBlockhash("confirmed");
+ combinedTx.recentBlockhash = blockhash;
+ combinedTx.feePayer = payer;
+
+ // Serialize the combined transaction
+ const combinedTxSerialized = bs58.encode(
+ combinedTx.serialize({
+ requireAllSignatures: false,
+ verifySignatures: false
+ })
+ );
+
+ // Generate a unique transaction ID
+ const transactionId = crypto.randomBytes(16).toString('hex');
+
+ // Store transaction details for later verification
+ presaleLaunchTransactions.set(transactionId, {
+ combinedTx: combinedTxSerialized,
+ tokenAddress,
+ payerPublicKey,
+ escrowPublicKey: escrowKeypair.publicKey.toBase58(),
+ baseMintKeypair: bs58.encode(baseMintKeypair.secretKey), // Store the keypair for signing later
+ timestamp: Date.now()
+ });
+
+ res.json({
+ combinedTx: combinedTxSerialized,
+ transactionId
+ });
+
+ } catch (error) {
+ console.error('Presale launch error:', error);
+ res.status(500).json({
+ error: error instanceof Error ? error.message : 'Failed to create presale launch transaction'
+ });
+ }
+});
+
+// Confirm presale launch transaction
+router.post('/:tokenAddress/launch-confirm', async (req: Request, res: Response) => {
+ try {
+ const { tokenAddress } = req.params;
+ const { signedTransaction, transactionId } = req.body;
+
+ if (!tokenAddress) {
+ return res.status(400).json({ error: 'Token address is required' });
+ }
+
+ if (!signedTransaction) {
+ return res.status(400).json({ error: 'Signed transaction is required' });
+ }
+
+ if (!transactionId) {
+ return res.status(400).json({ error: 'Transaction ID is required' });
+ }
+
+ const RPC_URL = process.env.RPC_URL;
+
+ if (!RPC_URL) {
+ throw new Error('RPC_URL must be configured');
+ }
+
+ // Retrieve stored transaction
+ const storedTx = presaleLaunchTransactions.get(transactionId);
+
+ if (!storedTx) {
+ throw new Error('Transaction not found or expired. Please restart the launch process.');
+ }
+
+ // Verify this is for the correct token
+ if (storedTx.tokenAddress !== tokenAddress) {
+ throw new Error('Transaction token mismatch');
+ }
+
+ // Clean up stored transaction (one-time use)
+ presaleLaunchTransactions.delete(transactionId);
+
+ // Fetch presale from database to get escrow keypair
+ const presale = await getPresaleByTokenAddress(tokenAddress);
+
+ if (!presale) {
+ throw new Error('Presale not found');
+ }
+
+ if (!presale.escrow_priv_key) {
+ throw new Error('Escrow keypair not found');
+ }
+
+ // Decrypt escrow keypair
+ const escrowKeypair = decryptEscrowKeypair(presale.escrow_priv_key);
+
+ // Verify escrow public key matches
+ if (escrowKeypair.publicKey.toBase58() !== storedTx.escrowPublicKey) {
+ throw new Error('Escrow keypair mismatch');
+ }
+
+ // Reconstruct baseMint keypair from stored data (declare it in outer scope)
+ if (!storedTx.baseMintKeypair) {
+ throw new Error('BaseMint keypair not found in transaction data');
+ }
+ const baseMintKeypair = Keypair.fromSecretKey(bs58.decode(storedTx.baseMintKeypair));
+
+ // Deserialize the signed transaction
+ const transaction = Transaction.from(bs58.decode(signedTransaction));
+
+ // Add escrow and baseMint signatures
+ transaction.partialSign(escrowKeypair);
+ transaction.partialSign(baseMintKeypair);
+
+ // Send the fully signed transaction
+ const connection = new Connection(RPC_URL, "confirmed");
+
+ const signature = await connection.sendRawTransaction(
+ transaction.serialize(),
+ {
+ skipPreflight: false,
+ preflightCommitment: 'confirmed'
+ }
+ );
+
+ // Wait for confirmation
+ await connection.confirmTransaction(signature, 'confirmed');
+
+ // Calculate tokens bought by escrow after the swap
+ let tokensBought = '0';
+ try {
+ // Use the baseMint from the generated keypair
+ const baseMintPubKey = baseMintKeypair.publicKey;
+
+ // Get escrow's token account address for the launched token
+ const escrowTokenAccount = await getAssociatedTokenAddress(
+ baseMintPubKey,
+ escrowKeypair.publicKey
+ );
+
+ // Get the token account to read balance
+ const tokenAccount = await getAccount(connection, escrowTokenAccount);
+ tokensBought = tokenAccount.amount.toString();
+
+ // Initialize presale claims with vesting (using the generated baseMint address)
+ await initializePresaleClaims(tokenAddress, baseMintPubKey.toBase58(), tokensBought);
+
+ console.log(`Presale ${tokenAddress}: ${tokensBought} tokens bought, claims initialized`);
+ } catch (error) {
+ console.error('Error initializing presale claims:', error);
+ // Don't fail the launch if we can't initialize claims
+ }
+
+ // Update presale status with base mint address and tokens bought
+ await updatePresaleStatus(tokenAddress, 'launched', baseMintKeypair.publicKey.toBase58(), tokensBought);
+
+ res.json({
+ success: true,
+ signature,
+ message: 'Presale launched successfully!'
+ });
+
+ } catch (error) {
+ console.error('Presale launch confirmation error:', error);
+ res.status(500).json({
+ error: error instanceof Error ? error.message : 'Failed to confirm presale launch'
+ });
+ }
+});
+
+export default router;
diff --git a/ui/types/api.ts b/ui/types/api.ts
index b3ec29d..9c16eb9 100644
--- a/ui/types/api.ts
+++ b/ui/types/api.ts
@@ -26,8 +26,13 @@ export interface MintClaimResponse {
success: true;
transaction: string; // base58 encoded unsigned transaction
transactionKey: string;
- userTokenAccount: string;
claimAmount: string;
+ splitRecipients: Array<{
+ wallet: string;
+ amount: string;
+ label?: string;
+ }>;
+ adminAmount: string;
mintDecimals: number;
message: string;
}
@@ -42,8 +47,12 @@ export interface ConfirmClaimResponse {
success: true;
transactionSignature: string;
tokenAddress: string;
- userTokenAccount: string;
claimAmount: string;
+ splitRecipients: Array<{
+ wallet: string;
+ amount: string;
+ label?: string;
+ }>;
confirmation: any; // Solana confirmation object
}
diff --git a/ui/types/server.ts b/ui/types/server.ts
index 0599e20..75426af 100644
--- a/ui/types/server.ts
+++ b/ui/types/server.ts
@@ -27,8 +27,13 @@ export interface MintClaimResponseBody {
success: true;
transaction: string;
transactionKey: string;
- userTokenAccount: string;
claimAmount: string;
+ splitRecipients: Array<{
+ wallet: string;
+ amount: string;
+ label?: string;
+ }>;
+ adminAmount: string;
mintDecimals: number;
message: string;
}
@@ -37,8 +42,12 @@ export interface ConfirmClaimResponseBody {
success: true;
transactionSignature: string;
tokenAddress: string;
- userTokenAccount: string;
claimAmount: string;
+ splitRecipients: Array<{
+ wallet: string;
+ amount: string;
+ label?: string;
+ }>;
confirmation: any;
}