Skip to content
Draft
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
7 changes: 5 additions & 2 deletions p2p-safe-swap/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "@/frontend/components/theme-provider";
import { ThemeToggle } from "@/frontend/components/ui/theme-toggle";
import { WalletProvider } from "@/frontend/components/wallet";

// SafeSwap brand typeface (self-hosted variable font).
const satoshi = localFont({
Expand Down Expand Up @@ -39,8 +40,10 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
{children}
<ThemeToggle className="fixed bottom-4 right-4 z-50" />
<WalletProvider>
{children}
<ThemeToggle className="fixed bottom-4 right-4 z-50" />
</WalletProvider>
</ThemeProvider>
</body>
</html>
Expand Down
5 changes: 5 additions & 0 deletions p2p-safe-swap/app/p2p/orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from "react";
import { P2POrderList } from "@/frontend/components/p2p";
import type { OrderMode, P2POrder } from "@/frontend/components/p2p";
import { ConnectWalletButton } from "@/frontend/components/wallet";

const MOCK_ORDERS: P2POrder[] = [
{
Expand Down Expand Up @@ -65,6 +66,10 @@ export default function OrdersPage() {

return (
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col">
<div className="flex justify-end px-4 pt-4">
<ConnectWalletButton />
</div>

<P2POrderList
orders={MOCK_ORDERS}
bestPrice={BEST_PRICE}
Expand Down
5 changes: 5 additions & 0 deletions p2p-safe-swap/app/transactions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type Transaction,
type TransactionTab,
} from "@/frontend/components/ui/transaction-list";
import { ConnectWalletButton } from "@/frontend/components/wallet";

function createDate(daysAgo: number, hours: number, minutes: number): string {
const date = new Date();
Expand Down Expand Up @@ -88,6 +89,10 @@ export default function TransactionsPage() {

return (
<div className="mx-auto flex min-h-full w-full max-w-md flex-col bg-background px-4 py-6">
<div className="mb-4 flex justify-end">
<ConnectWalletButton />
</div>

<header className="mb-6 flex items-center justify-between">
<button
type="button"
Expand Down
83 changes: 83 additions & 0 deletions p2p-safe-swap/frontend/components/escrows/adapters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { Transaction } from "@/frontend/components/ui/transaction-list";
import type { P2POrder } from "@/frontend/components/p2p/types";
import type { EscrowStatus, IndexerEscrow } from "./types";
import { deriveEscrowStatus, directionForSigner, roleOfSigner } from "./status";

// Adapters map an indexer escrow onto the existing UI view-models.
//
// Both output types are the existing shapes PLUS a derived `status` and
// `contractId`, so the current components keep rendering while the pages gain
// live escrow status. Extend the shared `Transaction` / `P2POrder` types (and
// their components) to display `status` as a follow-up UI step.

export type EscrowTransaction = Transaction & {
status: EscrowStatus;
contractId?: string;
};

export type EscrowOrder = P2POrder & {
status: EscrowStatus;
contractId?: string;
};

function shortAddress(address: string): string {
if (address.length <= 10) return address;
return `${address.slice(0, 4)}…${address.slice(-4)}`;
}

/** The other side of the trade, relative to the connected wallet. */
function counterpartyAddress(escrow: IndexerEscrow, signer: string): string {
const isReceiver = roleOfSigner(escrow, signer) === "receiver";
return isReceiver ? escrow.roles.serviceProvider : escrow.roles.receiver;
}

/** Escrow → transactions-list row. */
export function escrowToTransaction(
escrow: IndexerEscrow,
signer: string
): EscrowTransaction {
return {
id: escrow.contractId ?? escrow.engagementId,
contractId: escrow.contractId,
address: shortAddress(counterpartyAddress(escrow, signer)),
memo: escrow.title,
amount: escrow.amount,
date: escrow.createdAt,
type: directionForSigner(escrow, signer),
status: deriveEscrowStatus(escrow),
};
}

/**
* Escrow → orders-list card.
*
* Marketplace-only fields (price, currencyPair, rating, opsCount, verified,
* windowMinutes, paymentMethods) are NOT part of on-chain escrow data — they
* are set to safe placeholders here. The meaningful, live fields are `status`,
* `available` (current balance), amount limits, and the counterparty address.
*/
export function escrowToOrder(
escrow: IndexerEscrow,
signer: string
): EscrowOrder {
const counterparty = counterpartyAddress(escrow, signer);
return {
id: escrow.contractId ?? escrow.engagementId,
contractId: escrow.contractId,
status: deriveEscrowStatus(escrow),
user: {
name: escrow.title || shortAddress(counterparty),
initials: shortAddress(counterparty).slice(0, 2).toUpperCase(),
verified: false,
rating: 0,
opsCount: 0,
address: counterparty,
},
price: 0, // not on-chain
currencyPair: { base: escrow.trustline.symbol, quote: escrow.trustline.symbol },
available: escrow.balance ?? escrow.amount,
limits: { min: 0, max: escrow.amount },
windowMinutes: 0, // not on-chain
paymentMethods: [], // not on-chain
};
}
51 changes: 51 additions & 0 deletions p2p-safe-swap/frontend/components/escrows/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { GetEscrowsBySignerParams, IndexerEscrow } from "./types";
import { deriveEscrowStatus } from "./status";
import { MOCK_ESCROWS } from "./mock";

// Single seam for fetching escrows. Today it filters/paginates the mock data
// client-side; once #306 / PR #324 lands, replace the body of
// `getEscrowsBySigner` with a real fetch to the proxied API route:
//
// const res = await fetch(`/api/escrows?${new URLSearchParams(...)}`)
// return res.json()
//
// The real Trustless Work call MUST stay server-side (the x-api-key never
// reaches the browser) — see #318 for the key handling. Hence the /api proxy.

const PAGE_SIZE = 10;

/** Mirrors the endpoint: page-based, returns a bare array (no total/envelope). */
export async function getEscrowsBySigner(
params: GetEscrowsBySignerParams
): Promise<IndexerEscrow[]> {
const {
signer,
type,
status,
role,
engagementId,
isActive,
orderBy = "createdAt",
orderDirection = "desc",
page = 1,
} = params;

let rows = MOCK_ESCROWS.filter((e) => e.signer === signer || e.user === signer);

if (type) rows = rows.filter((e) => e.type === type);
if (status) rows = rows.filter((e) => deriveEscrowStatus(e) === status);
if (engagementId) rows = rows.filter((e) => e.engagementId === engagementId);
if (typeof isActive === "boolean") rows = rows.filter((e) => e.isActive === isActive);
if (role) rows = rows.filter((e) => Boolean(e.roles[role]));

rows = [...rows].sort((a, b) => {
const av = orderBy === "amount" ? a.amount : Date.parse(a[orderBy]);
const bv = orderBy === "amount" ? b.amount : Date.parse(b[orderBy]);
return orderDirection === "asc" ? av - bv : bv - av;
});

const start = (page - 1) * PAGE_SIZE;
return rows.slice(start, start + PAGE_SIZE);
}

export const ESCROWS_PAGE_SIZE = PAGE_SIZE;
6 changes: 6 additions & 0 deletions p2p-safe-swap/frontend/components/escrows/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from "./types";
export * from "./status";
export * from "./adapters";
export { getEscrowsBySigner, ESCROWS_PAGE_SIZE } from "./client";
export { useEscrows, type UseEscrowsResult } from "./use-escrows";
export { MOCK_ESCROWS, MOCK_SIGNER } from "./mock";
104 changes: 104 additions & 0 deletions p2p-safe-swap/frontend/components/escrows/mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { IndexerEscrow, Roles } from "./types";

// Deterministic mock escrows shaped exactly like the indexer response, so the
// UI + adapters can be built and tested before the real endpoint is wired
// (blocked on #306 / PR #324). Swap these out in client.ts, not here.

const SIGNER = "GBUSER000000000000000000000000000000000000000000000000000SELF";

function roles(overrides: Partial<Roles>): Roles {
return {
approver: "GBAPPROVER00000000000000000000000000000000000000000000000000",
serviceProvider: "GBSERVICE0000000000000000000000000000000000000000000000000",
platformAddress: "GBPLATFORM000000000000000000000000000000000000000000000000",
releaseSigner: "GBRELEASE0000000000000000000000000000000000000000000000000",
disputeResolver: "GBDISPUTE0000000000000000000000000000000000000000000000000",
receiver: "GBRECEIVER000000000000000000000000000000000000000000000000",
...overrides,
};
}

const USDC = { symbol: "USDC", address: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", name: "USD Coin" };

/** MOCK_ESCROWS[i].signer is SELF, so this wallet participates in every record. */
export const MOCK_SIGNER = SIGNER;

export const MOCK_ESCROWS: IndexerEscrow[] = [
{
signer: SIGNER,
user: SIGNER,
contractId: "CONTRACT_PENDING_0001",
engagementId: "eng-001",
title: "USDC buy from Diego V.",
description: "P2P USDC purchase, SEPA",
roles: roles({ receiver: SIGNER }), // signer receives → "in"
amount: 250,
platformFee: 1.5,
balance: 0, // not funded → pending
milestones: [{ description: "Fiat sent", status: "pending" }],
flags: {},
trustline: USDC,
isActive: true,
createdAt: "2026-07-19T09:12:00.000Z",
updatedAt: "2026-07-19T09:12:00.000Z",
type: "single-release",
},
{
signer: SIGNER,
user: SIGNER,
contractId: "CONTRACT_FUNDED_0002",
engagementId: "eng-002",
title: "USDC sell to Ana C.",
description: "P2P USDC sale, Bizum",
roles: roles({ serviceProvider: SIGNER }), // signer pays in → "out"
amount: 600,
platformFee: 3,
balance: 600, // funded, not released
milestones: [{ description: "Escrow funded", status: "funded" }],
flags: {},
trustline: USDC,
isActive: true,
createdAt: "2026-07-18T14:15:00.000Z",
updatedAt: "2026-07-18T16:02:00.000Z",
type: "single-release",
},
{
signer: SIGNER,
user: SIGNER,
contractId: "CONTRACT_DISPUTED_0003",
engagementId: "eng-003",
title: "USDC buy from Carlos L.",
description: "P2P USDC purchase, Revolut",
roles: roles({ receiver: SIGNER }),
amount: 120,
platformFee: 0.6,
balance: 120,
milestones: [{ description: "Payment disputed", status: "disputed", evidence: "receipt.png" }],
flags: { disputed: true },
disputeStartedBy: SIGNER,
trustline: USDC,
isActive: true,
createdAt: "2026-07-17T19:30:00.000Z",
updatedAt: "2026-07-18T08:44:00.000Z",
type: "single-release",
},
{
signer: SIGNER,
user: SIGNER,
contractId: "CONTRACT_RELEASED_0004",
engagementId: "eng-004",
title: "USDC sell to Sofía P.",
description: "P2P USDC sale, Wise",
roles: roles({ serviceProvider: SIGNER }),
amount: 5000,
platformFee: 25,
balance: 0, // released → balance drained
milestones: [{ description: "Funds released", status: "released", approved: true }],
flags: { released: true, approved: true },
trustline: USDC,
isActive: false,
createdAt: "2026-07-01T11:00:00.000Z",
updatedAt: "2026-07-02T10:10:00.000Z",
type: "single-release",
},
];
48 changes: 48 additions & 0 deletions p2p-safe-swap/frontend/components/escrows/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { EscrowStatus, IndexerEscrow, Role } from "./types";

/**
* Derive the UI status from an escrow's on-chain flags + balance.
*
* The indexer does NOT return a single `status` string, so we compute it.
* Precedence (provisional — reconcile against the real `SingleReleaseEscrowStatus`
* enum once the #306/#324 client lands):
* released → funds have been released to the receiver
* disputed → an open, unresolved dispute
* funded → USDC is locked in the contract but not yet released
* pending → deployed but not yet funded
*/
export function deriveEscrowStatus(escrow: IndexerEscrow): EscrowStatus {
const flags = escrow.flags ?? {};

if (flags.released) return "released";
if (flags.disputed && !flags.resolved) return "disputed";
if ((escrow.balance ?? 0) > 0) return "funded";
return "pending";
}

/**
* Which role the connected wallet plays in a given escrow, if any.
* Used to decide transaction direction (in/out) on the transactions list.
*/
export function roleOfSigner(
escrow: IndexerEscrow,
signer: string
): Role | null {
const { roles } = escrow;
const match = (Object.keys(roles) as Role[]).find(
(role) => roles[role]?.toLowerCase() === signer.toLowerCase()
);
return match ?? null;
}

/**
* Direction of funds relative to the connected wallet.
* The receiver is money-in; every other funding role (approver / service
* provider paying into escrow) is money-out.
*/
export function directionForSigner(
escrow: IndexerEscrow,
signer: string
): "in" | "out" {
return roleOfSigner(escrow, signer) === "receiver" ? "in" : "out";
}
Loading