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
192 changes: 36 additions & 156 deletions contracts/Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion contracts/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build:
cargo build --release --workspace

test:
cargo test --workspace --all-features
cargo test --workspace --all-features --target $(shell rustc -vV | sed -n 's|host: ||p')

clean:
cargo clean --workspace
2 changes: 1 addition & 1 deletion contracts/escrow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ publish = false
crate-type = ["cdylib"]

[dependencies]
soroban-sdk = { workspace = true }
soroban-sdk = { workspace = true, features = ["alloc"] }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
Expand Down
48 changes: 48 additions & 0 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#![no_std]
#![allow(clippy::too_many_arguments)]

extern crate alloc;

use soroban_sdk::{
contract, contractimpl, contracttype, symbol_short,
token, Address, Env, Symbol, Vec,
Expand Down Expand Up @@ -67,6 +69,8 @@ pub struct SubEscrow {
#[derive(Clone, Debug, PartialEq)]
pub enum ContractError {
ContractPaused,
Unauthorized,
TimelockNotExpired,
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -832,6 +836,50 @@ mod test {
client.cancel_and_refund(&buyer, &trade_id);
}

#[test]
fn test_admin_cancels_immediately() {
let (env, client, admin, seller, buyer, token) = setup();
env.ledger().with_mut(|l| l.timestamp = 1_000_000);

let trade_id = client.create_listing(
&seller,
&token,
&500_0000000i128,
&symbol_short!("AIRTIME"),
&(1_000_000 + 86_400),
);
client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128);

// Admin cancels immediately before timelock expiry
client.cancel_and_refund(&admin, &trade_id);

let trade = client.get_trade(&trade_id);
assert_eq!(trade.status, TradeStatus::Cancelled);
assert_eq!(trade.filled_amount, 0);

let token_client = TokenClient::new(&env, &token);
assert_eq!(token_client.balance(&buyer), 10_000_0000000i128);
}

#[test]
#[should_panic(expected = "only admin or buyer can cancel")]
fn test_seller_cancel_fails() {
let (env, client, _admin, seller, buyer, token) = setup();
env.ledger().with_mut(|l| l.timestamp = 1_000_000);

let trade_id = client.create_listing(
&seller,
&token,
&500_0000000i128,
&symbol_short!("AIRTIME"),
&(1_000_000 + 86_400),
);
client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128);

// Seller attempts to cancel and refund
client.cancel_and_refund(&seller, &trade_id);
}

// -----------------------------------------------------------------------
// Pausability tests
// -----------------------------------------------------------------------
Expand Down
40 changes: 33 additions & 7 deletions contracts/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Soroban (Rust) smart contracts for the AirFlex P2P airtime/data marketplace on the Stellar network.

**Last Updated:** August 28, 2026

---

## Contracts
Expand Down Expand Up @@ -193,19 +195,43 @@ The returned XDR decodes to a `TradeOffer` struct (seller, token, amounts, statu

---

## Escrow Contract Functions
## ⚡ Contract Functions

### 1. `create_listing`

**Who calls it:** The Seller

* **Parameters:** `seller: Address`, `token: Address`, `amount: i128`, `asset_type: Symbol`, `expires_at: u64`
* **Returns:** `u64` (the new trade ID)
* **Logic:** Registers a new trade offer in persistent storage and sets its status to `Open`. Validates that `expires_at` is in the future, `amount` is positive, and the payment token is whitelisted.
* **Authorisation:** The seller address must sign the transaction — `seller.require_auth()` is enforced before any state writes.

### 2. `deposit_to_escrow`

**Who calls it:** The Buyer

* **Parameters:** `buyer: Address`, `trade_id: u64`, `fill_amount: i128`
* **Returns:** `()`
* **Logic:** Transfers tokens from the buyer to the contract's escrow storage. Records a sub-escrow entry for the fill and transitions trade status to `Locked` once fully filled (or `PartiallyFilled` for partial purchases).
* **Authorisation:** Caller must be the buyer — `buyer.require_auth()` is enforced before reading trade state or transferring tokens.

### `create_listing(seller, token, amount, asset_type, expires_at) → u64`
### 3. `release_payment`

Called by the seller to register a new trade offer on-chain. Returns the assigned trade ID.
**Who calls it:** System Backend (via Oracle / Admin)

**Authorisation:** The seller address must sign the transaction — `seller.require_auth()` is enforced as the first statement.
* **Parameters:** `caller: Address`, `trade_id: u64`, `fill_id: u64`
* **Returns:** `()`
* **Logic:** Finalizes the trade once delivery of airtime or data is verified by transferring funds from the contract to the seller. Sets the sub-escrow to released and transitions the trade status to `Completed` when all fills are released.
* **Authorisation:** Caller must be the contract admin address — `caller.require_auth()` is enforced.

### `deposit_to_escrow(buyer, trade_id, fill_amount)`
### 4. `cancel_and_refund`

Locks the buyer's funds into escrow for a specific trade. Sets trade status to `Locked` when fully filled.
**Who calls it:** Buyer (after timelock) or Admin (immediate bypass)

**Authorisation:** Caller must be the buyer — `buyer.require_auth()` is enforced before reading trade state or transferring tokens.
* **Parameters:** `caller: Address`, `trade_id: u64`
* **Returns:** `()`
* **Logic:** Caller guard enforces that only the buyer or the contract admin can invoke this function. If the caller is the buyer, an additional check enforces the 24-hour timelock (the trade must have been in `Locked` status for at least 86,400 seconds; premature calls fail). If the caller is the admin, the timelock check is bypassed for immediate cancellation and dispute resolution. Escrowed tokens are transferred back to the buyer and trade status transitions to `Cancelled`.
* **Authorisation:** Caller must authenticate with `caller.require_auth()`. Any caller other than the buyer or admin is rejected with an unauthorized error.

---

Expand Down
4 changes: 3 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
"test": "jest --runInBand --forceExit",
"test:watch": "jest --watch",
"generate:openapi": "ts-node scripts/generate-openapi.ts",
"check:openapi": "ts-node scripts/generate-openapi.ts && git diff --exit-code openapi.json"
"check:openapi": "ts-node scripts/generate-openapi.ts && git diff --exit-code openapi.json",
"db:migrate": "ts-node src/db/migrate.ts",
"db:migrate:rollback": "ts-node src/db/migrate.ts rollback"
},
"dependencies": {
"@airflex/shared": "workspace:*",
Expand Down
130 changes: 130 additions & 0 deletions server/src/db/migrate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import fs from "fs";
import path from "path";
import os from "os";
import { runMigrations, rollbackLastMigration, ensureMigrationTable } from "./migrate";

describe("Database Migrations CLI (migrate.ts)", () => {
let tempDir: string;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "airflex-migrate-test-"));
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
jest.restoreAllMocks();
});

