Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 6 additions & 6 deletions app/(app)/batch/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,8 +47,8 @@ export default function BatchPaymentsPage() {
const [file, setFile] = useState<File | null>(null)
const [summary, setSummary] = useState<BatchSummary | null>(null)
const [batchId, setBatchId] = useState<string | null>(null)
const [batchStatus, setBatchStatus] = useState<BatchPaymentStatusResponse | null>(null)
const [rows, setRows] = useState<BatchPaymentRowResult[]>([])
const [batchStatus, setBatchStatus] = useState<BatchPaymentStatus | null>(null)
const [rows, setRows] = useState<RowResult[]>([])
const [isUploading, setIsUploading] = useState(false)
const [isExecuting, setIsExecuting] = useState(false)
const [isApproving, setIsApproving] = useState(false)
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/buy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export default function BuyPage() {
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">Rate</dt>
<dd className="text-muted-foreground">
{liveQuote ? formatRate(liveQuote.rate_kobo, liveQuote.asset) : "—"}
{liveQuote ? formatRate(liveQuote) : "—"}
</dd>
</div>
<div className="flex justify-between gap-4">
Expand Down
3 changes: 2 additions & 1 deletion app/(app)/send/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 0 additions & 1 deletion backend/crates/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion backend/crates/chain/src/bitcoin/sender.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 5 additions & 0 deletions backend/scripts/verify-migrations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 '^$'
}

Expand Down
90 changes: 34 additions & 56 deletions components/naira/NairaAnchorRamp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<NairaDepositResponse | null>(null)
const [depositStatus, setDepositStatus] = useState<NairaTransactionStatusResponse | null>(null)
const [depositTx, setDepositTx] = useState<InteractiveFlowResponse | null>(null)
const [depositStatus, setDepositStatus] = useState<AnchorTransactionStatus | null>(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<NairaWithdrawalResponse | null>(null)
const [withdrawStatus, setWithdrawStatus] = useState<NairaTransactionStatusResponse | null>(null)
const [withdrawTx, setWithdrawTx] = useState<InteractiveFlowResponse | null>(null)
const [withdrawStatus, setWithdrawStatus] = useState<AnchorTransactionStatus | null>(null)

const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -210,27 +194,24 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="deposit-amount">Amount</Label>
<Label htmlFor="deposit-amount">Amount (NGN)</Label>
<Input
id="deposit-amount"
type="number"
step="any"
placeholder="50.00"
placeholder="10000"
value={depositAmount}
onChange={(e) => setDepositAmount(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="deposit-asset">Receive Asset</Label>
<Select value={depositAsset} onValueChange={setDepositAsset}>
<SelectTrigger id="deposit-asset">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USDC">USDC (Stellar)</SelectItem>
<SelectItem value="XLM">XLM (Native)</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="deposit-asset">You receive</Label>
<p
id="deposit-asset"
className="flex h-10 items-center rounded-md border border-input bg-background px-3 py-2 text-sm"
>
USDC on Stellar
</p>
</div>
</div>

Expand Down Expand Up @@ -260,11 +241,11 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono">
Transaction ID: {depositTx.id}
Transaction ID: {depositTx.transaction_id}
</p>

<a
href={depositTx.url}
href={depositTx.interactive_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90 transition-colors"
Expand Down Expand Up @@ -297,7 +278,7 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="withdraw-amount">Amount</Label>
<Label htmlFor="withdraw-amount">Amount (USDC)</Label>
<Input
id="withdraw-amount"
type="number"
Expand All @@ -308,16 +289,13 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo
/>
</div>
<div className="space-y-2">
<Label htmlFor="withdraw-asset">Sell Asset</Label>
<Select value={withdrawAsset} onValueChange={setWithdrawAsset}>
<SelectTrigger id="withdraw-asset">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USDC">USDC (Stellar)</SelectItem>
<SelectItem value="XLM">XLM (Native)</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="withdraw-asset">You sell</Label>
<p
id="withdraw-asset"
className="flex h-10 items-center rounded-md border border-input bg-background px-3 py-2 text-sm"
>
USDC on Stellar
</p>
</div>
</div>

Expand All @@ -328,7 +306,7 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUPPORTED_BANKS.map((b) => (
{SUPPORTED_NIGERIAN_BANKS.map((b) => (
<SelectItem key={b.code} value={b.code}>
{b.name}
</SelectItem>
Expand Down Expand Up @@ -377,11 +355,11 @@ export function NairaAnchorRamp({ defaultTab = "deposit" }: { defaultTab?: "depo
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono">
Transaction ID: {withdrawTx.id}
Transaction ID: {withdrawTx.transaction_id}
</p>

<a
href={withdrawTx.url}
href={withdrawTx.interactive_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground hover:bg-primary/90 transition-colors"
Expand Down
24 changes: 24 additions & 0 deletions lib/naira-ramp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import assert from "node:assert/strict"
import {
isValidNuban,
isValidBankCode,
getBankByCode,
getSep24StatusLabel,
isSep24Terminal,
isSep24Success,
SUPPORTED_NIGERIAN_BANKS,
} from "./naira-ramp.ts"

Expand Down Expand Up @@ -36,6 +39,12 @@ describe("Naira Anchor Ramp - Bank Validation", () => {
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", () => {
Expand All @@ -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)
})
})
38 changes: 38 additions & 0 deletions lib/naira-ramp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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.
*/
Expand Down
Loading