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/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] 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. */