From ddab33d73ef2bed8a811ecdd3567ad51c7b16424 Mon Sep 17 00:00:00 2001 From: AptechEmmanuel Date: Thu, 22 Oct 2026 03:58:06 +0100 Subject: [PATCH 1/2] fix(ci): fix the lockfile, the migration comparison and the web type errors `main` is red in three places, and none of them has anything to do with whatever a pull request happens to change. - the api crate declares `reqwest` but never uses it, and the `engipay-api` entry in `Cargo.lock` does not list it either, so every step that passes `--locked` stops with "cannot update the lock file". The dependency is removed, so the manifest and the lock file agree again. - `verify-migrations.sh` compares two `pg_dump` outputs but filters out the comments and the blank lines only. `pg_dump` also brackets the dump with `\restrict` and `\unrestrict` lines carrying a random token, so the two passes never matched. Those lines are filtered out now. - the web app does not type check. The Naira ramp was written against an API that does not exist: it asked for `asset_code` and `stellar_account`, read `id` and `url` off the response and imported three types the client does not export. It calls the API the api crate actually serves now. The batch payments page uses the names the client exports, the buy page calls `formatRate` the way it is defined, and the send page imports `Link` and `ArrowUpRight`. --- app/(app)/batch/page.tsx | 12 ++-- app/(app)/buy/page.tsx | 2 +- app/(app)/send/page.tsx | 3 +- backend/crates/api/Cargo.toml | 1 - backend/scripts/verify-migrations.sh | 5 ++ components/naira/NairaAnchorRamp.tsx | 90 +++++++++++----------------- lib/naira-ramp.test.ts | 24 ++++++++ lib/naira-ramp.ts | 38 ++++++++++++ 8 files changed, 110 insertions(+), 65 deletions(-) diff --git a/app/(app)/batch/page.tsx b/app/(app)/batch/page.tsx index ade4f4e7..316e6dc8 100644 --- a/app/(app)/batch/page.tsx +++ b/app/(app)/batch/page.tsx @@ -32,8 +32,8 @@ import { getBatchPaymentRows, approveBatchPayment, executeBatchPayment, - type BatchPaymentStatusResponse, - type BatchPaymentRowResult, + type BatchPaymentStatus, + type RowResult, } from "@/lib/api-client" const SAMPLE_CSV = `destination,asset,amount,memo @@ -47,8 +47,8 @@ export default function BatchPaymentsPage() { const [file, setFile] = useState(null) const [summary, setSummary] = useState(null) const [batchId, setBatchId] = useState(null) - const [batchStatus, setBatchStatus] = useState(null) - const [rows, setRows] = useState([]) + const [batchStatus, setBatchStatus] = useState(null) + const [rows, setRows] = useState([]) const [isUploading, setIsUploading] = useState(false) const [isExecuting, setIsExecuting] = useState(false) const [isApproving, setIsApproving] = useState(false) @@ -116,8 +116,8 @@ export default function BatchPaymentsPage() { setIsApproving(true) setErrorMessage(null) try { - const res = await approveBatchPayment(batchId) - setBatchStatus(res) + await approveBatchPayment(batchId) + setBatchStatus(await getBatchPaymentStatus(batchId)) setSuccessMessage("Batch approved by second signer! Ready for execution.") } catch (err: unknown) { const msg = err instanceof Error ? err.message : "Failed to approve batch" diff --git a/app/(app)/buy/page.tsx b/app/(app)/buy/page.tsx index da28ca41..0148c50d 100644 --- a/app/(app)/buy/page.tsx +++ b/app/(app)/buy/page.tsx @@ -223,7 +223,7 @@ export default function BuyPage() {
Rate
- {liveQuote ? formatRate(liveQuote.rate_kobo, liveQuote.asset) : "—"} + {liveQuote ? formatRate(liveQuote) : "—"}
diff --git a/app/(app)/send/page.tsx b/app/(app)/send/page.tsx index cc480730..b1bca24f 100644 --- a/app/(app)/send/page.tsx +++ b/app/(app)/send/page.tsx @@ -2,12 +2,13 @@ import { Suspense, useState } from "react" import { useSearchParams } from "next/navigation" +import Link from "next/link" import { PageHeader } from "@/components/app/PageHeader" import { SendPayment } from "@/components/payments/SendPayment" import { StellarSendPayment } from "@/components/payments/StellarSendPayment" import { EngiPaySendPayment } from "@/components/payments/EngiPaySendPayment" import { Card, CardContent } from "@/components/ui/card" -import { Bitcoin, ShieldCheck, Wallet } from "lucide-react" +import { ArrowUpRight, Bitcoin, ShieldCheck, Wallet } from "lucide-react" import { activeChain } from "@/lib/wagmi" import { stellarNetwork } from "@/lib/stellar" import { NetworkTabs, networkPanelId, networkTabId } from "@/components/app/NetworkTabs" diff --git a/backend/crates/api/Cargo.toml b/backend/crates/api/Cargo.toml index ec44639f..e1027821 100644 --- a/backend/crates/api/Cargo.toml +++ b/backend/crates/api/Cargo.toml @@ -17,7 +17,6 @@ base64.workspace = true csv = "1.3" futures-core.workspace = true futures-util.workspace = true -reqwest.workspace = true serde.workspace = true serde_json.workspace = true sqlx.workspace = true diff --git a/backend/scripts/verify-migrations.sh b/backend/scripts/verify-migrations.sh index d6754523..81589d1d 100755 --- a/backend/scripts/verify-migrations.sh +++ b/backend/scripts/verify-migrations.sh @@ -31,9 +31,14 @@ apply_migrations() { capture_schema() { local target_db="$1" + # pg_dump brackets the dump with \restrict and \unrestrict lines carrying a + # random token, so the two passes never agree on them. They are not part of + # the schema, so they are filtered out along with the comments. docker compose exec -T postgres \ pg_dump --schema-only -U "${POSTGRES_USER:-engipay}" "$target_db" 2>/dev/null \ | grep -v '^--' \ + | grep -v '^\\restrict' \ + | grep -v '^\\unrestrict' \ | grep -v '^$' } diff --git a/components/naira/NairaAnchorRamp.tsx b/components/naira/NairaAnchorRamp.tsx index 7a412e9e..7b629792 100644 --- a/components/naira/NairaAnchorRamp.tsx +++ b/components/naira/NairaAnchorRamp.tsx @@ -26,47 +26,37 @@ import { ShieldCheck, ChevronRight, } from "lucide-react" -import { useWallet } from "@/contexts/WalletContext" import { - SUPPORTED_BANKS, + SUPPORTED_NIGERIAN_BANKS, isValidNuban, - getBankByCode, - isSep24Success, isSep24Terminal, - type Sep24TransactionState, } from "@/lib/naira-ramp" import { startNairaDeposit, getNairaDepositStatus, startNairaWithdrawal, getNairaWithdrawalStatus, - type NairaDepositResponse, - type NairaWithdrawalResponse, - type NairaTransactionStatusResponse, + type InteractiveFlowResponse, + type AnchorTransactionStatus, } from "@/lib/api-client" export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "deposit" | "withdraw" }) { - const { stellarAddress, walletAddress } = useWallet() - const accountAddress = stellarAddress || walletAddress || "" - const [activeTab, setActiveTab] = useState<"deposit" | "withdraw">(defaultTab) // Deposit state - const [depositAmount, setDepositAmount] = useState("50.00") - const [depositAsset, setDepositAsset] = useState("USDC") + const [depositAmount, setDepositAmount] = useState("10000") const [isStartingDeposit, setIsStartingDeposit] = useState(false) - const [depositTx, setDepositTx] = useState(null) - const [depositStatus, setDepositStatus] = useState(null) + const [depositTx, setDepositTx] = useState(null) + const [depositStatus, setDepositStatus] = useState(null) // Withdrawal state const [withdrawAmount, setWithdrawAmount] = useState("25.00") - const [withdrawAsset, setWithdrawAsset] = useState("USDC") const [bankCode, setBankCode] = useState("058") // GTBank default const [accountNumber, setAccountNumber] = useState("") const [accountName, setAccountName] = useState("") const [isStartingWithdrawal, setIsStartingWithdrawal] = useState(false) - const [withdrawTx, setWithdrawTx] = useState(null) - const [withdrawStatus, setWithdrawStatus] = useState(null) + const [withdrawTx, setWithdrawTx] = useState(null) + const [withdrawStatus, setWithdrawStatus] = useState(null) const [errorMsg, setErrorMsg] = useState(null) const [successMsg, setSuccessMsg] = useState(null) @@ -76,7 +66,7 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo if (!depositTx) return const interval = setInterval(async () => { try { - const status = await getNairaDepositStatus(depositTx.id) + const status = await getNairaDepositStatus(depositTx.transaction_id) setDepositStatus(status) if (isSep24Terminal(status.status)) { clearInterval(interval) @@ -93,7 +83,7 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo if (!withdrawTx) return const interval = setInterval(async () => { try { - const status = await getNairaWithdrawalStatus(withdrawTx.id) + const status = await getNairaWithdrawalStatus(withdrawTx.transaction_id) setWithdrawStatus(status) if (isSep24Terminal(status.status)) { clearInterval(interval) @@ -116,11 +106,7 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo setSuccessMsg(null) try { - const res = await startNairaDeposit({ - asset_code: depositAsset, - amount: depositAmount, - stellar_account: accountAddress || undefined, - }) + const res = await startNairaDeposit({ amount: depositAmount }) setDepositTx(res) setSuccessMsg("Interactive SEP-24 deposit initiated. Complete the checkout window to receive funds.") } catch (err: unknown) { @@ -151,12 +137,10 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo try { const res = await startNairaWithdrawal({ - asset_code: withdrawAsset, amount: withdrawAmount, bank_code: bankCode, - account_number: accountNumber.trim(), + bank_account: accountNumber.trim(), account_name: accountName.trim(), - stellar_account: accountAddress || undefined, }) setWithdrawTx(res) setSuccessMsg("Withdrawal initiated. Cash will be disbursed to your Nigerian bank account.") @@ -210,27 +194,24 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo
- + setDepositAmount(e.target.value)} />
- - + +

+ USDC on Stellar +

@@ -260,11 +241,11 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo

- Transaction ID: {depositTx.id} + Transaction ID: {depositTx.transaction_id}

- +
- - + +

+ USDC on Stellar +

@@ -328,7 +306,7 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo - {SUPPORTED_BANKS.map((b) => ( + {SUPPORTED_NIGERIAN_BANKS.map((b) => ( {b.name} @@ -377,11 +355,11 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo

- Transaction ID: {withdrawTx.id} + Transaction ID: {withdrawTx.transaction_id}

{ it("has a non-empty supported banks list", () => { assert.ok(SUPPORTED_NIGERIAN_BANKS.length >= 8) }) + + it("looks a bank up by its code", () => { + assert.equal(getBankByCode("058")?.name, "Guaranty Trust Bank (GTBank)") + assert.equal(getBankByCode("999999"), undefined) + assert.equal(getBankByCode(""), undefined) + }) }) describe("Naira Anchor Ramp - SEP-24 Status Mapping", () => { @@ -56,4 +65,19 @@ describe("Naira Anchor Ramp - SEP-24 Status Mapping", () => { assert.equal(pending.isTerminal, false) assert.equal(pending.isSuccess, false) }) + + it("reports terminal states the same way the label does", () => { + assert.equal(isSep24Terminal("completed"), true) + assert.equal(isSep24Terminal("expired"), true) + assert.equal(isSep24Terminal("error"), true) + assert.equal(isSep24Terminal("pending_anchor"), false) + assert.equal(isSep24Terminal("incomplete"), false) + }) + + it("reports success only for a settled transaction", () => { + assert.equal(isSep24Success("completed"), true) + assert.equal(isSep24Success("expired"), false) + assert.equal(isSep24Success("error"), false) + assert.equal(isSep24Success("pending_anchor"), false) + }) }) diff --git a/lib/naira-ramp.ts b/lib/naira-ramp.ts index 0548c8e9..0263323d 100644 --- a/lib/naira-ramp.ts +++ b/lib/naira-ramp.ts @@ -7,6 +7,21 @@ export interface NigerianBank { code: string } +/** + * The states a SEP-24 transaction can be in. These are the values of the + * `anchor_status` enum the api crate stores. + */ +export type Sep24TransactionState = + | "incomplete" + | "pending_user_transfer_start" + | "pending_anchor" + | "pending_stellar" + | "pending_external" + | "pending_user" + | "completed" + | "expired" + | "error" + export const SUPPORTED_NIGERIAN_BANKS: NigerianBank[] = [ { name: "Access Bank", code: "044" }, { name: "Guaranty Trust Bank (GTBank)", code: "058" }, @@ -37,6 +52,29 @@ export function isValidBankCode(code: string): boolean { return SUPPORTED_NIGERIAN_BANKS.some((b) => b.code === trimmed) } +/** + * The bank with this code, or undefined when the code is not one we support. + */ +export function getBankByCode(code: string): NigerianBank | undefined { + const trimmed = code.trim() + return SUPPORTED_NIGERIAN_BANKS.find((b) => b.code === trimmed) +} + +/** + * A transaction in a terminal state will not change again, so the caller can + * stop polling it. + */ +export function isSep24Terminal(status: string): boolean { + return getSep24StatusLabel(status).isTerminal +} + +/** + * A transaction in a successful state has settled. + */ +export function isSep24Success(status: string): boolean { + return getSep24StatusLabel(status).isSuccess +} + /** * User-friendly status message for each SEP-24 transaction state. */ From 6caa620ac21f261ef4f1a55c22dd3c15ff4aed39 Mon Sep 17 00:00:00 2001 From: AptechEmmanuel Date: Thu, 22 Oct 2026 04:04:07 +0100 Subject: [PATCH 2/2] fix(chain): assert the change is less than what is left, not that it is non-negative `change_sats` is a `u64`, so `assert!(prepared.change_sats >= 0)` is always true and clippy rejects it as an absurd extreme comparison. The test withdraws 60_000 from 80_000 of input, so the change is 20_000 less the fee. --- backend/crates/chain/src/bitcoin/sender.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/crates/chain/src/bitcoin/sender.rs b/backend/crates/chain/src/bitcoin/sender.rs index 8d6ca6c7..4d30b6d9 100644 --- a/backend/crates/chain/src/bitcoin/sender.rs +++ b/backend/crates/chain/src/bitcoin/sender.rs @@ -605,7 +605,8 @@ mod tests { // Should select 50_000 + 30_000 = 80_000 assert_eq!(prepared.inputs.len(), 2); assert!(prepared.fee_sats > 0); - assert!(prepared.change_sats >= 0); + // 80_000 in, 60_000 out, so the change is 20_000 less the fee. + assert!(prepared.change_sats < 20_000); } #[tokio::test]