it("creates schema_migrations table if not exists", async () => {
const mockQuery = jest.fn().mockResolvedValue({ rows: [] });
const mockClient = {
query: mockQuery,
release: jest.fn(),
};
await ensureMigrationTable(mockClient as any);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("CREATE TABLE IF NOT EXISTS schema_migrations")
);
});

it("executes and records a new migration file when not previously applied", async () => {
const migrationFile = "001_test_migration.sql";
const migrationSql = "CREATE TABLE test_table (id INT);";
fs.writeFileSync(path.join(tempDir, migrationFile), migrationSql);

const queryLog: Array<{ text: string; params?: unknown[] }> = [];
const mockClient = {
query: jest.fn().mockImplementation(async (text: string, params?: unknown[]) => {
queryLog.push({ text, params });
if (text.includes("SELECT filename FROM schema_migrations")) {
return { rows: [] };
}
return { rows: [] };
}),
release: jest.fn(),
};

const mockPool = {
connect: jest.fn().mockResolvedValue(mockClient),
};

const result = await runMigrations({
pool: mockPool as any,
migrationsDir: tempDir,
});

expect(result.applied).toEqual([migrationFile]);
expect(result.skipped).toEqual([]);

// Verify transaction and insertion
expect(mockClient.query).toHaveBeenCalledWith("BEGIN");
expect(mockClient.query).toHaveBeenCalledWith(migrationSql);
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringContaining("INSERT INTO schema_migrations"),
[migrationFile]
);
expect(mockClient.query).toHaveBeenCalledWith("COMMIT");
expect(mockClient.release).toHaveBeenCalled();
});

it("skips previously applied migration file and reports no pending migrations", async () => {
const migrationFile = "001_test_migration.sql";
fs.writeFileSync(path.join(tempDir, migrationFile), "SELECT 1;");

const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {});

const mockClient = {
query: jest.fn().mockImplementation(async (text: string) => {
if (text.includes("SELECT filename FROM schema_migrations")) {
return { rows: [{ filename: migrationFile }] };
}
return { rows: [] };
}),
release: jest.fn(),
};

const mockPool = {
connect: jest.fn().mockResolvedValue(mockClient),
};

const result = await runMigrations({
pool: mockPool as any,
migrationsDir: tempDir,
});

expect(result.applied).toEqual([]);
expect(result.skipped).toEqual([migrationFile]);
expect(consoleSpy).toHaveBeenCalledWith("No pending migrations");
expect(mockClient.query).not.toHaveBeenCalledWith("BEGIN");
});

it("rolls back the last migration by removing it from schema_migrations", async () => {
const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {});

const mockClient = {
query: jest.fn().mockImplementation(async (text: string) => {
if (text.includes("SELECT id, filename FROM schema_migrations")) {
return { rows: [{ id: 42, filename: "002_kyc.sql" }] };
}
return { rows: [] };
}),
release: jest.fn(),
};

const mockPool = {
connect: jest.fn().mockResolvedValue(mockClient),
};

const rolledBackFile = await rollbackLastMigration({
pool: mockPool as any,
});

expect(rolledBackFile).toBe("002_kyc.sql");
expect(mockClient.query).toHaveBeenCalledWith(
expect.stringContaining("DELETE FROM schema_migrations WHERE id = $1"),
[42]
);
expect(consoleSpy).toHaveBeenCalledWith("Rolled back migration record: 002_kyc.sql");
expect(consoleSpy).toHaveBeenCalledWith("Migration to reverse: 002_kyc.sql");
});
});
Loading