Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ef6dbb0
feat: implement cross-chain balance display with CNGN rate fallbacks
jeremy0x Jan 5, 2026
172d5cc
refactor: apply DRY principle to CNGN balance conversion logic
jeremy0x Jan 5, 2026
ab4cc24
fix: resolve TypeScript type predicate error in BalanceContext
jeremy0x Jan 5, 2026
0a5d3cc
chore: bump pnpm version to 10.27.0
jeremy0x Jan 5, 2026
9bc4d16
feat: simplify mobile CNGN display to show only raw amounts
jeremy0x Jan 5, 2026
a319be6
fix: address all PR #335 review feedback
jeremy0x Jan 6, 2026
9ff8d51
refactor: streamline release note formatting in GitHub Actions workflow
chibie Jan 31, 2026
37895de
Merge branch 'main' into main
onahprosper Feb 5, 2026
c0ebbbd
Fix CNGN raw balance fallback
cursoragent Feb 9, 2026
175e9a0
Remove unused activeBalance prop
cursoragent Feb 9, 2026
f0c6c3b
fix(balance): remove unused cngn refetch
cursoragent Feb 9, 2026
f13db9c
refactor: dedupe cngn rate fetching
cursoragent Feb 9, 2026
17be172
refactor: extract cross-chain balance sorting hook
cursoragent Feb 9, 2026
4fedb2a
fix: use token image prop in wallet view
cursoragent Feb 9, 2026
8a7e0b5
Reset cross-chain balances on balance fetch errors
cursoragent Feb 9, 2026
23c7c28
fix: exclude CNGN from totals when rate is unavailable
cursoragent Feb 9, 2026
99eeaaa
Remove unused CNGN rate hook from WalletDetails
cursoragent Feb 9, 2026
11cbb57
fix: use canonical BNB fallback network and normalized filter
cursoragent Feb 9, 2026
b76d5c6
feat: add refetch rate on retry transaction after failure (#352)
Dprof-in-tech Feb 10, 2026
1fb7e30
Merge branch 'main' into main
onahprosper Feb 10, 2026
1fef119
Merge pull request #335 from jeremy0x/main
onahprosper Feb 10, 2026
b041641
fix: drop engines.pnpm for Heroku compatibility
onahprosper Feb 10, 2026
0d69abb
Merge pull request #359 from paycrest/fix/remove-engines-pnpm-for-heroku
onahprosper Feb 10, 2026
29ec49f
feat/eip 7702 migration v2 (#358)
sundayonah Feb 10, 2026
c0a4f54
fix: correct HTML entity in WalletMigrationSuccessModal button text
sundayonah Feb 10, 2026
4553ce5
Merge pull request #360 from paycrest/fix/escape-apostrophe
onahprosper Feb 10, 2026
279fbfe
feat: integrate wallet fetching and cross-chain balance updates
sundayonah Feb 10, 2026
02845c4
Merge pull request #361 from paycrest/feat/mobile-wallet-eoa
onahprosper Feb 10, 2026
4027c61
feat: enhance WalletMigrationBanner and context for improved migratio…
sundayonah Feb 11, 2026
78b4e3f
refactor: streamline cross-chain balance fetching logic in BalanceCon…
sundayonah Feb 11, 2026
add8627
Merge pull request #362 from paycrest/fix/partial-migration-scw-balan…
onahprosper Feb 11, 2026
80dc498
feat: enhance wallet migration modals and context for improved user e…
sundayonah Feb 12, 2026
f59494f
Merge remote-tracking branch 'origin/stable'
onahprosper Feb 13, 2026
00f7fd1
style: update MaintenanceNoticeModal to constrain width for better la…
onahprosper Feb 13, 2026
c3e570c
style: update font import in globals.css to use specific font weights…
onahprosper Feb 13, 2026
d606bf6
Merge pull request #366 from paycrest/merge-stable-into-main
onahprosper Feb 13, 2026
f5727d4
feat: enhance maintenance notice functionality with auto-hide feature…
Dprof-in-tech Feb 13, 2026
7050128
Merge pull request #367 from paycrest/fix-new-fixes-to-maintainance-b…
onahprosper Feb 13, 2026
ae34726
Merge branch 'stable' into main
5ran6 Feb 13, 2026
a84c711
feat: improve schedule parsing in MaintenanceNoticeModal to handle ov…
Dprof-in-tech Feb 13, 2026
b829e0c
Merge pull request #369 from paycrest/fix-maintainance-banner-no-show
onahprosper Feb 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 43 additions & 38 deletions .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,46 +89,51 @@ jobs:
fi

# Format PRs for release notes
echo "formatted_prs<<EOF" >> $GITHUB_OUTPUT
while IFS='|' read -r number title body labels; do
echo "### PR #$number: $title"
echo "Labels: $labels"
{
echo "formatted_prs<<EOF"
while IFS='|' read -r number title body labels; do
echo "### PR #$number: $title"
echo "Labels: $labels"
echo ""
echo "$body"
echo ""
done < prs.txt
echo "EOF"
} >> $GITHUB_OUTPUT

# Format commits by type - use %s (subject) where conventional types live
# Wrap in block to redirect all output to GITHUB_OUTPUT
{
echo "formatted_commits<<EOF"
echo "### Breaking Changes"
if [ "$latest_tag" = "0.0.0" ]; then
git log --pretty=format:"- %s" | grep -E "^- (feat!|BREAKING CHANGE)" || echo "None"
else
git log $latest_tag..HEAD --pretty=format:"- %s" | grep -E "^- (feat!|BREAKING CHANGE)" || echo "None"
fi
echo ""
echo "$body"
echo "### Features"
if [ "$latest_tag" = "0.0.0" ]; then
git log --pretty=format:"- %s" | grep -E "^- feat[^!]" || echo "None"
else
git log $latest_tag..HEAD --pretty=format:"- %s" | grep -E "^- feat[^!]" || echo "None"
fi
echo ""
done < prs.txt
echo "EOF" >> $GITHUB_OUTPUT

# Format commits by type - use full commit messages for display
echo "formatted_commits<<EOF" >> $GITHUB_OUTPUT
echo "### Breaking Changes"
if [ "$latest_tag" = "0.0.0" ]; then
git log --pretty=format:"%B" | grep -E "^(feat!|BREAKING CHANGE)" || echo "None"
else
git log $latest_tag..HEAD --pretty=format:"%B" | grep -E "^(feat!|BREAKING CHANGE)" || echo "None"
fi
echo ""
echo "### Features"
if [ "$latest_tag" = "0.0.0" ]; then
git log --pretty=format:"%B" | grep -E "^(feat|\[minor\])" || echo "None"
else
git log $latest_tag..HEAD --pretty=format:"%B" | grep -E "^(feat|\[minor\])" || echo "None"
fi
echo ""
echo "### Fixes"
if [ "$latest_tag" = "0.0.0" ]; then
git log --pretty=format:"%B" | grep -E "^(fix)" || echo "None"
else
git log $latest_tag..HEAD --pretty=format:"%B" | grep -E "^(fix)" || echo "None"
fi
echo ""
echo "### Other Changes"
if [ "$latest_tag" = "0.0.0" ]; then
git log --pretty=format:"%B" | grep -E "^(chore|docs|style|refactor|perf|test|ci|build|revert)" || echo "None"
else
git log $latest_tag..HEAD --pretty=format:"%B" | grep -E "^(chore|docs|style|refactor|perf|test|ci|build|revert)" || echo "None"
fi
echo "EOF" >> $GITHUB_OUTPUT
echo "### Fixes"
if [ "$latest_tag" = "0.0.0" ]; then
git log --pretty=format:"- %s" | grep -E "^- fix" || echo "None"
else
git log $latest_tag..HEAD --pretty=format:"- %s" | grep -E "^- fix" || echo "None"
fi
echo ""
echo "### Other Changes"
if [ "$latest_tag" = "0.0.0" ]; then
git log --pretty=format:"- %s" | grep -E "^- (chore|docs|style|refactor|perf|test|ci|build|revert)" || echo "None"
else
git log $latest_tag..HEAD --pretty=format:"- %s" | grep -E "^- (chore|docs|style|refactor|perf|test|ci|build|revert)" || echo "None"
fi
echo "EOF"
} >> $GITHUB_OUTPUT

- name: Generate new version
id: new_version
Expand Down
39 changes: 39 additions & 0 deletions __tests__/calculateCorrectedTotalBalance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { calculateCorrectedTotalBalance } from "../app/utils";

describe("calculateCorrectedTotalBalance", () => {
it("excludes CNGN face value from total when rate is null", () => {
const rawBalance = {
total: 1600,
balances: {
USDC: 100,
cNGN: 1500,
},
};

expect(calculateCorrectedTotalBalance(rawBalance, null)).toBe(100);
});

it("excludes CNGN face value from total when rate is non-positive", () => {
const rawBalance = {
total: 1600,
balances: {
USDC: 100,
CNGN: 1500,
},
};

expect(calculateCorrectedTotalBalance(rawBalance, 0)).toBe(100);
});

it("converts CNGN into USD equivalent when rate is available", () => {
const rawBalance = {
total: 1600,
balances: {
USDC: 100,
cNGN: 1500,
},
};

expect(calculateCorrectedTotalBalance(rawBalance, 1500)).toBe(101);
});
});
3 changes: 2 additions & 1 deletion app/api/aggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const fetchRate = async ({
currency,
providerId,
network,
signal,
}: RatePayload): Promise<RateResponse> => {
const startTime = Date.now();

Expand All @@ -71,7 +72,7 @@ export const fetchRate = async ({
params.network = network;
}

const response = await axios.get(endpoint, { params });
const response = await axios.get(endpoint, { params, signal });
const { data } = response;

// Track successful response
Expand Down
183 changes: 183 additions & 0 deletions app/api/v1/wallets/deprecate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { NextRequest, NextResponse } from "next/server";
import { supabaseAdmin } from "@/app/lib/supabase";
import { withRateLimit } from "@/app/lib/rate-limit";
import { trackApiRequest, trackApiResponse, trackApiError } from "@/app/lib/server-analytics";
import { verifyJWT } from "@/app/lib/jwt";
import { DEFAULT_PRIVY_CONFIG } from "@/app/lib/config";

export const POST = withRateLimit(async (request: NextRequest) => {
const startTime = Date.now();

try {
// Step 1: Verify authentication token
const authHeader = request.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");

if (!token) {
trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Unauthorized"), 401);
return NextResponse.json(
{ success: false, error: "Unauthorized" },
{ status: 401 }
);
}

let authenticatedUserId: string;
try {
const jwtResult = await verifyJWT(token, DEFAULT_PRIVY_CONFIG);
authenticatedUserId = jwtResult.payload.sub;

if (!authenticatedUserId) {
trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Invalid token"), 401);
return NextResponse.json(
{ success: false, error: "Invalid token" },
{ status: 401 }
);
}
} catch (jwtError) {
trackApiError(request, "/api/v1/wallets/deprecate", "POST", jwtError as Error, 401);
return NextResponse.json(
{ success: false, error: "Invalid or expired token" },
{ status: 401 }
);
}

const walletAddress = request.headers.get("x-wallet-address")?.toLowerCase();
const body = await request.json();
const { oldAddress, newAddress, txHash, userId } = body;

if (!walletAddress || !oldAddress || !newAddress || !userId) {
trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Missing required fields"), 400);
return NextResponse.json(
{ success: false, error: "Missing required fields" },
{ status: 400 }
);
}

// Step 2: Verify userId matches authenticated user (CRITICAL SECURITY FIX)
if (userId !== authenticatedUserId) {
trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Unauthorized: userId mismatch"), 403);
return NextResponse.json(
{ success: false, error: "Unauthorized" },
{ status: 403 }
);
}

// Step 3: Verify wallet addresses match
if (newAddress.toLowerCase() !== walletAddress) {
trackApiError(request, "/api/v1/wallets/deprecate", "POST", new Error("Wallet address mismatch"), 403);
return NextResponse.json(
{ success: false, error: "Wallet address mismatch" },
{ status: 403 }
);
}

trackApiRequest(request, "/api/v1/wallets/deprecate", "POST", {
wallet_address: walletAddress,
old_address: oldAddress,
new_address: newAddress,
});

// Step 4: Atomic database operations with rollback on failure
// Ensure old (SCW) wallet exists and mark as deprecated (upsert so we insert if never saved to DB)
const now = new Date().toISOString();
const { error: deprecateError } = await supabaseAdmin
.from("wallets")
.upsert(
{
address: oldAddress.toLowerCase(),
user_id: userId,
wallet_type: "smart_contract",
status: "deprecated",
deprecated_at: now,
migration_completed: true,
migration_tx_hash: txHash,
updated_at: now,
},
{ onConflict: "address,user_id" }
);

if (deprecateError) {
trackApiError(request, "/api/v1/wallets/deprecate", "POST", deprecateError, 500);
throw deprecateError;
}

// Create or update new EOA wallet record
const { error: upsertError } = await supabaseAdmin
.from("wallets")
.upsert({
address: newAddress.toLowerCase(),
user_id: userId,
wallet_type: "eoa",
status: "active",
created_at: new Date().toISOString(),
updated_at: now,
}, { onConflict: "address,user_id" });

if (upsertError) {
// Rollback: Restore old wallet status
const { error: rollbackError } = await supabaseAdmin
.from("wallets")
.update({
status: "active",
deprecated_at: null,
migration_completed: false,
migration_tx_hash: null,
updated_at: new Date().toISOString(),
})
.eq("address", oldAddress.toLowerCase())
.eq("user_id", userId);

if (rollbackError) {
console.error("Critical: Rollback failed after EOA upsert error:", rollbackError);
trackApiError(request, "/api/v1/wallets/deprecate", "POST", rollbackError, 500);
}

trackApiError(request, "/api/v1/wallets/deprecate", "POST", upsertError, 500);
throw upsertError;
}

// Migrate KYC data
const { error: kycError } = await supabaseAdmin
.from("kyc_data")
.update({ wallet_address: newAddress.toLowerCase() })
.eq("wallet_address", oldAddress.toLowerCase())
.eq("user_id", userId);

if (kycError) {
console.error("KYC migration error:", kycError);
// Return partial success - wallet migrated but KYC migration failed
// This is better than rolling back the entire migration
const responseTime = Date.now() - startTime;
trackApiResponse("/api/v1/wallets/deprecate", "POST", 200, responseTime, {
wallet_address: walletAddress,
migration_successful: true,
kyc_migration_failed: true,
});

return NextResponse.json({
success: true,
message: "Wallet migrated but KYC migration failed",
kycMigrationFailed: true,
});
}

const responseTime = Date.now() - startTime;
trackApiResponse("/api/v1/wallets/deprecate", "POST", 200, responseTime, {
wallet_address: walletAddress,
migration_successful: true,
});

return NextResponse.json({ success: true, message: "Wallet migrated successfully" });
} catch (error) {
console.error("Error deprecating wallet:", error);
const responseTime = Date.now() - startTime;
trackApiError(request, "/api/v1/wallets/deprecate", "POST", error as Error, 500, {
response_time_ms: responseTime,
});

return NextResponse.json(
{ success: false, error: "Internal server error" },
{ status: 500 }
);
}
});
Loading