Skip to content
Open
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
Empty file added IMPLEMENTATION.md
Empty file.
6 changes: 6 additions & 0 deletions apps/currency-converter-x402/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
.git
.env
.env.local
bun.lock
*.log
7 changes: 7 additions & 0 deletions apps/currency-converter-x402/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
PORT=3000
FRANKFURTER_API_BASE=https://api.frankfurter.app
X402_PRICE_USD=0.001
PAYMENT_SECRET=replace-with-a-long-random-secret
PAYMENT_TTL_SECONDS=300
PAYMENT_REALM=currency-converter
FX_TIMEOUT_MS=8000
6 changes: 6 additions & 0 deletions apps/currency-converter-x402/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
bun.lock
*.log
18 changes: 18 additions & 0 deletions apps/currency-converter-x402/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM oven/bun:1.2.8-alpine

WORKDIR /app

COPY package.json tsconfig.json ./
RUN bun install

COPY src ./src
COPY scripts ./scripts
COPY README.md ./README.md
COPY SUBMISSION.md ./SUBMISSION.md

ENV NODE_ENV=production
ENV PORT=3000

EXPOSE 3000

CMD ["bun", "run", "start"]
21 changes: 21 additions & 0 deletions apps/currency-converter-x402/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "currency-converter-x402-agent",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Lucid Agent currency converter with x402 payment enforcement.",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"check": "bunx tsc --noEmit",
"make:payment-header": "bun scripts/make-payment-header.ts"
},
"dependencies": {
"@lucid-agents/http": "latest",
"@lucid-agents/payments": "latest"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.9.2"
}
}
10 changes: 10 additions & 0 deletions apps/currency-converter-x402/railway.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"build": {
"builder": "NIXPACKS"
},
"deploy": {
"startCommand": "bun run start",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}
37 changes: 37 additions & 0 deletions apps/currency-converter-x402/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface AppConfig {
port: number;
frankfurterApiBase: string;
priceUsd: number;
paymentSecret: string;
paymentTtlSeconds: number;
paymentRealm: string;
fxTimeoutMs: number;
}

function env(name: string): string | undefined {
return process.env[name];
}

function parsePort(raw: string | undefined, fallback: number): number {
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return fallback;
return parsed;
}

function parsePositiveNumber(raw: string | undefined, fallback: number): number {
if (!raw) return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return parsed;
}

export const appConfig: AppConfig = {
port: parsePort(env("PORT"), 3000),
frankfurterApiBase: env("FRANKFURTER_API_BASE") ?? "https://api.frankfurter.app",
priceUsd: parsePositiveNumber(env("X402_PRICE_USD"), 0.001),
paymentSecret: env("PAYMENT_SECRET") ?? "dev-only-change-me",
paymentTtlSeconds: parsePositiveNumber(env("PAYMENT_TTL_SECONDS"), 300),
paymentRealm: env("PAYMENT_REALM") ?? "currency-converter",
fxTimeoutMs: parsePositiveNumber(env("FX_TIMEOUT_MS"), 8000)
};
72 changes: 72 additions & 0 deletions apps/currency-converter-x402/src/fx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
export interface ConvertCurrencyInput {
apiBaseUrl: string;
from: string;
to: string;
amount: number;
timeoutMs: number;
}

export interface ConvertCurrencyResult {
result: number;
rate: number;
}

