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
508 changes: 178 additions & 330 deletions bun.lock

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"ignore": [".claude/**", "test/e2e/**", "**/dist/**"],
"workspaces": {
"packages/worker": {
"entry": ["src/worker.ts", "src/workflows/**/*.ts", "scripts/**/*.ts"]
},
"packages/relay": {
"entry": ["src/index.ts", "scripts/**/*.ts", "drizzle.config.ts"]
},
"packages/shared": {
"entry": [
"src/index.ts",
"src/networks/index.ts",
"src/db/index.ts",
"src/cache/index.ts",
"src/tracing/index.ts"
]
},
"packages/demo-agent": {
"entry": ["src/index.ts"]
},
"packages/demo-seller": {
"entry": ["src/index.ts"]
}
}
}
18 changes: 17 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,24 @@
"husky": "^9",
"lint-staged": "^15",
"prettier": "^3",
"turbo": "^2",
"turbo": "^2.9.14",
"typescript": "^5",
"typescript-eslint": "^8.58.0"
},
"overrides": {
"protobufjs": "^7.5.8",
"@protobufjs/utf8": "^1.1.1",
"axios": "^1.15.2",
"fast-uri": "^3.1.2",
"ws": "^8.20.1",
"uuid": "^11.1.1",
"hono": "^4.12.18",
"ip-address": "^10.1.2",
"follow-redirects": "^1.15.12",
"qs": "^6.15.2",
"brace-expansion": "^5.0.6",
"postcss": "^8.5.10",
"vite": "^7.3.2",
"esbuild": "^0.25.0"
}
}
4 changes: 4 additions & 0 deletions packages/relay/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ PLATFORM_FEE_BPS=0
MAX_TX_AMOUNT=1000000000
DAILY_VOLUME_LIMIT=10000000000

# --- MPP receipts (required) ---
# Secret used by @stellar/mpp to sign receipts. Generate a random 32-byte string.
MPP_SECRET_KEY=

# --- Tracing ---
OTEL_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces
9 changes: 3 additions & 6 deletions packages/relay/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,18 @@
"@elysiajs/html": "^1.4.0",
"@elysiajs/swagger": "^1",
"@kitajs/html": "^4.2.13",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/sdk-node": "^0.214.0",
"@stellar/mpp": "^0.4.0",
"@temporalio/client": "^1",
"drizzle-orm": "^0.38",
"@temporalio/client": "^1.16.2",
"drizzle-orm": "^0.45.2",
"elysia": "^1",
"ioredis": "^5",
"mppx": "^0.5.7",
"nanoid": "^5",
"postgres": "^3"
},
"devDependencies": {
"@resvg/resvg-js": "^2.6.2",
"@types/bun": "latest",
"drizzle-kit": "^0.30",
"drizzle-kit": "^0.31",
"typescript": "^5"
}
}
12 changes: 6 additions & 6 deletions packages/relay/src/bazaar/aggregations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export async function getStats({ window }: { window: Window }): Promise<StatsRes
// 2. getRoutes
// ----------------------------------------------------------------------------