interface FrankfurterResponse {
amount: number;
base: string;
date: string;
rates: Record<string, number>;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function parseFrankfurterResponse(raw: unknown, target: string, requestedAmount: number): ConvertCurrencyResult {
if (!isRecord(raw)) {
throw new Error("Invalid FX API response shape.");
}

const rates = raw.rates;
if (!isRecord(rates)) {
throw new Error("FX API response missing rates.");
}

const rawResult = rates[target];
if (typeof rawResult !== "number" || !Number.isFinite(rawResult)) {
throw new Error(`FX API response missing rate for ${target}.`);
}

const result = rawResult;
const rate = result / requestedAmount;

return { result, rate };
}

export async function convertCurrency(input: ConvertCurrencyInput): Promise<ConvertCurrencyResult> {
const { apiBaseUrl, from, to, amount, timeoutMs } = input;

const endpoint = new URL("/latest", apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`);
endpoint.searchParams.set("from", from);
endpoint.searchParams.set("to", to);
endpoint.searchParams.set("amount", String(amount));

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);

try {
const response = await fetch(endpoint, {
method: "GET",
signal: controller.signal
});

if (!response.ok) {
throw new Error(`FX provider returned HTTP ${response.status}.`);
}

const data = (await response.json()) as FrankfurterResponse;
return parseFrankfurterResponse(data, to, amount);
} finally {
clearTimeout(timeout);
}
}
45 changes: 45 additions & 0 deletions apps/currency-converter-x402/src/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as lucidHttp from "@lucid-agents/http";

type JsonFactory = (body: unknown, init?: ResponseInit) => Response;

function asExportsMap(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}

function resolveJsonFactory(): JsonFactory | null {
const exportsMap = asExportsMap(lucidHttp);

const candidateA = exportsMap.json;
if (typeof candidateA === "function") {
return candidateA as JsonFactory;
}

const candidateB = exportsMap.createJsonResponse;
if (typeof candidateB === "function") {
return candidateB as JsonFactory;
}

return null;
}

const jsonFactory = resolveJsonFactory();

export const lucidHttpRuntimeInfo = {
exportCount: Object.keys(asExportsMap(lucidHttp)).length
};

export function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
if (jsonFactory) {
return jsonFactory(body, init);
}

const headers = new Headers(init.headers);
if (!headers.has("content-type")) {
headers.set("content-type", "application/json; charset=utf-8");
}

return new Response(JSON.stringify(body), {
...init,
headers
});
}
2 changes: 2 additions & 0 deletions apps/currency-converter-x402/src/shims.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declare module "@lucid-agents/http";
declare module "@lucid-agents/payments";
14 changes: 14 additions & 0 deletions apps/currency-converter-x402/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"types": ["bun"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "scripts/**/*.ts"]
}
5 changes: 5 additions & 0 deletions packages/currency-converter-x402-agent/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.git
.gitignore
README.md
TASKMARKET_SUBMISSION.md
6 changes: 6 additions & 0 deletions packages/currency-converter-x402-agent/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
PORT=3000
HOST=0.0.0.0
LOOKUP_PRICE_USD=0.001
X402_TEST_TOKEN=demo_x402_paid
FRANKFURTER_BASE_URL=https://api.frankfurter.app
FX_TIMEOUT_MS=10000
17 changes: 17 additions & 0 deletions packages/currency-converter-x402-agent/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM oven/bun:1.2

WORKDIR /app

COPY package.json tsconfig.json ./
RUN bun install

COPY src ./src
COPY .env.example ./.env.example

ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0

EXPOSE 3000

CMD ["bun", "run", "start"]
76 changes: 76 additions & 0 deletions packages/currency-converter-x402-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Currency Converter Lucid Agent (x402)

Built via TaskMarket bounty on taskmarket.xyz.

This package adds a Bun + TypeScript Lucid Agent that converts currencies through frankfurter.app and enforces x402-style payment for each lookup.

## What it does
- Uses `@lucid-agents/http` for HTTP response integration (with safe native fallback).
- Uses `@lucid-agents/payments` for payment verification hooks (with deterministic fallback token verification).
- Exposes:
- `GET /convert?from=USD&to=EUR&amount=100`
- `GET /health`
- Charges $0.001 per conversion request through payment enforcement.
- Returns JSON:
- `{ from, to, amount, result, rate }`

## Local run
```bash
cp .env.example .env
bun install
bun run dev
```

## Required payment header
By default (local/dev), a valid paid request is:
- `x-payment: demo_x402_paid`

Set your own value in `X402_TEST_TOKEN` for production.

## curl examples

### 1) Request without payment (returns 402)
```bash
curl -i "http://localhost:3000/convert?from=USD&to=EUR&amount=100"
```

Example response body:
```json
{
"error": "payment_required",
"message": "x402 payment is required for /convert",
"reason": "Missing payment header",
"payment": {
"amount_usd": 0.001,
"header": "x-payment",
"accepts": "x402 payment token"
}
}
```

### 2) Paid request (returns 200)
```bash
curl -i \
-H "x-payment: demo_x402_paid" \
"http://localhost:3000/convert?from=USD&to=EUR&amount=100"
```

Example response body:
```json
{
"from": "USD",
"to": "EUR",
"amount": 100,
"result": 92.11,
"rate": 0.9211
}
```

## Deploy (Railway / Render / Fly.io)
- Service listens on `PORT` and `HOST`.
- Health endpoint: `GET /health`.
- Start command: `bun run start`.
- Use environment variables from `.env.example`.

## Task deliverable note
See `TASKMARKET_SUBMISSION.md` for the exact submission format required by the bounty.
Loading