export interface RouteRow {
interface RouteRow {
buyerNetwork: string
sellerNetwork: string
txCount: number
Expand Down Expand Up @@ -176,14 +176,14 @@ export async function getRoutes({ window }: { window: Window }): Promise<RoutesR
// 3. getRankedResources
// ----------------------------------------------------------------------------

export interface RankedResourceAccept {
interface RankedResourceAccept {
scheme: string
network: string
payTo: string
amount: string
}

export interface RankedResourceItem {
interface RankedResourceItem {
resource: string
type: 'http'
description: string | null
Expand Down Expand Up @@ -334,7 +334,7 @@ export async function getRankedResources(
// 4. getRankedSellers
// ----------------------------------------------------------------------------

export interface RankedSellerItem {
interface RankedSellerItem {
merchantId: string
primaryNetwork: string
txCount: number
Expand Down Expand Up @@ -412,7 +412,7 @@ export async function getRankedSellers(
// 5. getTransactions
// ----------------------------------------------------------------------------

export interface TransactionItem {
interface TransactionItem {
id: string
type: string
protocol: string | null
Expand Down Expand Up @@ -520,7 +520,7 @@ export async function getTransactions(params: TransactionsParams): Promise<Trans
// 6. getCostComparison
// ----------------------------------------------------------------------------

export interface CostComparisonTier {
interface CostComparisonTier {
amount: string
cctpAllowance: string
percentAlternative: string
Expand Down
7 changes: 6 additions & 1 deletion packages/relay/src/mpp/stellar-mpp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { SellerNotFoundError } from '@/shared/errors'

const isTestnet = resolveNetworkEnv() === 'testnet'

const MPP_SECRET_KEY = process.env.MPP_SECRET_KEY
if (!MPP_SECRET_KEY) {
throw new Error('MPP_SECRET_KEY is required')
}

function createMppServer(recipient: string) {
const currency = isTestnet ? USDC_SAC_TESTNET : USDC_SAC_MAINNET
const network = isTestnet ? STELLAR_TESTNET : STELLAR_PUBNET
Expand All @@ -18,7 +23,7 @@ function createMppServer(recipient: string) {
network,
}),
],
secretKey: process.env.MPP_SECRET_KEY ?? 'dev-secret-key-change-in-production',
secretKey: MPP_SECRET_KEY,
})
}

Expand Down
6 changes: 0 additions & 6 deletions packages/relay/src/sellers/sellers.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,3 @@ export async function findByMerchantId(merchantId: string) {
where: eq(sellers.merchantId, merchantId),
})
}

export async function findByWallet(walletAddress: string, network: string) {
return db.query.sellers.findFirst({
where: (s, { and, eq }) => and(eq(s.walletAddress, walletAddress), eq(s.network, network)),
})
}
3 changes: 0 additions & 3 deletions packages/relay/src/sellers/sellers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,3 @@ export async function getDiscovery(merchantId: string): Promise<DiscoveryRespons
gasFree: true,
}
}

// Re-export the default facilitator addresses for consumers that only want the map.
export { buildFacilitatorAddresses as _facilitatorAddresses }
2 changes: 1 addition & 1 deletion packages/relay/src/sellers/sellers.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface RegisterResponse {
codeSnippet: string
}

export interface DiscoveryNetworkEntry {
interface DiscoveryNetworkEntry {
network: string
payTo: string
asset: string
Expand Down
4 changes: 2 additions & 2 deletions packages/relay/src/settlements/settlements.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface PaymentResource {
interface PaymentResource {
url: string
description?: string
}
Expand Down Expand Up @@ -30,7 +30,7 @@ export interface VerifyResponse {
payer?: string
}

export interface PaymentAuthorization {
interface PaymentAuthorization {
from: string
to: string
value: string
Expand Down
2 changes: 1 addition & 1 deletion packages/relay/src/shared/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export class AppError extends Error {
class AppError extends Error {
constructor(
message: string,
public statusCode: number,
Expand Down
2 changes: 1 addition & 1 deletion packages/relay/src/views/nav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type NavPage = 'dashboard'
type NavPage = 'dashboard'

interface NavProps {
current?: NavPage
Expand Down
8 changes: 4 additions & 4 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@
"lint": "eslint src/"
},
"dependencies": {
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
"@opentelemetry/sdk-node": "^0.214.0",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/sdk-node": "^0.218.0",
"@solana/spl-token": "^0.4.14",
"@solana/web3.js": "^1",
"@stellar/stellar-sdk": "^12",
"drizzle-orm": "^0.38",
"drizzle-orm": "^0.45.2",
"ioredis": "^5",
"postgres": "^3",
"viem": "^2"
Expand Down
156 changes: 156 additions & 0 deletions packages/shared/src/networks/evm-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, expect, test } from 'bun:test'
import { Keypair, StrKey } from '@stellar/stellar-sdk'
import {
splitSignature,
padAddress,
stellarAddressToBytes32,
buildCctpForwarderHookData,
extractMessageHash,
} from './evm-adapter'

// EIP-3009 reuses ECDSA signatures: 32 bytes r + 32 bytes s + 1 byte v.
// A bug here is silent — the wrong v recovers a different signer, so the
// USDC contract either reverts ("invalid signature") OR (worse) pulls from
// an attacker-controlled address. Coverage here is load-bearing.
describe('splitSignature — EIP-3009 authorization decomposition', () => {
const r = '11'.repeat(32)
const s = '22'.repeat(32)

test('decomposes a canonical 65-byte hex signature (v = 27)', () => {
const sig = `0x${r}${s}1b`
expect(splitSignature(sig)).toEqual({
r: `0x${r}`,
s: `0x${s}`,
v: 27,
})
})

test('decomposes when v = 28 (the other parity)', () => {
const sig = `0x${r}${s}1c`
expect(splitSignature(sig).v).toBe(28)
})

test('accepts an unprefixed hex signature', () => {
const sig = `${r}${s}1b`
expect(splitSignature(sig)).toEqual({
r: `0x${r}`,
s: `0x${s}`,
v: 27,
})
})

test('two different signatures decompose to two different r/s pairs', () => {
const a = splitSignature(`0x${'11'.repeat(32)}${'22'.repeat(32)}1b`)
const b = splitSignature(`0x${'33'.repeat(32)}${'44'.repeat(32)}1b`)
expect(a.r).not.toBe(b.r)
expect(a.s).not.toBe(b.s)
})
})

describe('padAddress — bytes32 recipient encoding for CCTP burn', () => {
test('left-pads a 20-byte EVM address to 32 bytes', () => {
const addr = '0x' + 'ab'.repeat(20)
const padded = padAddress(addr)
expect(padded.length).toBe(2 + 64)
expect(padded.startsWith('0x000000000000000000000000ab')).toBe(true)
expect(padded.endsWith('ab'.repeat(20))).toBe(true)
})

test('accepts an unprefixed address', () => {
const padded = padAddress('ab'.repeat(20))
expect(padded).toBe('0x' + '0'.repeat(24) + 'ab'.repeat(20))
})
})

describe('stellarAddressToBytes32 — destination encoding for Stellar mint targeting', () => {
test('encodes a valid G... Ed25519 public key (32 raw bytes, no padding)', () => {
const account = Keypair.random().publicKey()
const encoded = stellarAddressToBytes32(account)
expect(encoded.length).toBe(2 + 64)
// Reverse: hex back to 32 bytes equals StrKey decode.
const raw = Buffer.from(encoded.slice(2), 'hex')
expect(raw.equals(Buffer.from(StrKey.decodeEd25519PublicKey(account)))).toBe(true)
})

test('encodes a C... contract address via the contract decode branch', () => {
// A valid Soroban contract strkey is 32 zero bytes encoded with the contract prefix.
const contractRaw = Buffer.alloc(32, 0)
const contractStrkey = StrKey.encodeContract(contractRaw)
const encoded = stellarAddressToBytes32(contractStrkey)
expect(encoded).toBe('0x' + '00'.repeat(32))
})

test('two different accounts encode to two different bytes32', () => {
const a = stellarAddressToBytes32(Keypair.random().publicKey())
const b = stellarAddressToBytes32(Keypair.random().publicKey())
expect(a).not.toBe(b)
})
})

describe('buildCctpForwarderHookData — Stellar forwarder hook payload', () => {
test('encodes [16 zero bytes | u32(version=0) | u32(length) | utf8(recipient)]', () => {
const recipient = 'G' + 'A'.repeat(55)
const hex = buildCctpForwarderHookData(recipient)
const buf = Buffer.from(hex.slice(2), 'hex')

// First 24 bytes are reserved zeros (alloc'd, never written by the builder).
expect(buf.subarray(0, 24).every((b) => b === 0)).toBe(true)
// u32 BE at offset 24 = hook version = 0.
expect(buf.readUInt32BE(24)).toBe(0)
// u32 BE at offset 28 = recipient byte length.
expect(buf.readUInt32BE(28)).toBe(recipient.length)
// Bytes 32+ = UTF-8 of the recipient strkey.
expect(buf.subarray(32).toString('utf8')).toBe(recipient)
})

test('total length = 32 + recipient.length', () => {
const recipient = 'G' + 'A'.repeat(55)
const hex = buildCctpForwarderHookData(recipient)
const buf = Buffer.from(hex.slice(2), 'hex')
expect(buf.length).toBe(32 + recipient.length)
})
})

// extractMessageHash defines the CCTP message identity used downstream
// for attestation polling AND on-chain mint. Returning the wrong log here
// means cctpMint targets the wrong message — either it reverts, or worse
// (with a colliding hash on a different attestation) duplicates a settlement.
describe('extractMessageHash — CCTP MessageSent identity', () => {
const MESSAGE_SENT_TOPIC = '0x8c5261668696ce22758910d05bab8f186d6eb247ceac2af2e82c7dc17669b036'

test('returns the data field of the matching log', () => {
const receipt = {
logs: [{ topics: [MESSAGE_SENT_TOPIC, '0xother'], data: '0xMESSAGE_BYTES' }],
}
expect(extractMessageHash(receipt)).toBe('0xMESSAGE_BYTES')
})

test('skips unrelated logs and picks the matching one', () => {
const receipt = {
logs: [
{ topics: ['0xUNRELATED_TOPIC'], data: '0xnoise' },
{ topics: [MESSAGE_SENT_TOPIC], data: '0xMESSAGE_BYTES' },
],
}
expect(extractMessageHash(receipt)).toBe('0xMESSAGE_BYTES')
})

test('throws when no MessageSent log is present', () => {
const receipt = { logs: [{ topics: ['0xUNRELATED'], data: '0xnoise' }] }
expect(() => extractMessageHash(receipt)).toThrow(/MessageSent event not found/)
})

test('throws on empty logs', () => {
expect(() => extractMessageHash({ logs: [] })).toThrow(/MessageSent event not found/)
})

test('returns the FIRST matching log when multiple are present', () => {
const receipt = {
logs: [
{ topics: [MESSAGE_SENT_TOPIC], data: '0xFIRST' },
{ topics: [MESSAGE_SENT_TOPIC], data: '0xSECOND' },
],
}
expect(extractMessageHash(receipt)).toBe('0xFIRST')
})
})
Loading
Loading