diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 34b09ea..59d76dd 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -21,7 +21,29 @@
"Bash(python3 -m json.tool)",
"Bash(bun install:*)",
"Bash(grep:*)",
- "Bash(bunx turbo:*)"
+ "Bash(bunx turbo:*)",
+ "Bash(find:*)",
+ "Bash(python3 -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(t['path']\\) for t in data.get\\('tree',[]\\)]\")",
+ "Bash(python3 -c \"import sys,json; [print\\(f['name'],f['type']\\) for f in json.load\\(sys.stdin\\)]\")",
+ "Bash(temporal operator:*)",
+ "Read(//private/tmp/**)",
+ "Bash(gh api:*)",
+ "Bash(ls /Users/henriquebreim/facilitator/docker-compose*)",
+ "Bash(ls /Users/henriquebreim/facilitator/docker*)",
+ "WebFetch(domain:www.circle.com)",
+ "Bash(bun test:*)",
+ "Bash(npx tsc:*)",
+ "WebFetch(domain:raw.githubusercontent.com)",
+ "WebFetch(domain:www.x402.org)",
+ "WebFetch(domain:docs.x402.org)",
+ "Bash(rm *)",
+ "WebFetch(domain:www.x402scan.com)",
+ "WebFetch(domain:dexter.cash)",
+ "Bash(gh pr *)",
+ "Bash(sed -n '25,1260p' /Users/henriquebreim/facilitator/packages/relay/public/index.html)",
+ "Bash(sed -n '1279,1850p' /Users/henriquebreim/facilitator/packages/relay/public/index.html)",
+ "Bash(sed -n '1853,2142p' /Users/henriquebreim/facilitator/packages/relay/public/index.html)",
+ "WebFetch(domain:agentcard.402.md)"
]
}
}
diff --git a/bun.lock b/bun.lock
index a527e3e..c2eb021 100644
--- a/bun.lock
+++ b/bun.lock
@@ -39,7 +39,9 @@
"dependencies": {
"@402md/shared": "workspace:*",
"@elysiajs/cors": "^1",
+ "@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",
@@ -132,6 +134,8 @@
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
+ "@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
+
"@elysiajs/swagger": ["@elysiajs/swagger@1.3.1", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-LcbLHa0zE6FJKWPWKsIC/f+62wbDv3aXydqcNPVPyqNcaUgwvCajIi+5kHEU6GO3oXUCpzKaMsb3gsjt8sLzFQ=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@@ -266,6 +270,10 @@
"@jsonjoy.com/util": ["@jsonjoy.com/util@1.9.0", "", { "dependencies": { "@jsonjoy.com/buffers": "^1.0.0", "@jsonjoy.com/codegen": "^1.0.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ=="],
+ "@kitajs/html": ["@kitajs/html@4.2.13", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-o+8e61EsoLDPTP7rsPkYolca1YFybHuxU2Lr5fWDZCUkYT/6uBlVkvnZUdCXMQKentJL9dxwpR8/xK2Q+U4LhA=="],
+
+ "@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.4", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.9.3" }, "bin": { "xss-scan": "dist/cli.js", "ts-html-plugin": "dist/cli.js" } }, "sha512-xK5mNrhnIy73kJFKx5yVGChJyWFRGmIaE0sjlVxVYllk5dyaEYVCrIh1N8AfnseEHka8gAqzPGW95HlkhDvnJA=="],
+
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
@@ -630,7 +638,7 @@
"@types/aws-lambda": ["@types/aws-lambda@8.10.161", "", {}, "sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ=="],
- "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
+ "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/bunyan": ["@types/bunyan@1.8.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ=="],
@@ -820,7 +828,7 @@
"bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="],
- "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
+ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -850,7 +858,7 @@
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
- "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
+ "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
@@ -878,6 +886,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -1566,9 +1576,9 @@
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
- "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
+ "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
- "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+ "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1584,6 +1594,8 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+ "@grpc/proto-loader/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
+
"@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack": ["@jsonjoy.com/json-pack@17.67.0", "", { "dependencies": { "@jsonjoy.com/base64": "17.67.0", "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0", "@jsonjoy.com/json-pointer": "17.67.0", "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w=="],
"@jsonjoy.com/fs-snapshot/@jsonjoy.com/util": ["@jsonjoy.com/util@17.67.0", "", { "dependencies": { "@jsonjoy.com/buffers": "17.67.0", "@jsonjoy.com/codegen": "17.67.0" }, "peerDependencies": { "tslib": "2" } }, "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew=="],
@@ -1592,6 +1604,8 @@
"@jsonjoy.com/util/@jsonjoy.com/buffers": ["@jsonjoy.com/buffers@1.2.1", "", { "peerDependencies": { "tslib": "2" } }, "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA=="],
+ "@kitajs/ts-html-plugin/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+
"@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"@modelcontextprotocol/sdk/eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
@@ -1648,12 +1662,6 @@
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
- "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
-
- "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
-
- "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
-
"eslint/@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -1714,8 +1722,6 @@
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
- "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
-
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
@@ -1760,6 +1766,12 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
+ "@grpc/proto-loader/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
+
+ "@grpc/proto-loader/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "@grpc/proto-loader/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+
"@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/base64": ["@jsonjoy.com/base64@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw=="],
"@jsonjoy.com/fs-snapshot/@jsonjoy.com/json-pack/@jsonjoy.com/codegen": ["@jsonjoy.com/codegen@17.67.0", "", { "peerDependencies": { "tslib": "2" } }, "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q=="],
@@ -1798,12 +1810,6 @@
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
- "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
-
- "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
-
- "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"gel/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
@@ -1910,11 +1916,15 @@
"webpack/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
- "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+ "@grpc/proto-loader/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "@grpc/proto-loader/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
- "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+ "@grpc/proto-loader/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
- "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+ "@grpc/proto-loader/yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
+ "@grpc/proto-loader/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@solana/codecs/@solana/codecs-core/@solana/errors/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@@ -1926,6 +1936,8 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
- "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "@grpc/proto-loader/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "@grpc/proto-loader/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}
diff --git a/packages/relay/drizzle/0003_cooing_sentry.sql b/packages/relay/drizzle/0003_cooing_sentry.sql
new file mode 100644
index 0000000..60d1f4c
--- /dev/null
+++ b/packages/relay/drizzle/0003_cooing_sentry.sql
@@ -0,0 +1,2 @@
+CREATE INDEX "idx_transactions_buyer_network" ON "transactions" USING btree ("buyer_network");--> statement-breakpoint
+CREATE INDEX "idx_transactions_seller_network" ON "transactions" USING btree ("seller_network");
\ No newline at end of file
diff --git a/packages/relay/drizzle/meta/0003_snapshot.json b/packages/relay/drizzle/meta/0003_snapshot.json
new file mode 100644
index 0000000..e40f95e
--- /dev/null
+++ b/packages/relay/drizzle/meta/0003_snapshot.json
@@ -0,0 +1,546 @@
+{
+ "id": "dec484ad-a09e-49f6-b3b2-0f1ccfbe6c1c",
+ "prevId": "489e6a6b-3e17-4921-8216-15d9f418c3f3",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.bazaar_resources": {
+ "name": "bazaar_resources",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "resource_url": {
+ "name": "resource_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "base_url": {
+ "name": "base_url",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "seller_id": {
+ "name": "seller_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "merchant_id": {
+ "name": "merchant_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "network": {
+ "name": "network",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pay_to": {
+ "name": "pay_to",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount": {
+ "name": "amount",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scheme": {
+ "name": "scheme",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'exact'"
+ },
+ "use_count": {
+ "name": "use_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "total_volume": {
+ "name": "total_volume",
+ "type": "numeric(30, 0)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "uniq_bazaar_resource_network": {
+ "name": "uniq_bazaar_resource_network",
+ "columns": [
+ {
+ "expression": "resource_url",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "network",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_bazaar_resources_seller": {
+ "name": "idx_bazaar_resources_seller",
+ "columns": [
+ {
+ "expression": "seller_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_bazaar_resources_base_url": {
+ "name": "idx_bazaar_resources_base_url",
+ "columns": [
+ {
+ "expression": "base_url",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_bazaar_resources_use_count": {
+ "name": "idx_bazaar_resources_use_count",
+ "columns": [
+ {
+ "expression": "use_count",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "bazaar_resources_seller_id_sellers_id_fk": {
+ "name": "bazaar_resources_seller_id_sellers_id_fk",
+ "tableFrom": "bazaar_resources",
+ "tableTo": "sellers",
+ "columnsFrom": ["seller_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sellers": {
+ "name": "sellers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "merchant_id": {
+ "name": "merchant_id",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "wallet_address": {
+ "name": "wallet_address",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "network": {
+ "name": "network",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_sellers_merchant": {
+ "name": "idx_sellers_merchant",
+ "columns": [
+ {
+ "expression": "merchant_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sellers_merchant_id_unique": {
+ "name": "sellers_merchant_id_unique",
+ "nullsNotDistinct": false,
+ "columns": ["merchant_id"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.transactions": {
+ "name": "transactions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "protocol": {
+ "name": "protocol",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "buyer_address": {
+ "name": "buyer_address",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "buyer_network": {
+ "name": "buyer_network",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seller_address": {
+ "name": "seller_address",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seller_network": {
+ "name": "seller_network",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seller_id": {
+ "name": "seller_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gross_amount": {
+ "name": "gross_amount",
+ "type": "numeric(30, 0)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "net_amount": {
+ "name": "net_amount",
+ "type": "numeric(30, 0)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "platform_fee": {
+ "name": "platform_fee",
+ "type": "numeric(30, 0)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gas_allowance": {
+ "name": "gas_allowance",
+ "type": "numeric(30, 0)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0'"
+ },
+ "stripe_fee": {
+ "name": "stripe_fee",
+ "type": "numeric(30, 0)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pull_tx_hash": {
+ "name": "pull_tx_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "burn_tx_hash": {
+ "name": "burn_tx_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mint_tx_hash": {
+ "name": "mint_tx_hash",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workflow_id": {
+ "name": "workflow_id",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bridge_provider": {
+ "name": "bridge_provider",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "settled_at": {
+ "name": "settled_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_transactions_seller": {
+ "name": "idx_transactions_seller",
+ "columns": [
+ {
+ "expression": "seller_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_transactions_status": {
+ "name": "idx_transactions_status",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_transactions_created": {
+ "name": "idx_transactions_created",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_transactions_seller_date": {
+ "name": "idx_transactions_seller_date",
+ "columns": [
+ {
+ "expression": "seller_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_transactions_workflow": {
+ "name": "idx_transactions_workflow",
+ "columns": [
+ {
+ "expression": "workflow_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_transactions_buyer": {
+ "name": "idx_transactions_buyer",
+ "columns": [
+ {
+ "expression": "buyer_address",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_transactions_buyer_network": {
+ "name": "idx_transactions_buyer_network",
+ "columns": [
+ {
+ "expression": "buyer_network",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_transactions_seller_network": {
+ "name": "idx_transactions_seller_network",
+ "columns": [
+ {
+ "expression": "seller_network",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "transactions_seller_id_sellers_id_fk": {
+ "name": "transactions_seller_id_sellers_id_fk",
+ "tableFrom": "transactions",
+ "tableTo": "sellers",
+ "columnsFrom": ["seller_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/relay/drizzle/meta/_journal.json b/packages/relay/drizzle/meta/_journal.json
index 3eca88f..a8975b8 100644
--- a/packages/relay/drizzle/meta/_journal.json
+++ b/packages/relay/drizzle/meta/_journal.json
@@ -22,6 +22,13 @@
"when": 1775793460521,
"tag": "0002_plain_sunfire",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1776465361800,
+ "tag": "0003_cooing_sentry",
+ "breakpoints": true
}
]
}
diff --git a/packages/relay/package.json b/packages/relay/package.json
index 5fd4ef3..bd2fd97 100644
--- a/packages/relay/package.json
+++ b/packages/relay/package.json
@@ -9,12 +9,15 @@
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
+ "db:seed-dashboard": "bun run scripts/seed-dashboard.ts",
"lint": "eslint src/"
},
"dependencies": {
"@402md/shared": "workspace:*",
"@elysiajs/cors": "^1",
+ "@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",
diff --git a/packages/relay/public/index.html b/packages/relay/public/index.html
deleted file mode 100644
index f50502f..0000000
--- a/packages/relay/public/index.html
+++ /dev/null
@@ -1,2144 +0,0 @@
-
-
-
-
-
-
- 402md Facilitator — Cross-chain USDC settlement for x402 and MPP
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Any chain in,
- your chain out.
-
-
-
- Cross-chain USDC settlement for x402 (Coinbase's HTTP 402 protocol) and MPP (Machine
- Payments Protocol). Buyer pays on Solana, seller receives on Stellar. No SDK, no
- dashboard, no code changes.
-
-
-
-
-
-
-
-
-
-
-
Getting Started
-
-
-
# 1. Register your wallet (one-time)
-curl -X POST https://facilitator.402.md/register \
- -H "Content-Type: application/json" \
- -d '{"wallet": "GSELLER...", "network": "stellar:pubnet"}'
-
-# 2. Use standard @x402/express — zero 402md code
-import { paymentMiddleware } from "@x402/express"
-
-app.use(paymentMiddleware({
- "GET /your-endpoint" : {
- accepts: [
- { scheme: "exact" , network: "eip155:8453" , payTo: "0x4C95...c3b9" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } },
- { scheme: "exact" , network: "solana:mainnet" , payTo: "4r7q...BDP5" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } },
- { scheme: "exact" , network: "stellar:pubnet" , payTo: "GAX5...5HZR" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } },
- ],
- },
-}, "https://facilitator.402.md" ))
-
-# Buyers on Base, Solana, or Stellar can now pay you.
-
-
-
-
-
-
-
-
The Problem
-
One chain is not enough
-
- x402 lets any API charge for access. But every facilitator today locks sellers to a
- single chain — and leaves money on the table.
-
-
-
-
-
Chain lock-in
-
- 30 x402 facilitators, all single-chain. Buyer on Solana, seller on Stellar? Payment
- fails. Revenue lost silently.
-
-
-
-
Wrong tokens, wrong path
-
- DEX-based bridges mean wrapped USDC, slippage, and risk. Native USDC via CCTP V2 is
- the only clean path.
-
-
-
-
Fragmented protocols
-
x402 and MPP serve different ecosystems. No one speaks both — until now.
-
-
-
Agents don't choose chains
-
- AI agents need to pay, not navigate blockchain infrastructure. The payment layer
- should be invisible.
-
-
-
-
-
-
-
-
-
-
How It Works
-
Three steps. That's it.
-
-
-
-
Seller
-
One curl to start receiving
-
-
-
01
-
-
Register your wallet
-
- POST /register with your address and chain. Get
- facilitator addresses on all supported chains.
-
-
-
-
-
02
-
-
Use standard x402
-
- Add @x402/express middleware. Point
- payTo to your facilitator address. Zero 402md
- dependencies.
-
-
-
-
-
03
-
-
Receive USDC on your chain
-
- Buyer pays on any chain. 402md settles via CCTP V2. Native USDC, directly to
- your wallet.
-
-
-
-
-
-
-
-
Buyer / Agent
-
Pay from any chain
-
-
-
01
-
-
Discover the seller
-
- GET the API. Receive a 402 response with facilitator
- addresses on your chain.
-
-
-
-
-
02
-
-
Pay with USDC
-
- Sign a standard x402 payment on Base
- Solana or
- Stellar . No bridge, no swap.
-
-
-
-
-
03
-
-
Get the resource
-
- Payment verified in milliseconds. Resource delivered instantly. Settlement
- runs in the background.
-
-
-
-
-
-
-
-
-
-
-
-
-
By Design
-
Opinionated choices, for good reasons
-
- Every decision optimizes for one thing: the seller should do zero extra work to accept
- payments from any chain.
-
-
-
-
-
Non-custodial
-
- CCTP V2 mints USDC directly to the seller's wallet. 402md never holds your funds.
-
-
-
-
Free
-
- 0% platform fee. Only network gas deducted from settlement. Fixed schedule, no
- surprises.
-
-
-
-
Zero dependencies
-
- Sellers use standard @x402/express from Coinbase. No 402md SDK, no custom packages.
-
-
-
-
-
-
-
Dual protocol
-
x402 and MPP through one facilitator. Two ecosystems, one registration.
-
-
-
Native USDC only
-
Circle CCTP V2 burns and mints real USDC. No wrapped tokens, no slippage.
-
-
-
Three chains
-
Base, Solana, Stellar. Adding a new EVM chain is a config change, not a deploy.
-
-
-
-
-
-
-
-
-
-
-
Pricing
-
Free. Seriously.
-
- Zero platform fee. The only cost is a fixed gas allowance per route, deducted from the
- seller's payout. Here's what that looks like on a $1.00 payment.
-
-
-
-
-
-
- Route
- Seller receives
- Gas allowance
-
-
-
-
-
- Stellar →
- Stellar
-
- $0.99999
- $0.00001 0.001%
-
-
-
- Solana →
- Solana
-
- $0.9990
- $0.0010 0.10%
-
-
-
- Base →
- Base
-
- $0.9980
- $0.0020 0.20%
-
-
-
- Stellar →
- Solana
-
- $0.9985
- $0.0015 0.15%
-
-
-
- Solana →
- Stellar
-
- $0.9975
- $0.0025 0.25%
-
-
-
- Stellar →
- Base
-
- $0.9975
- $0.0025 0.25%
-
-
-
- Base →
- Stellar
-
- $0.9968
- $0.0032 0.32%
-
-
-
- Base →
- Solana
-
- $0.9965
- $0.0035 0.35%
-
-
-
- Solana →
- Base
-
- $0.9965
- $0.0035 0.35%
-
-
-
-
-
-
-
-
-
-
-
FAQ
-
Common questions
-
-
-
- What is Facilitator by 402md?
-
- A cross-chain USDC settlement facilitator built by 402md for the x402 and MPP
- protocols. Sellers register once and receive payments from buyers on any supported
- chain — Base, Solana, or Stellar.
-
-
-
-
- How is this different from other x402 facilitators?
-
- Existing facilitators are single-chain. If a buyer is on Solana and the seller is on
- Stellar, the payment fails. 402md bridges that gap using Circle CCTP V2 — atomic,
- non-custodial, native USDC. No wrapped tokens, no slippage.
-
-
-
-
- What chains are supported?
-
- Base (EVM), Solana, and Stellar — all connected via Circle CCTP V2. Adding new EVM
- chains requires only a config update, no contract deployments.
-
-
-
-
- How much does it cost?
-
- Zero platform fee. The only deduction is a fixed gas allowance per route — for
- example, $0.0032 for Base→Stellar on a $1.00 payment. Same-chain payments cost
- even less ($0.00001 for Stellar→Stellar).
-
-
-
-
- Is it custodial?
-
- No. Cross-chain settlements use Circle CCTP V2, which mints USDC directly to the
- seller's wallet. 402md never holds seller funds. The facilitator only retains the
- gas allowance needed to execute the settlement.
-
-
-
-
- How long does settlement take?
-
- Same-chain: under 5 seconds. Stellar or Solana cross-chain: 5-30 seconds. EVM
- cross-chain: 15-19 minutes, limited by block finality. The buyer gets the resource
- instantly — settlement happens in the background.
-
-
-
-
- Do I need to install any 402md package?
-
- No. Sellers use the standard @x402/express SDK from Coinbase. The only
- 402md-specific step is a one-time POST /register call — after that, everything works
- with standard x402 tooling.
-
-
-
-
- What about same-chain payments?
-
- If buyer and seller are on the same chain, 402md does a direct USDC transfer — no
- bridge involved. Faster settlement, lower gas, same API.
-
-
-
-
- What is MPP?
-
- Machine Payments Protocol, developed by Stellar and used by services like OpenAI,
- Anthropic, and Dune. 402md supports MPP alongside x402, enabling AI agents from both
- ecosystems to transact through one facilitator.
-
-
-
-
-
-
-
-
-
-
Get Started
-
One curl. All chains.
-
- Register your wallet and start accepting cross-chain USDC in under a minute. No
- dashboard, no login, no SDK.
-
-
-
- USDC
- Base
- Solana
- Stellar
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/relay/scripts/seed-dashboard.ts b/packages/relay/scripts/seed-dashboard.ts
new file mode 100644
index 0000000..e41c423
--- /dev/null
+++ b/packages/relay/scripts/seed-dashboard.ts
@@ -0,0 +1,188 @@
+/**
+ * Seed the dashboard with synthetic sellers, bazaar resources, and transactions.
+ *
+ * Usage (from packages/relay):
+ * bun run scripts/seed-dashboard.ts
+ * bun run scripts/seed-dashboard.ts --reset
+ *
+ * All seeded rows use merchantIds prefixed with `seed-` so the reset is safe
+ * and never touches real data.
+ */
+import { sql, inArray, like } from 'drizzle-orm'
+import { db, sellers, bazaarResources, transactions } from '@402md/shared/db'
+
+const SEED_PREFIX = 'seed-'
+
+const CHAINS = [
+ { slug: 'base', caip2: 'eip155:8453', evm: true },
+ { slug: 'ethereum', caip2: 'eip155:1', evm: true },
+ { slug: 'optimism', caip2: 'eip155:10', evm: true },
+ { slug: 'arbitrum', caip2: 'eip155:42161', evm: true },
+ { slug: 'linea', caip2: 'eip155:59144', evm: true },
+ { slug: 'unichain', caip2: 'eip155:130', evm: true },
+ { slug: 'worldchain', caip2: 'eip155:480', evm: true },
+ { slug: 'solana', caip2: 'solana:mainnet', evm: false },
+ { slug: 'stellar', caip2: 'stellar:pubnet', evm: false },
+]
+
+const PROTOCOLS = ['x402', 'mpp'] as const
+const STATUSES = ['settled', 'settled', 'settled', 'settled', 'pending', 'failed'] as const
+
+const RESOURCE_CATALOG = [
+ { path: '/weather/forecast', description: 'Global weather forecast API', cents: '5000' },
+ { path: '/market/quote', description: 'Real-time stock quote', cents: '10000' },
+ { path: '/llm/chat', description: 'Cheap LLM chat endpoint', cents: '25000' },
+ { path: '/embed/text', description: 'Text embedding 1536-dim', cents: '2000' },
+ { path: '/ocr/extract', description: 'OCR image-to-text', cents: '50000' },
+ { path: '/translate/en-es', description: 'EN→ES machine translation', cents: '3000' },
+ { path: '/speech/transcribe', description: 'Audio transcription', cents: '150000' },
+ { path: '/image/generate', description: 'AI image generation', cents: '200000' },
+ { path: '/geocode/reverse', description: 'Reverse geocoding', cents: '1000' },
+ { path: '/news/feed', description: 'Curated news feed', cents: '500' },
+ { path: '/crypto/price', description: 'Crypto price ticker', cents: '1500' },
+ { path: '/scrape/url', description: 'URL scrape + summarize', cents: '80000' },
+]
+
+const BUYER_EVM = '0xA1B2C3D4E5F60718293A4B5C6D7E8F9012345678'
+const BUYER_SOL = 'So1anaB4yer1111111111111111111111111111111'
+const BUYER_STELLAR = 'GABC5SELLERXSTELLARZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZA'
+
+function walletFor(chain: (typeof CHAINS)[number], idx: number): string {
+ if (chain.evm) return `0x${(idx + 1).toString(16).padStart(2, '0').repeat(20)}`.slice(0, 42)
+ if (chain.slug === 'solana')
+ return `So1anaSel1er${idx.toString().padStart(2, '0')}${'1'.repeat(30)}`.slice(0, 44)
+ return `GSELLER${idx.toString().padStart(2, '0')}${'A'.repeat(48)}`.slice(0, 56)
+}
+
+function buyerFor(caip2: string): string {
+ if (caip2.startsWith('eip155:')) return BUYER_EVM
+ if (caip2.startsWith('solana:')) return BUYER_SOL
+ return BUYER_STELLAR
+}
+
+function txHash(prefix: string, n: number): string {
+ return `0x${prefix}${n.toString(16).padStart(60, '0')}`
+}
+
+function randomInt(min: number, max: number): number {
+ return Math.floor(Math.random() * (max - min + 1)) + min
+}
+
+function pick(arr: readonly T[]): T {
+ return arr[Math.floor(Math.random() * arr.length)]
+}
+
+async function reset() {
+ console.log('→ wiping previous seed rows…')
+ const seedSellers = await db
+ .select({ id: sellers.id })
+ .from(sellers)
+ .where(like(sellers.merchantId, `${SEED_PREFIX}%`))
+ const ids = seedSellers.map((s) => s.id)
+ if (ids.length > 0) {
+ await db.delete(transactions).where(inArray(transactions.sellerId, ids))
+ await db.delete(bazaarResources).where(inArray(bazaarResources.sellerId, ids))
+ await db.delete(sellers).where(inArray(sellers.id, ids))
+ }
+ // transactions w/ no sellerId fallback wipe
+ await db.execute(
+ sql`DELETE FROM transactions WHERE pull_tx_hash LIKE '0xseed%' OR burn_tx_hash LIKE '0xseed%'`,
+ )
+ console.log(` removed ${ids.length} sellers and dependents`)
+}
+
+async function seed() {
+ console.log('→ inserting sellers…')
+ const sellerRows = CHAINS.map((chain, i) => ({
+ merchantId: `${SEED_PREFIX}${chain.slug}-${i + 1}`,
+ walletAddress: walletFor(chain, i + 1),
+ network: chain.caip2,
+ }))
+ const insertedSellers = await db.insert(sellers).values(sellerRows).returning()
+ console.log(` inserted ${insertedSellers.length} sellers`)
+
+ console.log('→ inserting bazaar resources…')
+ const resourceRows = insertedSellers.flatMap((seller, i) => {
+ const slice = RESOURCE_CATALOG.slice(i % 3, (i % 3) + 3)
+ return slice.map((r) => ({
+ resourceUrl: `https://api.seller${i + 1}.example.com${r.path}`,
+ baseUrl: `https://api.seller${i + 1}.example.com`,
+ description: r.description,
+ sellerId: seller.id,
+ merchantId: seller.merchantId,
+ network: seller.network,
+ payTo: seller.walletAddress,
+ amount: r.cents,
+ scheme: 'exact',
+ useCount: randomInt(10, 800),
+ totalVolume: (BigInt(r.cents) * BigInt(randomInt(10, 800))).toString(),
+ }))
+ })
+ await db.insert(bazaarResources).values(resourceRows)
+ console.log(` inserted ${resourceRows.length} resources`)
+
+ console.log('→ inserting transactions…')
+ const TX_COUNT = 300
+ const txRows: (typeof transactions.$inferInsert)[] = []
+ for (let i = 0; i < TX_COUNT; i++) {
+ const seller = pick(insertedSellers)
+ const sellerChain = CHAINS.find((c) => c.caip2 === seller.network)!
+ // 35% cross-chain so the matrix has spread
+ const crossChain = Math.random() < 0.35
+ const buyerChain = crossChain
+ ? pick(CHAINS.filter((c) => c.caip2 !== sellerChain.caip2))
+ : sellerChain
+ const type = crossChain ? 'BRIDGE_SETTLEMENT' : 'SAME_CHAIN'
+ const protocol = pick(PROTOCOLS)
+ const status = pick(STATUSES)
+
+ const gross = BigInt(randomInt(500, 500000)) // $0.0005 – $0.50
+ const gasAllowance = crossChain ? BigInt(randomInt(100, 2000)) : 0n
+ const net = gross - gasAllowance
+ const daysAgo = randomInt(0, 29)
+ const createdAt = new Date(Date.now() - daysAgo * 86400 * 1000 - randomInt(0, 86400 * 1000))
+ const settledAt =
+ status === 'settled' ? new Date(createdAt.getTime() + randomInt(5, 60) * 1000) : null
+
+ txRows.push({
+ type,
+ protocol,
+ status,
+ buyerAddress: buyerFor(buyerChain.caip2),
+ buyerNetwork: buyerChain.caip2,
+ sellerAddress: seller.walletAddress,
+ sellerNetwork: seller.network,
+ sellerId: seller.id,
+ grossAmount: gross.toString(),
+ netAmount: net.toString(),
+ platformFee: '0',
+ gasAllowance: gasAllowance.toString(),
+ pullTxHash: txHash('seed', i),
+ burnTxHash: crossChain ? txHash('seedb', i) : null,
+ mintTxHash: crossChain && status === 'settled' ? txHash('seedm', i) : null,
+ createdAt,
+ settledAt,
+ })
+ }
+ // batch insert in chunks of 100 (PG param limit safety)
+ for (let i = 0; i < txRows.length; i += 100) {
+ await db.insert(transactions).values(txRows.slice(i, i + 100))
+ }
+ console.log(` inserted ${txRows.length} transactions`)
+}
+
+async function main() {
+ if (!process.env.DATABASE_URL) {
+ console.error('DATABASE_URL not set')
+ process.exit(1)
+ }
+ if (process.argv.includes('--reset')) await reset()
+ await seed()
+ console.log('✔ dashboard seeded — hit /dashboard to see it')
+ process.exit(0)
+}
+
+main().catch((err) => {
+ console.error(err)
+ process.exit(1)
+})
diff --git a/packages/relay/src/bazaar/aggregations.test.ts b/packages/relay/src/bazaar/aggregations.test.ts
new file mode 100644
index 0000000..bc48c38
--- /dev/null
+++ b/packages/relay/src/bazaar/aggregations.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, test } from 'bun:test'
+import '@/shared/test-helpers'
+import { parseWindow, getCostComparison, RouteNotConfiguredError } from './aggregations'
+import { toLimitOffset } from './bazaar.routes'
+
+describe('parseWindow', () => {
+ test('accepts 1d / 7d / 30d', () => {
+ expect(parseWindow('1d')).toBe('1d')
+ expect(parseWindow('7d')).toBe('7d')
+ expect(parseWindow('30d')).toBe('30d')
+ })
+
+ test('defaults to 7d for unknown or missing', () => {
+ expect(parseWindow(undefined)).toBe('7d')
+ expect(parseWindow('')).toBe('7d')
+ expect(parseWindow('42h')).toBe('7d')
+ expect(parseWindow('2d')).toBe('7d')
+ })
+})
+
+describe('getCostComparison', () => {
+ test('returns three tiers with CCTP allowance from gas schedule', () => {
+ const result = getCostComparison({
+ buyerNetwork: 'eip155:8453',
+ sellerNetwork: 'stellar:pubnet',
+ })
+
+ expect(result.buyerNetwork).toBe('eip155:8453')
+ expect(result.sellerNetwork).toBe('stellar:pubnet')
+ expect(result.tiers).toHaveLength(3)
+ expect(result.tiers.map((t) => t.amount)).toEqual(['100000', '1000000', '100000000'])
+
+ for (const tier of result.tiers) {
+ expect(typeof tier.cctpAllowance).toBe('string')
+ expect(typeof tier.percentAlternative).toBe('string')
+ expect(typeof tier.savingsVsPercent).toBe('string')
+ // savings = percentAlternative - cctpAllowance
+ const expected = (BigInt(tier.percentAlternative) - BigInt(tier.cctpAllowance)).toString()
+ expect(tier.savingsVsPercent).toBe(expected)
+ }
+ })
+
+ test('percentAlternative respects the $0.10 floor for small amounts', () => {
+ const result = getCostComparison({
+ buyerNetwork: 'eip155:8453',
+ sellerNetwork: 'stellar:pubnet',
+ })
+ const smallest = result.tiers[0]
+ // $0.10 amount * 1% = $0.001, but the floor is $0.10 → percentAlternative = 100_000 micro-USDC
+ expect(smallest.percentAlternative).toBe('100000')
+ })
+
+ test('percentAlternative scales at 1% above the floor', () => {
+ const result = getCostComparison({
+ buyerNetwork: 'eip155:8453',
+ sellerNetwork: 'stellar:pubnet',
+ })
+ // $100 amount * 1% = $1.00 = 1_000_000 micro-USDC
+ expect(result.tiers[2].percentAlternative).toBe('1000000')
+ })
+
+ test('includes documentation notes', () => {
+ const result = getCostComparison({
+ buyerNetwork: 'eip155:8453',
+ sellerNetwork: 'stellar:pubnet',
+ })
+ expect(result.notes.cctpSource).toBe('gas-schedule.ts')
+ expect(result.notes.percentAssumption).toContain('1%')
+ expect(result.notes.percentAssumption).toContain('$0.10')
+ })
+
+ test('throws RouteNotConfiguredError for unsupported network', () => {
+ expect(() =>
+ getCostComparison({ buyerNetwork: 'eip155:999999', sellerNetwork: 'stellar:pubnet' }),
+ ).toThrow(RouteNotConfiguredError)
+ })
+})
+
+describe('RouteNotConfiguredError', () => {
+ test('has code and statusCode fields for error-handling middleware', () => {
+ const err = new RouteNotConfiguredError('eip155:8453', 'eip155:999999')
+ expect(err.code).toBe('ROUTE_NOT_CONFIGURED')
+ expect(err.statusCode).toBe(404)
+ expect(err.message).toContain('eip155:8453')
+ expect(err.message).toContain('eip155:999999')
+ })
+})
+
+describe('toLimitOffset', () => {
+ test('defaults when both undefined', () => {
+ expect(toLimitOffset(undefined, undefined)).toEqual({ limit: 20, offset: 0 })
+ })
+
+ test('parses valid numeric strings', () => {
+ expect(toLimitOffset('50', '10')).toEqual({ limit: 50, offset: 10 })
+ })
+
+ test('clamps limit to max 100', () => {
+ expect(toLimitOffset('500', '0')).toEqual({ limit: 100, offset: 0 })
+ })
+
+ test('clamps limit to min 1', () => {
+ expect(toLimitOffset('0', '0')).toEqual({ limit: 1, offset: 0 })
+ expect(toLimitOffset('-5', '0')).toEqual({ limit: 1, offset: 0 })
+ })
+
+ test('clamps offset to min 0', () => {
+ expect(toLimitOffset('20', '-10')).toEqual({ limit: 20, offset: 0 })
+ })
+
+ test('falls back to defaults when input is not a finite number', () => {
+ // This is the NaN regression: Number('abc') = NaN, must not reach SQL
+ expect(toLimitOffset('abc', 'xyz')).toEqual({ limit: 20, offset: 0 })
+ expect(toLimitOffset('Infinity', 'NaN')).toEqual({ limit: 20, offset: 0 })
+ })
+
+ test('handles empty string as missing (falls back to defaults)', () => {
+ expect(toLimitOffset('', '')).toEqual({ limit: 20, offset: 0 })
+ })
+})
diff --git a/packages/relay/src/bazaar/aggregations.ts b/packages/relay/src/bazaar/aggregations.ts
new file mode 100644
index 0000000..9843734
--- /dev/null
+++ b/packages/relay/src/bazaar/aggregations.ts
@@ -0,0 +1,592 @@
+import { sql, and, eq, like, desc, type SQL } from 'drizzle-orm'
+import { db, bazaarResources, sellers, transactions } from '@402md/shared/db'
+import { networks, resolveSlugByCaip2, getGasAllowanceBySlug } from '@402md/shared/networks'
+
+export type Window = '1d' | '7d' | '30d'
+
+export function parseWindow(raw: string | undefined): Window {
+ if (raw === '1d' || raw === '7d' || raw === '30d') return raw
+ return '7d'
+}
+
+function windowInterval(w: Window): SQL {
+ if (w === '1d') return sql`NOW() - INTERVAL '1 day'`
+ if (w === '7d') return sql`NOW() - INTERVAL '7 days'`
+ return sql`NOW() - INTERVAL '30 days'`
+}
+
+function normalizeType(raw: string): 'same_chain' | 'cross_chain' | string {
+ if (raw === 'SAME_CHAIN') return 'same_chain'
+ if (raw === 'BRIDGE_SETTLEMENT') return 'cross_chain'
+ return raw
+}
+
+function denormalizeType(raw: string): string {
+ if (raw === 'same_chain') return 'SAME_CHAIN'
+ if (raw === 'cross_chain') return 'BRIDGE_SETTLEMENT'
+ return raw
+}
+
+export class RouteNotConfiguredError extends Error {
+ readonly code = 'ROUTE_NOT_CONFIGURED'
+ readonly statusCode = 404
+ constructor(from: string, to: string) {
+ super(`No gas schedule for route ${from} → ${to}`)
+ }
+}
+
+// ----------------------------------------------------------------------------
+// 1. getStats
+// ----------------------------------------------------------------------------
+
+export interface StatsResponse {
+ window: Window
+ chainsActive: number
+ chainsSupported: number
+ uniqueRoutes: number
+ crossChainRoutes: number
+ sameChainRoutes: number
+ totalVolume: string
+ txCount: number
+ protocolSplit: Record
+ typeSplit: Record
+}
+
+export async function getStats({ window }: { window: Window }): Promise {
+ const interval = windowInterval(window)
+
+ const rows = await db.execute<{
+ unique_routes: string
+ cross_chain_routes: string
+ same_chain_routes: string
+ tx_count: string
+ total_volume: string | null
+ chains_active: string
+ protocol_split: Record | null
+ type_split: Record | null
+ }>(sql`
+ WITH base AS (
+ SELECT * FROM transactions
+ WHERE created_at > ${interval} AND status = 'settled'
+ ),
+ stats AS (
+ SELECT
+ COUNT(DISTINCT (buyer_network || '|' || seller_network))::text AS unique_routes,
+ COUNT(DISTINCT CASE WHEN buyer_network <> seller_network THEN buyer_network || '|' || seller_network END)::text AS cross_chain_routes,
+ COUNT(DISTINCT CASE WHEN buyer_network = seller_network THEN buyer_network || '|' || seller_network END)::text AS same_chain_routes,
+ COUNT(*)::text AS tx_count,
+ COALESCE(SUM(gross_amount), 0)::text AS total_volume
+ FROM base
+ ),
+ chains AS (
+ SELECT COUNT(DISTINCT x.net)::text AS chains_active
+ FROM base t, LATERAL (VALUES (t.buyer_network), (t.seller_network)) AS x(net)
+ ),
+ protocols AS (
+ SELECT COALESCE(jsonb_object_agg(protocol, cnt), '{}'::jsonb) AS protocol_split
+ FROM (SELECT protocol, COUNT(*)::int AS cnt FROM base GROUP BY protocol) p
+ ),
+ types AS (
+ SELECT COALESCE(jsonb_object_agg(type, cnt), '{}'::jsonb) AS type_split
+ FROM (SELECT type, COUNT(*)::int AS cnt FROM base GROUP BY type) t
+ )
+ SELECT stats.*, chains.chains_active, protocols.protocol_split, types.type_split
+ FROM stats, chains, protocols, types
+ `)
+
+ const head = rows[0] ?? {
+ unique_routes: '0',
+ cross_chain_routes: '0',
+ same_chain_routes: '0',
+ tx_count: '0',
+ total_volume: '0',
+ chains_active: '0',
+ protocol_split: {},
+ type_split: {},
+ }
+
+ const protocolSplit: Record = { ...(head.protocol_split ?? {}) }
+
+ const rawTypeSplit = head.type_split ?? {}
+ const typeSplit: Record = {}
+ for (const [rawType, count] of Object.entries(rawTypeSplit)) {
+ typeSplit[normalizeType(rawType)] = count
+ }
+
+ return {
+ window,
+ chainsActive: Number(head.chains_active),
+ chainsSupported: networks.length,
+ uniqueRoutes: Number(head.unique_routes),
+ crossChainRoutes: Number(head.cross_chain_routes),
+ sameChainRoutes: Number(head.same_chain_routes),
+ totalVolume: head.total_volume ?? '0',
+ txCount: Number(head.tx_count),
+ protocolSplit,
+ typeSplit,
+ }
+}
+
+// ----------------------------------------------------------------------------
+// 2. getRoutes
+// ----------------------------------------------------------------------------
+
+export interface RouteRow {
+ buyerNetwork: string
+ sellerNetwork: string
+ txCount: number
+ volume: string
+ isCrossChain: boolean
+}
+
+export interface RoutesResponse {
+ window: Window
+ routes: RouteRow[]
+}
+
+export async function getRoutes({ window }: { window: Window }): Promise {
+ const interval = windowInterval(window)
+
+ const rows = await db.execute<{
+ buyer_network: string
+ seller_network: string
+ tx_count: string
+ volume: string | null
+ }>(sql`
+ SELECT buyer_network, seller_network, COUNT(*)::text AS tx_count, COALESCE(SUM(gross_amount), 0)::text AS volume
+ FROM transactions
+ WHERE created_at > ${interval} AND status = 'settled'
+ GROUP BY buyer_network, seller_network
+ ORDER BY SUM(gross_amount) DESC NULLS LAST
+ `)
+
+ return {
+ window,
+ routes: rows.map((r) => ({
+ buyerNetwork: r.buyer_network,
+ sellerNetwork: r.seller_network,
+ txCount: Number(r.tx_count),
+ volume: r.volume ?? '0',
+ isCrossChain: r.buyer_network !== r.seller_network,
+ })),
+ }
+}
+
+// ----------------------------------------------------------------------------
+// 3. getRankedResources
+// ----------------------------------------------------------------------------
+
+export interface RankedResourceAccept {
+ scheme: string
+ network: string
+ payTo: string
+ amount: string
+}
+
+export interface RankedResourceItem {
+ resource: string
+ type: 'http'
+ description: string | null
+ merchantId: string
+ accepts: RankedResourceAccept[]
+ useCount: number
+ totalVolume: string
+ windowedUseCount: number
+ windowedVolume: string
+ lastUsedAt: string | null
+}
+
+export interface RankedResourcesResponse {
+ window: Window
+ items: RankedResourceItem[]
+ total: number
+}
+
+export interface RankedResourcesParams {
+ window: Window
+ rank: 'uses' | 'volume'
+ network?: string
+ q?: string
+ limit: number
+ offset: number
+}
+
+export async function getRankedResources(
+ params: RankedResourcesParams,
+): Promise {
+ const { window, rank, network, q, limit, offset } = params
+ const interval = windowInterval(window)
+
+ const conditions: SQL[] = []
+ if (network) conditions.push(eq(bazaarResources.network, network))
+ if (q) conditions.push(like(bazaarResources.resourceUrl, `%${q}%`))
+ const where = conditions.length > 0 ? and(...conditions) : undefined
+
+ const rows = await db
+ .select({
+ id: bazaarResources.id,
+ resourceUrl: bazaarResources.resourceUrl,
+ description: bazaarResources.description,
+ merchantId: bazaarResources.merchantId,
+ network: bazaarResources.network,
+ payTo: bazaarResources.payTo,
+ amount: bazaarResources.amount,
+ scheme: bazaarResources.scheme,
+ useCount: bazaarResources.useCount,
+ totalVolume: bazaarResources.totalVolume,
+ lastUsedAt: bazaarResources.lastUsedAt,
+ })
+ .from(bazaarResources)
+ .where(where)
+ .orderBy(desc(bazaarResources.useCount), desc(bazaarResources.lastUsedAt))
+
+ const windowed = await db.execute<{
+ resource_url: string
+ use_count: string
+ volume: string | null
+ }>(sql`
+ SELECT b.resource_url,
+ COUNT(t.id)::text AS use_count,
+ COALESCE(SUM(t.gross_amount), 0)::text AS volume
+ FROM bazaar_resources b
+ LEFT JOIN transactions t
+ ON t.seller_id = b.seller_id
+ AND t.seller_network = b.network
+ AND t.created_at > ${interval}
+ AND t.status = 'settled'
+ GROUP BY b.resource_url
+ `)
+
+ const windowedByUrl = new Map()
+ for (const w of windowed) {
+ windowedByUrl.set(w.resource_url, {
+ useCount: Number(w.use_count),
+ volume: w.volume ?? '0',
+ })
+ }
+
+ type GroupedEntry = {
+ resource: string
+ type: 'http'
+ description: string | null
+ merchantId: string
+ accepts: RankedResourceAccept[]
+ useCount: number
+ totalVolume: string
+ windowedUseCount: number
+ windowedVolume: string
+ lastUsedAt: string | null
+ }
+
+ const grouped = new Map()
+
+ for (const r of rows) {
+ const existing = grouped.get(r.resourceUrl)
+ const accept: RankedResourceAccept = {
+ scheme: r.scheme,
+ network: r.network,
+ payTo: r.payTo,
+ amount: r.amount,
+ }
+
+ if (existing) {
+ existing.accepts.push(accept)
+ existing.useCount += r.useCount
+ existing.totalVolume = (BigInt(existing.totalVolume) + BigInt(r.totalVolume)).toString()
+ const lastIso = r.lastUsedAt?.toISOString() ?? null
+ if (lastIso && (!existing.lastUsedAt || lastIso > existing.lastUsedAt)) {
+ existing.lastUsedAt = lastIso
+ }
+ } else {
+ const w = windowedByUrl.get(r.resourceUrl)
+ grouped.set(r.resourceUrl, {
+ resource: r.resourceUrl,
+ type: 'http',
+ description: r.description,
+ merchantId: r.merchantId,
+ accepts: [accept],
+ useCount: r.useCount,
+ totalVolume: r.totalVolume ?? '0',
+ windowedUseCount: w?.useCount ?? 0,
+ windowedVolume: w?.volume ?? '0',
+ lastUsedAt: r.lastUsedAt?.toISOString() ?? null,
+ })
+ }
+ }
+
+ const items = [...grouped.values()]
+
+ if (rank === 'volume') {
+ items.sort((a, b) => {
+ const diff = BigInt(b.windowedVolume) - BigInt(a.windowedVolume)
+ return diff > 0n ? 1 : diff < 0n ? -1 : 0
+ })
+ } else {
+ items.sort((a, b) => b.windowedUseCount - a.windowedUseCount)
+ }
+
+ const paginated = items.slice(offset, offset + limit)
+
+ return { window, items: paginated, total: items.length }
+}
+
+// ----------------------------------------------------------------------------
+// 4. getRankedSellers
+// ----------------------------------------------------------------------------
+
+export interface RankedSellerItem {
+ merchantId: string
+ primaryNetwork: string
+ txCount: number
+ volume: string
+ resourceCount: number
+ firstSeenAt: string | null
+}
+
+export interface RankedSellersResponse {
+ window: Window
+ items: RankedSellerItem[]
+ total: number
+}
+
+export interface RankedSellersParams {
+ window: Window
+ rank: 'volume' | 'tx_count'
+ network?: string
+ limit: number
+ offset: number
+}
+
+export async function getRankedSellers(
+ params: RankedSellersParams,
+): Promise {
+ const { window, rank, network, limit, offset } = params
+ const interval = windowInterval(window)
+
+ const orderColumn = rank === 'tx_count' ? sql`tx_count` : sql`volume`
+
+ const networkFilter = network ? sql`AND s.network = ${network}` : sql``
+
+ const rows = await db.execute<{
+ merchant_id: string
+ primary_network: string
+ first_seen_at: Date | string | null
+ tx_count: string
+ volume: string | null
+ resource_count: string
+ }>(sql`
+ SELECT s.merchant_id,
+ s.network AS primary_network,
+ s.created_at AS first_seen_at,
+ COUNT(t.id)::text AS tx_count,
+ COALESCE(SUM(t.gross_amount), 0)::text AS volume,
+ (SELECT COUNT(*)::text FROM bazaar_resources WHERE seller_id = s.id) AS resource_count
+ FROM sellers s
+ LEFT JOIN transactions t
+ ON t.seller_id = s.id
+ AND t.created_at > ${interval}
+ AND t.status = 'settled'
+ WHERE 1 = 1 ${networkFilter}
+ GROUP BY s.id
+ ORDER BY ${orderColumn} DESC NULLS LAST
+ LIMIT ${limit} OFFSET ${offset}
+ `)
+
+ const totalRows = await db.execute<{ total: string }>(sql`
+ SELECT COUNT(*)::text AS total FROM sellers s WHERE 1 = 1 ${networkFilter}
+ `)
+
+ const items: RankedSellerItem[] = rows.map((r) => ({
+ merchantId: r.merchant_id,
+ primaryNetwork: r.primary_network,
+ txCount: Number(r.tx_count),
+ volume: r.volume ?? '0',
+ resourceCount: Number(r.resource_count),
+ firstSeenAt: r.first_seen_at ? new Date(r.first_seen_at).toISOString() : null,
+ }))
+
+ return { window, items, total: Number(totalRows[0]?.total ?? 0) }
+}
+
+// ----------------------------------------------------------------------------
+// 5. getTransactions
+// ----------------------------------------------------------------------------
+
+export interface TransactionItem {
+ id: string
+ type: string
+ protocol: string | null
+ status: string
+ buyerNetwork: string
+ sellerNetwork: string
+ merchantId: string | null
+ grossAmount: string
+ netAmount: string | null
+ gasAllowance: string | null
+ pullTxHash: string | null
+ burnTxHash: string | null
+ mintTxHash: string | null
+ createdAt: string | null
+ settledAt: string | null
+}
+
+export interface TransactionsResponse {
+ items: TransactionItem[]
+ total: number
+ limit: number
+ offset: number
+}
+
+export interface TransactionsParams {
+ window?: Window
+ merchantId?: string
+ buyerNetwork?: string
+ sellerNetwork?: string
+ status?: string
+ protocol?: string
+ type?: string
+ limit: number
+ offset: number
+}
+
+export async function getTransactions(params: TransactionsParams): Promise {
+ const { window, merchantId, buyerNetwork, sellerNetwork, status, protocol, type, limit, offset } =
+ params
+
+ const conditions: SQL[] = []
+ if (window) conditions.push(sql`${transactions.createdAt} > ${windowInterval(window)}`)
+ if (merchantId) conditions.push(eq(sellers.merchantId, merchantId))
+ if (buyerNetwork) conditions.push(eq(transactions.buyerNetwork, buyerNetwork))
+ if (sellerNetwork) conditions.push(eq(transactions.sellerNetwork, sellerNetwork))
+ if (status) conditions.push(eq(transactions.status, status))
+ if (protocol) conditions.push(eq(transactions.protocol, protocol))
+ if (type) conditions.push(eq(transactions.type, denormalizeType(type)))
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined
+
+ const rows = await db
+ .select({
+ id: transactions.id,
+ type: transactions.type,
+ protocol: transactions.protocol,
+ status: transactions.status,
+ buyerNetwork: transactions.buyerNetwork,
+ sellerNetwork: transactions.sellerNetwork,
+ merchantId: sellers.merchantId,
+ grossAmount: transactions.grossAmount,
+ netAmount: transactions.netAmount,
+ gasAllowance: transactions.gasAllowance,
+ pullTxHash: transactions.pullTxHash,
+ burnTxHash: transactions.burnTxHash,
+ mintTxHash: transactions.mintTxHash,
+ createdAt: transactions.createdAt,
+ settledAt: transactions.settledAt,
+ })
+ .from(transactions)
+ .leftJoin(sellers, eq(sellers.id, transactions.sellerId))
+ .where(where)
+ .orderBy(desc(transactions.createdAt))
+ .limit(limit)
+ .offset(offset)
+
+ const totalRows = await db
+ .select({ count: sql`COUNT(*)::text` })
+ .from(transactions)
+ .leftJoin(sellers, eq(sellers.id, transactions.sellerId))
+ .where(where)
+
+ const items: TransactionItem[] = rows.map((r) => ({
+ id: r.id,
+ type: normalizeType(r.type),
+ protocol: r.protocol,
+ status: r.status,
+ buyerNetwork: r.buyerNetwork,
+ sellerNetwork: r.sellerNetwork,
+ merchantId: r.merchantId,
+ grossAmount: r.grossAmount,
+ netAmount: r.netAmount,
+ gasAllowance: r.gasAllowance,
+ pullTxHash: r.pullTxHash,
+ burnTxHash: r.burnTxHash,
+ mintTxHash: r.mintTxHash,
+ createdAt: r.createdAt?.toISOString() ?? null,
+ settledAt: r.settledAt?.toISOString() ?? null,
+ }))
+
+ return { items, total: Number(totalRows[0]?.count ?? '0'), limit, offset }
+}
+
+// ----------------------------------------------------------------------------
+// 6. getCostComparison
+// ----------------------------------------------------------------------------
+
+export interface CostComparisonTier {
+ amount: string
+ cctpAllowance: string
+ percentAlternative: string
+ savingsVsPercent: string
+}
+
+export interface CostComparisonResponse {
+ buyerNetwork: string
+ sellerNetwork: string
+ tiers: CostComparisonTier[]
+ notes: {
+ cctpSource: string
+ percentAssumption: string
+ }
+}
+
+const COST_COMPARISON_TIERS = ['100000', '1000000', '100000000']
+const PERCENT_BPS = 100n // 1% = 100 bps
+const PERCENT_MIN_FLOOR = 100000n // $0.10 baseline spread
+
+function computePercentAlternative(amount: string): string {
+ const gross = BigInt(amount)
+ const onePercent = (gross * PERCENT_BPS) / 10000n
+ return (onePercent > PERCENT_MIN_FLOOR ? onePercent : PERCENT_MIN_FLOOR).toString()
+}
+
+export function getCostComparison(input: {
+ buyerNetwork: string
+ sellerNetwork: string
+}): CostComparisonResponse {
+ const { buyerNetwork, sellerNetwork } = input
+
+ const fromSlug = resolveSlugByCaip2(buyerNetwork)
+ const toSlug = resolveSlugByCaip2(sellerNetwork)
+ if (!fromSlug || !toSlug) {
+ throw new RouteNotConfiguredError(buyerNetwork, sellerNetwork)
+ }
+
+ let allowance: string
+ try {
+ allowance = getGasAllowanceBySlug(fromSlug, toSlug)
+ } catch (err) {
+ if (err instanceof Error && err.message.startsWith('No gas schedule for')) {
+ throw new RouteNotConfiguredError(buyerNetwork, sellerNetwork)
+ }
+ throw err
+ }
+
+ const tiers: CostComparisonTier[] = COST_COMPARISON_TIERS.map((amount) => {
+ const percent = computePercentAlternative(amount)
+ const savings = (BigInt(percent) - BigInt(allowance)).toString()
+ return {
+ amount,
+ cctpAllowance: allowance,
+ percentAlternative: percent,
+ savingsVsPercent: savings,
+ }
+ })
+
+ return {
+ buyerNetwork,
+ sellerNetwork,
+ tiers,
+ notes: {
+ cctpSource: 'gas-schedule.ts',
+ percentAssumption: '1% + $0.10 market-maker spread baseline',
+ },
+ }
+}
diff --git a/packages/relay/src/bazaar/bazaar.routes.ts b/packages/relay/src/bazaar/bazaar.routes.ts
index 528a241..e808d46 100644
--- a/packages/relay/src/bazaar/bazaar.routes.ts
+++ b/packages/relay/src/bazaar/bazaar.routes.ts
@@ -1,5 +1,31 @@
import { Elysia, t } from 'elysia'
import { listResources } from './bazaar.service'
+import { bazaarCacheKey, cached } from './cache'
+import {
+ parseWindow,
+ getStats,
+ getRoutes,
+ getRankedResources,
+ getRankedSellers,
+ getTransactions,
+ getCostComparison,
+ RouteNotConfiguredError,
+} from './aggregations'
+
+const CACHE_TTL = 60
+const DEFAULT_LIMIT = 20
+const DEFAULT_OFFSET = 0
+const MAX_LIMIT = 100
+
+export function toLimitOffset(limitRaw: string | undefined, offsetRaw: string | undefined) {
+ const parsedLimit = limitRaw ? Number(limitRaw) : DEFAULT_LIMIT
+ const limit = Number.isFinite(parsedLimit)
+ ? Math.min(Math.max(parsedLimit, 1), MAX_LIMIT)
+ : DEFAULT_LIMIT
+ const parsedOffset = offsetRaw ? Number(offsetRaw) : DEFAULT_OFFSET
+ const offset = Number.isFinite(parsedOffset) ? Math.max(parsedOffset, 0) : DEFAULT_OFFSET
+ return { limit, offset }
+}
export const bazaarRoutes = new Elysia({ prefix: '/discovery' }).get(
'/resources',
@@ -22,3 +48,138 @@ export const bazaarRoutes = new Elysia({ prefix: '/discovery' }).get(
}),
},
)
+
+export const bazaarEnrichedRoutes = new Elysia({ prefix: '/bazaar' })
+ .get(
+ '/stats',
+ async ({ query }) => {
+ const window = parseWindow(query.window)
+ const { value } = await cached(bazaarCacheKey('stats', { window }), CACHE_TTL, () =>
+ getStats({ window }),
+ )
+ return value
+ },
+ {
+ query: t.Object({ window: t.Optional(t.String()) }),
+ },
+ )
+ .get(
+ '/routes',
+ async ({ query }) => {
+ const window = parseWindow(query.window)
+ const { value } = await cached(bazaarCacheKey('routes', { window }), CACHE_TTL, () =>
+ getRoutes({ window }),
+ )
+ return value
+ },
+ {
+ query: t.Object({ window: t.Optional(t.String()) }),
+ },
+ )
+ .get(
+ '/resources',
+ async ({ query }) => {
+ const window = parseWindow(query.window)
+ const rank = query.rank === 'volume' ? 'volume' : 'uses'
+ const { limit, offset } = toLimitOffset(query.limit, query.offset)
+ const params = { window, rank, network: query.network, q: query.q, limit, offset }
+ const { value } = await cached(bazaarCacheKey('resources', params), CACHE_TTL, () =>
+ getRankedResources(params),
+ )
+ return value
+ },
+ {
+ query: t.Object({
+ window: t.Optional(t.String()),
+ rank: t.Optional(t.String()),
+ network: t.Optional(t.String()),
+ q: t.Optional(t.String()),
+ limit: t.Optional(t.String()),
+ offset: t.Optional(t.String()),
+ }),
+ },
+ )
+ .get(
+ '/sellers',
+ async ({ query }) => {
+ const window = parseWindow(query.window)
+ const rank = query.rank === 'tx_count' ? 'tx_count' : 'volume'
+ const { limit, offset } = toLimitOffset(query.limit, query.offset)
+ const params = { window, rank, network: query.network, limit, offset }
+ const { value } = await cached(bazaarCacheKey('sellers', params), CACHE_TTL, () =>
+ getRankedSellers(params),
+ )
+ return value
+ },
+ {
+ query: t.Object({
+ window: t.Optional(t.String()),
+ rank: t.Optional(t.String()),
+ network: t.Optional(t.String()),
+ limit: t.Optional(t.String()),
+ offset: t.Optional(t.String()),
+ }),
+ },
+ )
+ .get(
+ '/transactions',
+ async ({ query }) => {
+ const { limit, offset } = toLimitOffset(query.limit, query.offset)
+ const window = query.window ? parseWindow(query.window) : undefined
+ const params = {
+ window,
+ merchantId: query.merchantId,
+ buyerNetwork: query.buyerNetwork,
+ sellerNetwork: query.sellerNetwork,
+ status: query.status,
+ protocol: query.protocol,
+ type: query.type,
+ limit,
+ offset,
+ }
+ const { value } = await cached(bazaarCacheKey('transactions', params), CACHE_TTL, () =>
+ getTransactions(params),
+ )
+ return value
+ },
+ {
+ query: t.Object({
+ window: t.Optional(t.String()),
+ merchantId: t.Optional(t.String()),
+ buyerNetwork: t.Optional(t.String()),
+ sellerNetwork: t.Optional(t.String()),
+ status: t.Optional(t.String()),
+ protocol: t.Optional(t.String()),
+ type: t.Optional(t.String()),
+ limit: t.Optional(t.String()),
+ offset: t.Optional(t.String()),
+ }),
+ },
+ )
+ .get(
+ '/cost-comparison',
+ ({ query, set }) => {
+ if (!query.buyerNetwork || !query.sellerNetwork) {
+ set.status = 400
+ return { error: 'MISSING_PARAMS', message: 'buyerNetwork and sellerNetwork are required' }
+ }
+ try {
+ return getCostComparison({
+ buyerNetwork: query.buyerNetwork,
+ sellerNetwork: query.sellerNetwork,
+ })
+ } catch (err) {
+ if (err instanceof RouteNotConfiguredError) {
+ set.status = 404
+ return { error: err.code, message: err.message }
+ }
+ throw err
+ }
+ },
+ {
+ query: t.Object({
+ buyerNetwork: t.Optional(t.String()),
+ sellerNetwork: t.Optional(t.String()),
+ }),
+ },
+ )
diff --git a/packages/relay/src/bazaar/cache.ts b/packages/relay/src/bazaar/cache.ts
new file mode 100644
index 0000000..54eb21e
--- /dev/null
+++ b/packages/relay/src/bazaar/cache.ts
@@ -0,0 +1,25 @@
+import { redis } from '@402md/shared/cache'
+
+export interface CachedResult {
+ value: T
+ hit: boolean
+}
+
+export async function cached(
+ key: string,
+ ttlSeconds: number,
+ compute: () => Promise,
+): Promise> {
+ const raw = await redis.get(key)
+ if (raw) return { value: JSON.parse(raw) as T, hit: true }
+ const value = await compute()
+ await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds)
+ return { value, hit: false }
+}
+
+export function bazaarCacheKey(endpoint: string, params: Record): string {
+ const sortedKeys = Object.keys(params).sort()
+ const canonical: Record = {}
+ for (const k of sortedKeys) canonical[k] = params[k] ?? null
+ return `402md:bazaar:${endpoint}:${JSON.stringify(canonical)}`
+}
diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts
index c964c94..3fd50de 100644
--- a/packages/relay/src/index.ts
+++ b/packages/relay/src/index.ts
@@ -12,12 +12,15 @@ if (!envCheck.ok) {
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { swagger } from '@elysiajs/swagger'
+import { html } from '@elysiajs/html'
import { sql } from 'drizzle-orm'
+import { LandingPage } from '@/views/landing/page'
+import { DashboardPage } from '@/views/dashboard/page'
import { sellersRoutes } from '@/sellers/sellers.routes'
import { settlementsRoutes } from '@/settlements/settlements.routes'
import { onrampRoutes } from '@/onramp/onramp.routes'
import { stellarMppRoutes } from '@/mpp/stellar-mpp.routes'
-import { bazaarRoutes } from '@/bazaar/bazaar.routes'
+import { bazaarRoutes, bazaarEnrichedRoutes } from '@/bazaar/bazaar.routes'
import { db } from '@402md/shared/db'
import { redis } from '@402md/shared/cache'
import { getTemporalClient } from '@/shared/temporal'
@@ -25,6 +28,7 @@ import { checkRateLimit } from '@/shared/rate-limit'
export const app = new Elysia()
.use(cors())
+ .use(html())
.use(
swagger({
documentation: {
@@ -50,9 +54,9 @@ export const app = new Elysia()
.use(onrampRoutes)
.use(stellarMppRoutes)
.use(bazaarRoutes)
- .get('/', () => Bun.file(new URL('../public/index.html', import.meta.url).pathname), {
- detail: { hide: true },
- })
+ .use(bazaarEnrichedRoutes)
+ .get('/', () => LandingPage(), { detail: { hide: true } })
+ .get('/dashboard', () => DashboardPage(), { detail: { hide: true } })
.get('/cover.mp4', () => Bun.file(new URL('../public/cover.mp4', import.meta.url).pathname))
.get('/health', async () => {
const checks = {
diff --git a/packages/relay/src/shared/rate-limit.ts b/packages/relay/src/shared/rate-limit.ts
index 30b7bf9..ffe4e8e 100644
--- a/packages/relay/src/shared/rate-limit.ts
+++ b/packages/relay/src/shared/rate-limit.ts
@@ -15,6 +15,12 @@ const RATE_LIMITS: Record = {
'/mpp/config': { maxRequests: 100, windowSeconds: 60 },
'/mpp/charge': { maxRequests: 500, windowSeconds: 60 },
'/bazaar': { maxRequests: 100, windowSeconds: 60 },
+ '/bazaar/stats': { maxRequests: 200, windowSeconds: 60 },
+ '/bazaar/routes': { maxRequests: 200, windowSeconds: 60 },
+ '/bazaar/resources': { maxRequests: 200, windowSeconds: 60 },
+ '/bazaar/sellers': { maxRequests: 200, windowSeconds: 60 },
+ '/bazaar/transactions': { maxRequests: 200, windowSeconds: 60 },
+ '/bazaar/cost-comparison': { maxRequests: 200, windowSeconds: 60 },
}
export async function checkRateLimit(path: string, ip: string): Promise {
diff --git a/packages/relay/src/views/dashboard/client-script.ts b/packages/relay/src/views/dashboard/client-script.ts
new file mode 100644
index 0000000..97f26c8
--- /dev/null
+++ b/packages/relay/src/views/dashboard/client-script.ts
@@ -0,0 +1,384 @@
+// Dashboard polling + render script. Rendered as a raw
+
+
+)
diff --git a/packages/relay/src/views/dashboard/styles.ts b/packages/relay/src/views/dashboard/styles.ts
new file mode 100644
index 0000000..928f2e9
--- /dev/null
+++ b/packages/relay/src/views/dashboard/styles.ts
@@ -0,0 +1,443 @@
+export const dashboardStyles = /* css */ `
+ main.dashboard {
+ padding-top: var(--space-xl);
+ padding-bottom: var(--space-2xl);
+ }
+
+ /* ── Section rhythm ──
+ Hairline top + generous padding on each section creates editorial rhythm
+ without per-section decoration. Matches the landing's section pattern
+ but calibrated for data density. */
+ main.dashboard section {
+ margin-bottom: 0;
+ padding-top: var(--space-xl);
+ padding-bottom: var(--space-xl);
+ border-top: 1px solid var(--border);
+ }
+ main.dashboard section:first-of-type {
+ padding-top: var(--space-md);
+ border-top: 0;
+ }
+
+ main.dashboard h2 {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--signal-deep);
+ margin-bottom: var(--space-md);
+ }
+
+ .section-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-md);
+ margin-bottom: var(--space-md);
+ flex-wrap: wrap;
+ }
+ .section-head h2 {
+ margin-bottom: 0;
+ }
+
+ /* ── Snapshot readouts ──
+ Flat editorial row, no cards. Vertical hairlines separate columns;
+ the section's own rhythm handles top/bottom boundaries, so no extra
+ horizontals here — otherwise they stack with the next section's border. */
+ .snapshot {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ }
+ .stat {
+ padding: 0 var(--space-lg) 0 0;
+ border-right: 1px solid var(--border);
+ }
+ .stat + .stat {
+ padding-left: var(--space-lg);
+ }
+ .stat:last-child {
+ border-right: 0;
+ padding-right: 0;
+ }
+ .stat .label {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-2);
+ margin-bottom: var(--space-xs);
+ }
+ .stat .value {
+ font-family: 'Bricolage Grotesque', sans-serif;
+ font-size: 1.75rem;
+ font-weight: 500;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+ }
+ .stat .sub {
+ font-size: 0.8125rem;
+ color: var(--text-2);
+ margin-top: var(--space-xs);
+ }
+
+ /* ── Segmented window toggle ── */
+ .window-toggle {
+ display: inline-flex;
+ gap: 2px;
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 2px;
+ }
+ .window-toggle button {
+ background: transparent;
+ border: none;
+ font: inherit;
+ font-size: 0.8125rem;
+ color: var(--text-2);
+ padding: 4px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ transition:
+ color 150ms var(--ease-out-quart),
+ background-color 150ms var(--ease-out-quart);
+ }
+ .window-toggle button:hover:not(.active) {
+ color: var(--text);
+ }
+ .window-toggle button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+ .window-toggle button.active {
+ background: var(--accent-soft);
+ color: var(--accent);
+ }
+
+ /* ── Rank toggle (inline text-like) ── */
+ .rank-toggle {
+ display: inline-flex;
+ gap: var(--space-xs);
+ font-size: 0.8125rem;
+ }
+ .rank-toggle button {
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 4px 10px;
+ font: inherit;
+ font-size: 0.8125rem;
+ color: var(--text-2);
+ cursor: pointer;
+ transition:
+ color 150ms var(--ease-out-quart),
+ border-color 150ms var(--ease-out-quart),
+ background-color 150ms var(--ease-out-quart);
+ }
+ .rank-toggle button:hover:not(.active) {
+ color: var(--text);
+ border-color: var(--border-mid);
+ }
+ .rank-toggle button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+ .rank-toggle button.active {
+ background: var(--accent-soft);
+ color: var(--accent);
+ border-color: var(--accent-soft);
+ }
+
+ /* ── Per-chain grid ── */
+ .chain-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: var(--space-sm);
+ }
+ .chain-card {
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: var(--space-md);
+ transition: border-color 150ms var(--ease-out-quart);
+ }
+ .chain-card:hover {
+ border-color: var(--border-mid);
+ }
+ .chain-card .chain-name {
+ font-weight: 500;
+ font-size: 0.875rem;
+ margin-bottom: var(--space-sm);
+ letter-spacing: -0.005em;
+ }
+ .chain-card .chain-metric {
+ font-size: 0.8125rem;
+ color: var(--text-2);
+ font-variant-numeric: tabular-nums;
+ }
+ .chain-card .chain-metric + .chain-metric {
+ margin-top: 2px;
+ }
+ .chain-card .chain-metric strong {
+ color: var(--text);
+ font-family: 'JetBrains Mono', monospace;
+ font-weight: 500;
+ }
+
+ /* ── Route matrix ── */
+ .route-matrix {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ }
+ table.matrix {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
+ table-layout: fixed;
+ }
+ table.matrix th,
+ table.matrix td {
+ border: 1px solid var(--border);
+ padding: 8px 10px;
+ text-align: center;
+ white-space: nowrap;
+ }
+ table.matrix th {
+ background: var(--bg-alt);
+ color: var(--text-2);
+ font-weight: 500;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ letter-spacing: 0.02em;
+ }
+ table.matrix thead th {
+ border-top: 0;
+ }
+ table.matrix tr > th:first-child,
+ table.matrix tr > td:first-child {
+ border-left: 0;
+ }
+ table.matrix tr > th:last-child,
+ table.matrix tr > td:last-child {
+ border-right: 0;
+ }
+ table.matrix tbody tr:last-child th,
+ table.matrix tbody tr:last-child td {
+ border-bottom: 0;
+ }
+ table.matrix th.corner {
+ background: transparent;
+ border: none;
+ }
+ table.matrix td {
+ font-family: 'JetBrains Mono', monospace;
+ transition: filter 150ms var(--ease-out-quart);
+ }
+ table.matrix td:not(.empty):hover {
+ filter: brightness(0.96);
+ }
+ table.matrix td.same-chain {
+ border-style: dashed;
+ }
+ table.matrix td.empty {
+ color: var(--text-3);
+ }
+
+ /* ── Data tables ── */
+ table.list {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+ font-variant-numeric: tabular-nums;
+ }
+ table.list th,
+ table.list td {
+ text-align: left;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border);
+ }
+ table.list th {
+ font-family: 'JetBrains Mono', monospace;
+ font-weight: 500;
+ color: var(--text-3);
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ }
+ table.list tbody tr {
+ transition: background-color 120ms var(--ease-out-quart);
+ }
+ table.list tbody tr:hover {
+ background: var(--bg-alt);
+ }
+ table.list tbody tr:last-child td {
+ border-bottom: 0;
+ }
+ table.list td.num {
+ font-family: 'JetBrains Mono', monospace;
+ text-align: right;
+ }
+ table.list td.mono {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.8125rem;
+ color: var(--text);
+ }
+ table.list a {
+ color: var(--accent);
+ transition: opacity 150ms;
+ }
+ table.list a:hover {
+ opacity: 0.7;
+ text-decoration: none;
+ }
+
+ /* ── Badges ── */
+ .badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 0.6875rem;
+ font-family: 'JetBrains Mono', monospace;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+ }
+ .badge.same { background: var(--green-soft); color: var(--green); }
+ .badge.cross { background: var(--accent-soft); color: var(--accent); }
+ .badge.x402 { background: var(--purple-soft); color: var(--purple); }
+ .badge.mpp { background: var(--warn-soft); color: var(--warn-deep); }
+ .badge.status-settled { background: var(--green-soft); color: var(--green); }
+ .badge.status-pending { background: var(--bg-alt); color: var(--text-2); }
+ .badge.status-failed {
+ background: var(--error-soft);
+ color: var(--error);
+ }
+
+ /* ── Cost comparison panel ── */
+ .panel {
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-lg);
+ }
+ .panel-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-md);
+ flex-wrap: wrap;
+ margin-bottom: var(--space-md);
+ }
+ .panel-head strong {
+ font-family: 'Bricolage Grotesque', sans-serif;
+ font-weight: 500;
+ font-size: 1rem;
+ letter-spacing: -0.01em;
+ color: var(--text);
+ }
+ .panel-caption {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ }
+ .panel table.list tbody tr:hover {
+ background: transparent;
+ }
+ .panel table.list th,
+ .panel table.list td {
+ border-bottom-color: var(--border-mid);
+ }
+ .cost-note {
+ font-size: 0.75rem;
+ color: var(--text-3);
+ margin-top: var(--space-md);
+ line-height: 1.55;
+ }
+
+ /* ── Loading / empty / error ── */
+ .loading {
+ padding: var(--space-lg);
+ color: var(--text-3);
+ font-size: 0.8125rem;
+ font-family: 'JetBrains Mono', monospace;
+ letter-spacing: 0.02em;
+ animation: dashboard-pulse 1.4s var(--ease-out-quart) infinite;
+ }
+ .empty,
+ .error {
+ padding: var(--space-lg);
+ text-align: center;
+ color: var(--text-3);
+ font-size: 0.8125rem;
+ background: var(--bg-alt);
+ border: 1px dashed var(--border-mid);
+ border-radius: var(--radius);
+ }
+ .error {
+ color: var(--warn-deep);
+ border-color: var(--warn-deep);
+ background: var(--warn-soft);
+ }
+
+ @keyframes dashboard-pulse {
+ 0%, 100% { opacity: 0.55; }
+ 50% { opacity: 1; }
+ }
+
+ /* ── Footer ── */
+ footer.site {
+ margin-top: var(--space-xl);
+ padding-top: var(--space-lg);
+ border-top: 1px solid var(--border);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.75rem;
+ color: var(--text-3);
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ letter-spacing: 0.01em;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .loading {
+ animation: none;
+ opacity: 0.7;
+ }
+ table.matrix td,
+ table.list tbody tr,
+ .chain-card,
+ .window-toggle button,
+ .rank-toggle button {
+ transition: none;
+ }
+ }
+
+ @media (max-width: 480px) {
+ main.dashboard section {
+ padding-top: var(--space-lg);
+ padding-bottom: var(--space-lg);
+ }
+ .snapshot {
+ grid-template-columns: 1fr;
+ }
+ .stat {
+ padding: var(--space-md) 0;
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ }
+ .stat:last-child {
+ border-bottom: 0;
+ }
+ .stat .value {
+ font-size: 1.5rem;
+ }
+ }
+`
diff --git a/packages/relay/src/views/landing/assets.d.ts b/packages/relay/src/views/landing/assets.d.ts
new file mode 100644
index 0000000..c8f91b0
--- /dev/null
+++ b/packages/relay/src/views/landing/assets.d.ts
@@ -0,0 +1,14 @@
+declare module '*.html' {
+ const content: string
+ export default content
+}
+
+declare module '*.css' {
+ const content: string
+ export default content
+}
+
+declare module '*.txt' {
+ const content: string
+ export default content
+}
diff --git a/packages/relay/src/views/landing/content.html b/packages/relay/src/views/landing/content.html
new file mode 100644
index 0000000..a4a206d
--- /dev/null
+++ b/packages/relay/src/views/landing/content.html
@@ -0,0 +1,624 @@
+
+
+
+
+
+
+ Any chain in,
+ your chain out.
+
+
+
+ Cross-chain USDC settlement for x402 (Coinbase's HTTP 402 protocol) and MPP (Machine
+ Payments Protocol). Buyer pays on Solana, seller receives on Stellar. No SDK, no
+ dashboard, no code changes.
+
+
+
+
+
+
+
+
+
+
+
Getting Started
+
+
+
# 1. Register your wallet (one-time)
+curl -X POST https://facilitator.402.md/register \
+ -H "Content-Type: application/json" \
+ -d '{"wallet": "GSELLER...", "network": "stellar:pubnet"}'
+
+# 2. Use standard @x402/express — zero 402md code
+import { paymentMiddleware } from "@x402/express"
+
+app.use(paymentMiddleware({
+ "GET /your-endpoint" : {
+ accepts: [
+ { scheme: "exact" , network: "eip155:8453" , payTo: "0x4C95...c3b9" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } }, // Base
+ { scheme: "exact" , network: "eip155:1" , payTo: "0x8aF2...71d4" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } }, // Ethereum
+ { scheme: "exact" , network: "eip155:10" , payTo: "0x2E19...a0b7" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } }, // Optimism
+ { scheme: "exact" , network: "eip155:42161" , payTo: "0xF1c3...9e82" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } }, // Arbitrum
+ { scheme: "exact" , network: "eip155:59144" , payTo: "0x7b45...d60a" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } }, // Linea
+ { scheme: "exact" , network: "eip155:130" , payTo: "0xD309...4cf1" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } }, // Unichain
+ { scheme: "exact" , network: "eip155:480" , payTo: "0x5a6e...bb28" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } }, // World Chain
+ { scheme: "exact" , network: "solana:mainnet" , payTo: "4r7q...BDP5" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } }, // Solana
+ { scheme: "exact" , network: "stellar:pubnet" , payTo: "GAX5...5HZR" , price: "$0.001" , extra: { merchantId: "7c-vQZHANEd5ICp" } }, // Stellar
+ ],
+ },
+}, "https://facilitator.402.md" ))
+
+# Buyers on any of the 9 supported chains can now pay you.
+
+
+
+
+
+
+
+
The Problem
+
One chain is not enough
+
+ x402 lets any API charge for access. But every facilitator today locks sellers to a single
+ chain — and leaves money on the table.
+
+
+
+
+
Chain lock-in
+
+ 30 x402 facilitators, all single-chain. Buyer on Solana, seller on Stellar? Payment
+ fails. Revenue lost silently.
+
+
+
+
Wrong tokens, wrong path
+
+ DEX-based bridges mean wrapped USDC, slippage, and risk. Native USDC via CCTP V2 is the
+ only clean path.
+
+
+
+
Fragmented protocols
+
x402 and MPP serve different ecosystems. No one speaks both — until now.
+
+
+
Agents don't choose chains
+
+ AI agents need to pay, not navigate blockchain infrastructure. The payment layer should
+ be invisible.
+
+
+
+
+
+
+
+
+
+
How It Works
+
Three steps. That's it.
+
+
+
+
Seller
+
One curl to start receiving
+
+
+
01
+
+
Register your wallet
+
+ POST /register with your address and chain. Get
+ facilitator addresses on all supported chains.
+
+
+
+
+
02
+
+
Use standard x402
+
+ Add @x402/express middleware. Point
+ payTo to your facilitator address. Zero 402md
+ dependencies.
+
+
+
+
+
03
+
+
Receive USDC on your chain
+
+ Buyer pays on any chain. 402md settles via CCTP V2. Native USDC, directly to your
+ wallet.
+
+
+
+
+
+
+
+
Buyer / Agent
+
Pay from any chain
+
+
+
01
+
+
Discover the seller
+
+ GET the API. Receive a 402 response with facilitator
+ addresses on your chain.
+
+
+
+
+
02
+
+
Pay with USDC
+
+ Sign a standard x402 payment on Base
+ Solana or
+ Stellar . No bridge, no swap.
+
+
+
+
+
03
+
+
Get the resource
+
+ Payment verified in milliseconds. Resource delivered instantly. Settlement runs in
+ the background.
+
+
+
+
+
+
+
+
+
+
+
+
+
By Design
+
Opinionated choices, for good reasons
+
+ Every decision optimizes for one thing: the seller should do zero extra work to accept
+ payments from any chain.
+
+
+
+
+
Non-custodial
+
CCTP V2 mints USDC directly to the seller's wallet. 402md never holds your funds.
+
+
+
Free
+
+ 0% platform fee. Only network gas deducted from settlement. Fixed schedule, no
+ surprises.
+
+
+
+
Zero dependencies
+
Sellers use standard @x402/express from Coinbase. No 402md SDK, no custom packages.
+
+
+
+
+
+
Dual protocol
+
x402 and MPP through one facilitator. Two ecosystems, one registration.
+
+
+
Native USDC only
+
Circle CCTP V2 burns and mints real USDC. No wrapped tokens, no slippage.
+
+
+
Three chains
+
Base, Solana, Stellar. Adding a new EVM chain is a config change, not a deploy.
+
+
+
+
+
+
+
+
+
+
+
Pricing
+
Free. Seriously.
+
+ Zero platform fee. The only cost is a fixed gas allowance per route, deducted from the
+ seller's payout. Here's what that looks like on a $1.00 payment.
+
+
+
+
+
+
+ Route
+ Seller receives
+ Gas allowance
+
+
+
+
+
+ Stellar →
+ Stellar
+
+ $0.99999
+ $0.00001 0.001%
+
+
+
+ Solana →
+ Solana
+
+ $0.9990
+ $0.0010 0.10%
+
+
+
+ Base →
+ Base
+
+ $0.9980
+ $0.0020 0.20%
+
+
+
+ Stellar →
+ Solana
+
+ $0.9985
+ $0.0015 0.15%
+
+
+
+ Solana →
+ Stellar
+
+ $0.9975
+ $0.0025 0.25%
+
+
+
+ Stellar →
+ Base
+
+ $0.9975
+ $0.0025 0.25%
+
+
+
+ Base →
+ Stellar
+
+ $0.9968
+ $0.0032 0.32%
+
+
+
+ Base →
+ Solana
+
+ $0.9965
+ $0.0035 0.35%
+
+
+
+ Solana →
+ Base
+
+ $0.9965
+ $0.0035 0.35%
+
+
+
+
+
+
+
+
+
+
+
FAQ
+
Common questions
+
+
+
+ What is Facilitator by 402md?
+
+ A cross-chain USDC settlement facilitator built by 402md for the x402 and MPP protocols.
+ Sellers register once and receive payments from buyers on any supported chain — Base,
+ Solana, or Stellar.
+
+
+
+
+ How is this different from other x402 facilitators?
+
+ Existing facilitators are single-chain. If a buyer is on Solana and the seller is on
+ Stellar, the payment fails. 402md bridges that gap using Circle CCTP V2 — atomic,
+ non-custodial, native USDC. No wrapped tokens, no slippage.
+
+
+
+
+ What chains are supported?
+
+ Base (EVM), Solana, and Stellar — all connected via Circle CCTP V2. Adding new EVM
+ chains requires only a config update, no contract deployments.
+
+
+
+
+ How much does it cost?
+
+ Zero platform fee. The only deduction is a fixed gas allowance per route — for example,
+ $0.0032 for Base→Stellar on a $1.00 payment. Same-chain payments cost even less
+ ($0.00001 for Stellar→Stellar).
+
+
+
+
+ Is it custodial?
+
+ No. Cross-chain settlements use Circle CCTP V2, which mints USDC directly to the
+ seller's wallet. 402md never holds seller funds. The facilitator only retains the gas
+ allowance needed to execute the settlement.
+
+
+
+
+ How long does settlement take?
+
+ Same-chain: under 5 seconds. Stellar or Solana cross-chain: 5-30 seconds. EVM
+ cross-chain: 15-19 minutes, limited by block finality. The buyer gets the resource
+ instantly — settlement happens in the background.
+
+
+
+
+ Do I need to install any 402md package?
+
+ No. Sellers use the standard @x402/express SDK from Coinbase. The only 402md-specific
+ step is a one-time POST /register call — after that, everything works with standard x402
+ tooling.
+
+
+
+
+ What about same-chain payments?
+
+ If buyer and seller are on the same chain, 402md does a direct USDC transfer — no bridge
+ involved. Faster settlement, lower gas, same API.
+
+
+
+
+ What is MPP?
+
+ Machine Payments Protocol, developed by Stellar and used by services like OpenAI,
+ Anthropic, and Dune. 402md supports MPP alongside x402, enabling AI agents from both
+ ecosystems to transact through one facilitator.
+
+
+
+
+
+
+
+
+
+
Get Started
+
One curl. All chains.
+
+ Register your wallet and start accepting cross-chain USDC in under a minute. No dashboard,
+ no login, no SDK.
+
+
+
+ USDC
+ Base
+ Solana
+ Stellar
+
+
+
+
+
+
+
+
+
diff --git a/packages/relay/src/views/landing/page.test.ts b/packages/relay/src/views/landing/page.test.ts
new file mode 100644
index 0000000..2f042ea
--- /dev/null
+++ b/packages/relay/src/views/landing/page.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from 'bun:test'
+import '@/shared/test-helpers'
+import { LandingPage } from './page'
+
+describe('LandingPage', () => {
+ const html = LandingPage()
+
+ test('renders as a large HTML string with doctype', () => {
+ expect(typeof html).toBe('string')
+ expect(html.startsWith('')).toBe(true)
+ expect(html.length).toBeGreaterThan(50_000)
+ })
+
+ test('includes the shared Nav (same component as the dashboard)', () => {
+ expect(html).toContain('')
+ expect(html).toContain('href="/dashboard"')
+ expect(html).not.toContain('aria-current="page"') // not active on landing
+ })
+
+ test('renders the hero section and register anchor', () => {
+ expect(html).toContain('class="hero"')
+ expect(html).toContain('id="register"')
+ expect(html).toContain('#how-it-works')
+ })
+
+ test('includes landing-specific styles and scripts', () => {
+ expect(html).toContain('Scroll reveals')
+ expect(html).toContain('register-form')
+ })
+})
diff --git a/packages/relay/src/views/landing/page.tsx b/packages/relay/src/views/landing/page.tsx
new file mode 100644
index 0000000..2b5935d
--- /dev/null
+++ b/packages/relay/src/views/landing/page.tsx
@@ -0,0 +1,17 @@
+import { Layout } from '../layout'
+import { Nav } from '../nav'
+import landingContent from './content.html' with { type: 'text' }
+import landingStyles from './styles.css' with { type: 'text' }
+import landingScript from './script.txt' with { type: 'text' }
+
+export const LandingPage = () => (
+
+
+ {landingContent}
+
+
+)
diff --git a/packages/relay/src/views/landing/script.txt b/packages/relay/src/views/landing/script.txt
new file mode 100644
index 0000000..ebd291c
--- /dev/null
+++ b/packages/relay/src/views/landing/script.txt
@@ -0,0 +1,331 @@
+ /* ── Helpers ── */
+ const esc = (s) => {
+ const d = document.createElement('span')
+ d.textContent = s
+ return d.innerHTML
+ }
+
+ /* ── Scroll reveals ── */
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((e) => {
+ if (e.isIntersecting) {
+ e.target.classList.add('visible')
+ observer.unobserve(e.target)
+ }
+ })
+ },
+ { threshold: 0.08 },
+ )
+
+ document.querySelectorAll('.reveal').forEach((el) => observer.observe(el))
+
+ /* ── Nav scroll shadow (IntersectionObserver — fires only at threshold, not every frame) ── */
+ const nav = document.querySelector('.nav')
+ const scrollSentinel = document.createElement('div')
+ scrollSentinel.style.cssText =
+ 'position:absolute;top:0;height:20px;width:1px;pointer-events:none'
+ scrollSentinel.setAttribute('aria-hidden', 'true')
+ document.body.prepend(scrollSentinel)
+ new IntersectionObserver(([e]) => {
+ nav.classList.toggle('scrolled', !e.isIntersecting)
+ }).observe(scrollSentinel)
+
+ /* ── Registration form ── */
+ const registerForm = document.getElementById('register-form')
+ const registerResult = document.getElementById('register-result')
+ const registerBtn = registerForm.querySelector('.register-btn')
+ const walletInput = document.getElementById('wallet-input')
+
+ const ENV_NETWORKS = {
+ mainnet: {
+ base: 'eip155:8453',
+ ethereum: 'eip155:1',
+ optimism: 'eip155:10',
+ arbitrum: 'eip155:42161',
+ linea: 'eip155:59144',
+ unichain: 'eip155:130',
+ worldchain: 'eip155:480',
+ solana: 'solana:mainnet',
+ stellar: 'stellar:pubnet',
+ },
+ testnet: {
+ base: 'eip155:84532',
+ ethereum: 'eip155:11155111',
+ optimism: 'eip155:11155420',
+ arbitrum: 'eip155:421614',
+ linea: 'eip155:59141',
+ unichain: 'eip155:1301',
+ worldchain: 'eip155:4801',
+ solana: 'solana:devnet',
+ stellar: 'stellar:testnet',
+ },
+ }
+
+ const NETWORK_LABELS = {
+ 'eip155:8453': 'Base',
+ 'eip155:84532': 'Base Sepolia',
+ 'eip155:1': 'Ethereum',
+ 'eip155:11155111': 'Ethereum Sepolia',
+ 'eip155:10': 'Optimism',
+ 'eip155:11155420': 'Optimism Sepolia',
+ 'eip155:42161': 'Arbitrum',
+ 'eip155:421614': 'Arbitrum Sepolia',
+ 'eip155:59144': 'Linea',
+ 'eip155:59141': 'Linea Sepolia',
+ 'eip155:130': 'Unichain',
+ 'eip155:1301': 'Unichain Sepolia',
+ 'eip155:480': 'World Chain',
+ 'eip155:4801': 'World Chain Sepolia',
+ 'solana:mainnet': 'Solana',
+ 'solana:devnet': 'Solana Devnet',
+ 'stellar:pubnet': 'Stellar',
+ 'stellar:testnet': 'Stellar Testnet',
+ }
+
+ // All EVM chains share the same address format — one family, one validator.
+ const CHAIN_FAMILY = {
+ base: 'evm',
+ ethereum: 'evm',
+ optimism: 'evm',
+ arbitrum: 'evm',
+ linea: 'evm',
+ unichain: 'evm',
+ worldchain: 'evm',
+ solana: 'solana',
+ stellar: 'stellar',
+ }
+
+ const NETWORK_FORMATS = {
+ evm: {
+ placeholder: '0xd8dA6BF2...aA96045',
+ hint: '0x followed by 40 hex characters',
+ pattern: /^0x[0-9a-fA-F]{40}$/,
+ example: '0x...',
+ },
+ solana: {
+ placeholder: '7EcDhSYG...rBDP5',
+ hint: 'Base58 address, 32-44 characters',
+ pattern: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/,
+ example: 'base58 address',
+ },
+ stellar: {
+ placeholder: 'GABCDE...WXYZ',
+ hint: 'G followed by 55 uppercase characters',
+ pattern: /^G[A-Z2-7]{55}$/,
+ example: 'G...',
+ },
+ }
+
+ function getFormat(chain) {
+ return NETWORK_FORMATS[CHAIN_FAMILY[chain]]
+ }
+
+ function getEnv() {
+ return registerForm.querySelector('[name="env-toggle"]:checked').value
+ }
+
+ function getChain() {
+ return registerForm.querySelector('[name="network"]:checked').value
+ }
+
+ function getNetworkId() {
+ return ENV_NETWORKS[getEnv()][getChain()]
+ }
+
+ function getChainLabel() {
+ const chain = getChain()
+ return NETWORK_LABELS[getNetworkId()] || chain
+ }
+
+ const walletHint = document.getElementById('wallet-hint')
+ const registerStatus = document.getElementById('register-status')
+
+ function updateWalletUI() {
+ const fmt = getFormat(getChain())
+ if (!fmt) return
+ walletInput.placeholder = fmt.placeholder
+ walletHint.textContent = fmt.hint
+ walletHint.classList.remove('error')
+ walletInput.classList.remove('invalid')
+ }
+
+ document.querySelectorAll('[name="network"]').forEach((radio) => {
+ radio.addEventListener('change', updateWalletUI)
+ })
+
+ walletInput.addEventListener('input', () => {
+ walletInput.classList.remove('invalid')
+ walletHint.classList.remove('error')
+ walletHint.textContent = getFormat(getChain()).hint
+ })
+
+ /* ── Copy to clipboard ── */
+ registerResult.addEventListener('click', (e) => {
+ const btn = e.target.closest('.copy-btn')
+ if (!btn) return
+ const text =
+ btn.dataset.copy ||
+ btn.closest('.result-step')?.querySelector('.result-code')?.textContent ||
+ ''
+ navigator.clipboard.writeText(text).then(() => {
+ const prev = btn.textContent
+ btn.textContent = 'copied'
+ setTimeout(() => {
+ btn.textContent = prev
+ }, 1500)
+ })
+ })
+
+ registerForm.addEventListener('submit', async (e) => {
+ e.preventDefault()
+
+ const chain = getChain()
+ const network = getNetworkId()
+ const wallet = walletInput.value.trim()
+ const fmt = getFormat(chain)
+ const label = NETWORK_LABELS[network]
+
+ /* ── Per-chain validation ── */
+ if (!wallet) {
+ walletHint.textContent = 'Enter your ' + label + ' wallet address (' + fmt.example + ')'
+ walletHint.classList.add('error')
+ walletInput.classList.add('invalid')
+ walletInput.focus()
+ return
+ }
+
+ if (!fmt.pattern.test(wallet)) {
+ walletHint.textContent =
+ 'Doesn\u2019t look like a ' + label + ' address. Expected: ' + fmt.hint
+ walletHint.classList.add('error')
+ walletInput.classList.add('invalid')
+ walletInput.focus()
+ return
+ }
+
+ walletHint.classList.remove('error')
+ walletInput.classList.remove('invalid')
+
+ registerBtn.classList.add('loading')
+ registerBtn.disabled = true
+ registerForm.setAttribute('aria-busy', 'true')
+ registerStatus.textContent = 'Registering\u2026'
+ registerResult.classList.remove('visible')
+
+ try {
+ const res = await fetch('/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ wallet, network }),
+ })
+
+ const text = await res.text()
+ let data
+ try {
+ data = JSON.parse(text)
+ } catch {
+ throw new Error(text || 'Unexpected response')
+ }
+
+ if (!res.ok) {
+ if (res.status === 429) {
+ throw new Error('Registration limit reached. Try again in a few minutes.')
+ }
+ if (res.status >= 500) {
+ throw new Error(
+ 'Something went wrong on our end. Try again, or use the API directly: POST /register',
+ )
+ }
+ throw new Error(data.message || 'Registration failed (' + String(res.status) + ')')
+ }
+
+ let html = '\u2713 Registered on ' + esc(label) + '
'
+
+ if (data.facilitatorAddresses) {
+ html +=
+ 'Your facilitator addresses:
'
+ for (const [net, addr] of Object.entries(data.facilitatorAddresses)) {
+ html += ''
+ html += '' + esc(NETWORK_LABELS[net] || net) + ' '
+ html += '' + esc(addr) + ' '
+ html += 'copy '
+ html += '
'
+ }
+ }
+
+ if (data.merchantId) {
+ html += ''
+ html += 'Merchant ID '
+ html += '' + esc(data.merchantId) + ' '
+ html +=
+ 'copy '
+ html += '
'
+ }
+
+ if (data.codeSnippet) {
+ html += ''
+ html += '
Next step \u2014 add to your app '
+ html += '
' + esc(data.codeSnippet) + ' '
+ html +=
+ '
copy snippet '
+ html += '
'
+ }
+
+ registerResult.innerHTML = html
+ registerResult.classList.add('visible')
+ } catch (err) {
+ registerResult.innerHTML = '\u2717 ' + esc(err.message) + ' '
+ registerResult.classList.add('visible')
+ } finally {
+ registerBtn.classList.remove('loading')
+ registerBtn.disabled = false
+ registerForm.removeAttribute('aria-busy')
+ registerStatus.textContent = ''
+ }
+ })
+
+ /* ── Code typewriter ── */
+ if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ const codeBody = document.querySelector('.code-section .code-body')
+ if (codeBody) {
+ const fullHTML = codeBody.innerHTML
+ const lines = fullHTML.split('\n')
+ const h = codeBody.offsetHeight
+ codeBody.style.minHeight = h + 'px'
+ codeBody.innerHTML = ''
+
+ const codeObs = new IntersectionObserver(
+ ([e]) => {
+ if (e.isIntersecting) {
+ codeObs.unobserve(e.target)
+ let i = 0
+
+ function typeLine() {
+ if (i >= lines.length) {
+ codeBody.insertAdjacentHTML('beforeend', ' ')
+ setTimeout(() => {
+ const c = codeBody.querySelector('.code-cursor')
+ if (c) c.remove()
+ }, 2500)
+ return
+ }
+
+ codeBody.innerHTML = lines.slice(0, i + 1).join('\n')
+ i++
+
+ const nextIsBlank = i < lines.length && lines[i].trim() === ''
+ setTimeout(typeLine, nextIsBlank ? 50 : 100)
+ }
+
+ typeLine()
+ }
+ },
+ { threshold: 0.3 },
+ )
+
+ codeObs.observe(codeBody)
+ }
+ }
diff --git a/packages/relay/src/views/landing/styles.css b/packages/relay/src/views/landing/styles.css
new file mode 100644
index 0000000..22afe6b
--- /dev/null
+++ b/packages/relay/src/views/landing/styles.css
@@ -0,0 +1,1088 @@
+/* ══════════════════════════════════════════
+ TOKENS — landing-only extensions
+ Everything else (colors, base spacing, radii, ease)
+ lives in shared-styles.ts.
+ ══════════════════════════════════════════ */
+:root {
+ --space-3xl: 64px;
+ --space-4xl: 96px;
+}
+
+/* ══════════════════════════════════════════
+ BASE — landing overrides
+ Looser measure for marketing prose vs. shared's denser default.
+ ══════════════════════════════════════════ */
+body {
+ font-size: 1rem;
+ line-height: 1.6;
+ font-kerning: normal;
+}
+
+a {
+ color: inherit;
+}
+a:hover {
+ text-decoration: none;
+}
+
+/* ══════════════════════════════════════════
+ TYPOGRAPHY
+ ══════════════════════════════════════════ */
+h1,
+h2,
+h3 {
+ font-family: 'Bricolage Grotesque', system-ui, sans-serif;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ text-wrap: balance;
+}
+
+h1 {
+ font-weight: 800;
+}
+h2 {
+ font-weight: 700;
+}
+h3 {
+ font-weight: 600;
+}
+
+h2 em {
+ font-weight: 300;
+ font-style: italic;
+ color: var(--text-2);
+}
+
+/* ══════════════════════════════════════════
+ LAYOUT
+ ══════════════════════════════════════════ */
+section {
+ padding: var(--space-3xl) 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.section-label {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--signal-deep);
+ margin-bottom: var(--space-sm);
+}
+
+.section-lead {
+ font-size: 1rem;
+ color: var(--text-2);
+ line-height: 1.65;
+ max-width: 65ch;
+ margin-bottom: var(--space-sm);
+}
+
+section h2 {
+ font-size: clamp(1.75rem, 3vw + 0.5rem, 2.5rem);
+ margin-bottom: var(--space-lg);
+}
+
+/* ══════════════════════════════════════════
+ BUTTONS
+ ══════════════════════════════════════════ */
+.btn-primary {
+ display: inline-flex;
+ align-items: center;
+ padding: 12px 24px;
+ background: var(--accent);
+ color: var(--bg);
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ font-weight: 600;
+ transition: background-color 150ms;
+}
+.btn-primary:hover {
+ background: var(--accent-deep);
+}
+
+.btn-ghost {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text-2);
+ transition: color 150ms;
+}
+.btn-ghost:hover {
+ color: var(--text);
+}
+
+.btn-outline {
+ display: inline-flex;
+ align-items: center;
+ padding: 12px 24px;
+ border: 1px solid var(--border-mid);
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text);
+ transition: border-color 150ms;
+}
+.btn-outline:hover {
+ border-color: var(--text);
+}
+
+/* ══════════════════════════════════════════
+ HERO
+ ══════════════════════════════════════════ */
+.hero {
+ padding: var(--space-3xl) 0 var(--space-2xl);
+ border-bottom: none;
+}
+
+.hero-content {
+ max-width: 600px;
+}
+
+.hero h1 {
+ font-size: clamp(2.5rem, 5.5vw + 1rem, 4.5rem);
+ margin-bottom: var(--space-md);
+}
+
+.hero h1 em {
+ display: block;
+ font-weight: 300;
+ font-style: italic;
+ color: var(--text-2);
+}
+
+.hero-any {
+ color: var(--accent);
+}
+
+.hero-sub {
+ font-size: 1rem;
+ color: var(--text-2);
+ max-width: 65ch;
+ line-height: 1.65;
+ margin-bottom: var(--space-xl);
+}
+
+.hero-ctas {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ flex-wrap: wrap;
+}
+
+/* ══════════════════════════════════════════
+ CODE BLOCK (light theme)
+ ══════════════════════════════════════════ */
+.code-section {
+ padding: var(--space-2xl) 0;
+ border-bottom: none;
+}
+
+.code-block {
+ border: 1px solid var(--border-mid);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background: var(--bg);
+}
+
+.code-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-md);
+ background: var(--bg-alt);
+ border-bottom: 1px solid var(--border);
+}
+
+.code-dots {
+ display: flex;
+ gap: var(--space-sm);
+}
+
+.code-dots span {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+}
+
+.code-dot-red {
+ background: #ff5f57;
+}
+.code-dot-yellow {
+ background: #febc2e;
+}
+.code-dot-green {
+ background: #28c840;
+}
+
+.code-label {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ color: var(--text-2);
+ letter-spacing: 0.02em;
+ margin-left: var(--space-sm);
+}
+
+.code-body {
+ padding: var(--space-lg);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.8125rem;
+ line-height: 1.75;
+ color: oklch(0.27 0.015 55);
+ overflow-x: auto;
+ font-variant-ligatures: none;
+}
+
+.code-body .cm {
+ color: var(--text-3);
+}
+.code-body .kw {
+ color: oklch(0.4 0.16 265);
+}
+.code-body .st {
+ color: oklch(0.42 0.12 55);
+}
+.code-body .fn {
+ color: oklch(0.42 0.12 320);
+}
+.code-body .op {
+ color: oklch(0.27 0.015 55);
+}
+
+/* ══════════════════════════════════════════
+ PROBLEM
+ ══════════════════════════════════════════ */
+.problem-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-xl);
+ margin-top: var(--space-xl);
+}
+
+.problem-item h3 {
+ font-size: 1.125rem;
+ margin-bottom: var(--space-xs);
+}
+
+.problem-item p {
+ font-size: 0.875rem;
+ color: var(--text-2);
+ line-height: 1.65;
+ max-width: 65ch;
+}
+
+/* ══════════════════════════════════════════
+ HOW IT WORKS
+ ══════════════════════════════════════════ */
+.flow-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-2xl);
+ margin-top: var(--space-xl);
+}
+
+.flow-label {
+ display: inline-block;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ padding: var(--space-xs) var(--space-sm);
+ background: var(--accent-soft);
+ color: var(--accent);
+ border-radius: 4px;
+ margin-bottom: var(--space-sm);
+}
+
+.flow-label-buyer {
+ background: var(--green-soft);
+ color: var(--green);
+}
+
+.flow-col-buyer .flow-num {
+ color: var(--green);
+}
+
+.flow-intro {
+ font-size: 0.875rem;
+ color: var(--text-3);
+ margin-bottom: var(--space-lg);
+}
+
+.flow-steps {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-lg);
+}
+
+.flow-step {
+ display: flex;
+ gap: var(--space-md);
+}
+
+.flow-num {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--accent);
+ flex-shrink: 0;
+ padding-top: 3px;
+}
+
+.flow-step h3 {
+ font-size: 1rem;
+ margin-bottom: var(--space-xs);
+}
+
+.flow-step p {
+ font-size: 0.875rem;
+ color: var(--text-2);
+ line-height: 1.6;
+}
+
+.pill {
+ display: inline;
+ padding: 2px var(--space-sm);
+ border-radius: 4px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.75rem;
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ color: var(--text-2);
+}
+
+/* ══════════════════════════════════════════
+ FEATURES
+ ══════════════════════════════════════════ */
+.features-primary {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin-top: var(--space-xl);
+}
+
+.feature-row {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-xs);
+ padding: var(--space-xl) 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.feature-row:last-child {
+ border-bottom: none;
+}
+
+.feature-row h3 {
+ font-size: 1.25rem;
+}
+
+.feature-row p {
+ font-size: 0.9375rem;
+ color: var(--text-2);
+ line-height: 1.6;
+ max-width: 65ch;
+}
+
+.features-secondary {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-xl);
+ margin-top: var(--space-xl);
+ padding-top: var(--space-xl);
+ border-top: 1px solid var(--border);
+}
+
+.feature-sm h3 {
+ font-size: 0.9375rem;
+ margin-bottom: var(--space-xs);
+}
+
+.feature-sm p {
+ font-size: 0.8125rem;
+ color: var(--text-2);
+ line-height: 1.6;
+}
+
+.ecosystem-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ margin-top: var(--space-2xl);
+ padding-top: var(--space-xl);
+ border-top: 1px solid var(--border);
+}
+
+.eco-link {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--space-sm) var(--space-md);
+ border: 1px solid var(--border-mid);
+ border-radius: var(--radius);
+ font-size: 0.8125rem;
+ color: var(--text-2);
+ transition:
+ border-color 150ms,
+ color 150ms;
+}
+.eco-link:hover {
+ border-color: var(--text);
+ color: var(--text);
+}
+
+/* ══════════════════════════════════════════
+ PRICING
+ ══════════════════════════════════════════ */
+.pricing-wrap {
+ overflow-x: auto;
+ margin-top: var(--space-xl);
+ -webkit-overflow-scrolling: touch;
+}
+
+.pricing-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-variant-numeric: tabular-nums;
+ min-width: 500px;
+}
+
+.pricing-table th {
+ text-align: left;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ padding: var(--space-sm) 0;
+ border-bottom: 1px solid var(--border-mid);
+}
+
+.pricing-table th:last-child {
+ text-align: right;
+}
+
+.pricing-table td {
+ padding: var(--space-md) 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 0.9375rem;
+}
+
+.pricing-table td:nth-child(2) {
+ font-family: 'JetBrains Mono', monospace;
+ font-weight: 500;
+ font-size: 0.875rem;
+}
+
+.pricing-table td:last-child {
+ text-align: right;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.8125rem;
+ color: var(--text-3);
+}
+
+.pricing-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.gas-pct {
+ color: var(--text-3);
+ font-size: 0.6875rem;
+ margin-left: var(--space-sm);
+}
+
+/* ══════════════════════════════════════════
+ FAQ
+ ══════════════════════════════════════════ */
+.faq-list {
+ margin-top: var(--space-xl);
+}
+
+.faq-item {
+ border-bottom: 1px solid var(--border);
+}
+
+.faq-item summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-lg) 0;
+ cursor: pointer;
+ font-family: 'Red Hat Text', sans-serif;
+ font-size: 1rem;
+ font-weight: 500;
+ list-style: none;
+ user-select: none;
+ gap: var(--space-md);
+ transition: color 150ms;
+}
+
+.faq-item summary::-webkit-details-marker {
+ display: none;
+}
+.faq-item summary:hover {
+ color: var(--accent);
+}
+.faq-item summary:hover::after {
+ color: var(--accent);
+}
+
+.faq-item summary::after {
+ content: '+';
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 1.125rem;
+ font-weight: 400;
+ color: var(--text-3);
+ flex-shrink: 0;
+}
+
+.faq-item[open] summary::after {
+ content: '\2212';
+}
+
+.faq-answer {
+ padding: 0 0 var(--space-lg);
+ font-size: 0.9375rem;
+ color: var(--text-2);
+ line-height: 1.7;
+ max-width: 65ch;
+}
+
+/* ══════════════════════════════════════════
+ CTA / REGISTER
+ ══════════════════════════════════════════ */
+.cta-section {
+ background: var(--bg-alt);
+ border-bottom: none;
+ padding-top: var(--space-4xl);
+ padding-bottom: var(--space-4xl);
+}
+
+.supported-currencies {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-xs) var(--space-sm);
+ margin-top: var(--space-lg);
+}
+
+.currency-label {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-2);
+ margin-right: var(--space-xs);
+}
+
+.register-form {
+ background: var(--bg);
+ border: 1px solid var(--border-mid);
+ border-radius: var(--radius-lg);
+ padding: var(--space-xl);
+ margin-top: var(--space-xl);
+}
+
+.register-form fieldset {
+ border: none;
+ padding: 0;
+}
+
+.register-label {
+ display: block;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: var(--space-sm);
+}
+
+.network-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ margin-bottom: var(--space-lg);
+}
+
+.network-radio {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.network-label {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--space-sm) var(--space-md);
+ border: 1px solid var(--border-mid);
+ border-radius: var(--radius);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.8125rem;
+ color: var(--text-2);
+ cursor: pointer;
+ transition:
+ border-color 150ms,
+ color 150ms,
+ background-color 150ms;
+}
+
+.network-label:hover {
+ border-color: var(--text-3);
+ color: var(--text);
+}
+
+.network-radio:checked + .network-label {
+ border-color: var(--accent);
+ color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.network-radio:focus-visible + .network-label {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.wallet-input {
+ width: 100%;
+ padding: 12px var(--space-md);
+ background: var(--bg-alt);
+ border: 1px solid var(--border-mid);
+ border-radius: var(--radius);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.875rem;
+ color: var(--text);
+ outline: none;
+ transition: border-color 150ms;
+}
+
+.wallet-input::placeholder {
+ color: var(--text-3);
+}
+.wallet-input:focus {
+ border-color: var(--accent);
+}
+.wallet-input.invalid {
+ border-color: var(--error);
+}
+
+.wallet-hint {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ color: var(--text-3);
+ margin-top: var(--space-xs);
+ transition: color 150ms;
+}
+
+.wallet-hint.error {
+ color: var(--error);
+}
+
+.register-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ margin-top: var(--space-lg);
+ flex-wrap: wrap;
+}
+
+.register-btn {
+ padding: 12px 24px;
+ background: var(--accent);
+ color: var(--bg);
+ border: none;
+ border-radius: var(--radius);
+ font-family: 'Red Hat Text', sans-serif;
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background-color 150ms;
+}
+
+.register-btn:hover {
+ background: var(--accent-deep);
+}
+.register-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.register-swagger {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.8125rem;
+ color: var(--text-3);
+ transition: color 150ms;
+}
+.register-swagger:hover {
+ color: var(--text-2);
+}
+
+.register-result {
+ display: none;
+ margin-top: var(--space-lg);
+ padding-top: var(--space-lg);
+ border-top: 1px solid var(--border);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.8125rem;
+ line-height: 1.75;
+ color: var(--text);
+}
+
+.register-result.visible {
+ display: block;
+}
+.register-result .success {
+ color: var(--green);
+}
+.register-result .error {
+ color: var(--error);
+}
+
+.addr-row {
+ display: flex;
+ gap: var(--space-md);
+ padding: 2px 0;
+}
+
+.addr-label {
+ flex-shrink: 0;
+ width: 100px;
+ color: var(--text-3);
+}
+
+.addr-value {
+ word-break: break-all;
+}
+
+.register-hint {
+ margin-top: var(--space-md);
+ font-size: 0.75rem;
+ color: var(--text-3);
+ line-height: 1.6;
+}
+
+.addr-row {
+ align-items: center;
+}
+
+.copy-btn {
+ flex-shrink: 0;
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 2px var(--space-sm);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.625rem;
+ color: var(--text-3);
+ cursor: pointer;
+ transition:
+ border-color 150ms,
+ color 150ms;
+ margin-left: auto;
+}
+.copy-btn:hover {
+ border-color: var(--text-2);
+ color: var(--text-2);
+}
+
+.result-step {
+ margin-top: var(--space-lg);
+ padding-top: var(--space-lg);
+ border-top: 1px solid var(--border);
+}
+
+.result-code {
+ margin-top: var(--space-sm);
+ padding: var(--space-md);
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.75rem;
+ line-height: 1.75;
+ overflow-x: auto;
+ white-space: pre;
+ color: oklch(0.27 0.015 55);
+}
+
+.result-code .kw {
+ color: oklch(0.4 0.16 265);
+}
+.result-code .st {
+ color: oklch(0.42 0.12 55);
+}
+.result-code .cm {
+ color: var(--text-3);
+}
+
+/* ══════════════════════════════════════════
+ CHAIN PILLS
+ ══════════════════════════════════════════ */
+.chain {
+ display: inline-block;
+ padding: 2px var(--space-sm);
+ border-radius: 4px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.6875rem;
+ font-weight: 500;
+}
+
+.chain-base {
+ background: #0052ff12;
+ color: #0052ff;
+}
+.chain-solana {
+ background: #9945ff12;
+ color: #7c3aed;
+}
+.chain-stellar {
+ background: oklch(0.17 0.015 55 / 0.06);
+ color: var(--text);
+}
+
+/* ══════════════════════════════════════════
+ CODE CURSOR
+ ══════════════════════════════════════════ */
+.code-cursor {
+ display: inline-block;
+ width: 2px;
+ height: 1.1em;
+ background: var(--accent);
+ vertical-align: text-bottom;
+ margin-left: 1px;
+ animation: cursor-blink 700ms step-end infinite;
+}
+
+/* ══════════════════════════════════════════
+ FOOTER
+ ══════════════════════════════════════════ */
+footer {
+ padding: var(--space-xl) 0;
+}
+
+.footer-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--space-md);
+}
+
+.footer-left {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.75rem;
+ color: var(--text-3);
+}
+
+.footer-links {
+ display: flex;
+ gap: var(--space-lg);
+ font-size: 0.8125rem;
+ color: var(--text-3);
+}
+
+.footer-links a {
+ transition: color 150ms;
+}
+.footer-links a:hover {
+ color: var(--text-2);
+}
+
+/* ══════════════════════════════════════════
+ ANIMATIONS
+ ══════════════════════════════════════════ */
+@keyframes fade-up {
+ from {
+ opacity: 0;
+ transform: translateY(16px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes faq-open {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes cursor-blink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
+}
+
+/* Hero stagger */
+.hero [data-reveal] {
+ opacity: 0;
+ animation: fade-up 600ms var(--ease-out-quart) both;
+ animation-delay: calc(var(--i, 0) * 80ms);
+}
+
+/* Scroll reveals */
+.reveal {
+ opacity: 0;
+ transform: translateY(16px);
+ transition:
+ opacity 500ms var(--ease-out-quart),
+ transform 500ms var(--ease-out-quart);
+}
+
+.reveal.visible {
+ opacity: 1;
+ transform: none;
+}
+
+/* Button press feedback */
+.btn-primary:active,
+.btn-outline:active,
+.btn-ghost:active,
+.register-btn:active {
+ transform: scale(0.97);
+ transition: transform 80ms;
+}
+
+/* FAQ smooth open */
+.faq-item[open] .faq-answer {
+ animation: faq-open 300ms var(--ease-out-quart);
+}
+
+.faq-item summary::after {
+ transition: color 200ms;
+}
+
+.faq-item[open] summary::after {
+ color: var(--accent);
+}
+
+/* Register button loading */
+.register-btn {
+ position: relative;
+}
+
+.register-btn.loading {
+ color: transparent;
+ pointer-events: none;
+}
+
+.register-btn.loading::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 18px;
+ height: 18px;
+ border: 2px solid oklch(0.98 0.008 75 / 0.3);
+ border-top-color: var(--bg);
+ border-radius: 50%;
+ animation: spin 600ms linear infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .hero [data-reveal] {
+ animation: none;
+ opacity: 1;
+ transform: none;
+ }
+ .reveal {
+ transition: none;
+ opacity: 1;
+ transform: none;
+ }
+ .faq-item[open] .faq-answer {
+ animation: none;
+ }
+ .code-cursor {
+ animation: none;
+ opacity: 0;
+ }
+ .register-btn.loading::after {
+ animation: none;
+ border: 2px solid var(--bg);
+ border-top-color: transparent;
+ }
+}
+
+/* ══════════════════════════════════════════
+ RESPONSIVE
+ ══════════════════════════════════════════ */
+@media (min-width: 768px) {
+ section {
+ padding: var(--space-4xl) 0;
+ }
+
+ .hero {
+ padding: var(--space-4xl) 0 var(--space-3xl);
+ }
+
+ .hero-content {
+ max-width: 640px;
+ }
+
+ .problem-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-xl) var(--space-2xl);
+ }
+
+ .flow-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-2xl);
+ }
+
+ .feature-row {
+ grid-template-columns: 200px 1fr;
+ gap: var(--space-xl);
+ align-items: baseline;
+ }
+
+ .features-secondary {
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-lg) var(--space-xl);
+ }
+}
+
+@media (max-width: 480px) {
+ .hero h1 {
+ font-size: 2.25rem;
+ }
+ .hero-ctas {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+ .code-body {
+ font-size: 0.75rem;
+ padding: var(--space-md);
+ }
+ .footer-inner {
+ flex-direction: column;
+ text-align: center;
+ }
+ .register-form {
+ padding: var(--space-md);
+ }
+ .network-options {
+ flex-wrap: wrap;
+ }
+ .register-actions {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/packages/relay/src/views/layout.tsx b/packages/relay/src/views/layout.tsx
new file mode 100644
index 0000000..ac968da
--- /dev/null
+++ b/packages/relay/src/views/layout.tsx
@@ -0,0 +1,39 @@
+import { sharedStyles } from './shared-styles'
+
+interface LayoutProps {
+ title: string
+ description?: string
+ themeColor?: string
+ extraStyles?: string
+ children?: string | string[]
+}
+
+export const Layout = ({
+ title,
+ description,
+ themeColor = '#1d35cf',
+ extraStyles,
+ children,
+}: LayoutProps) => (
+ <>
+ {''}
+
+
+
+
+
+ {title}
+ {description && }
+
+
+
+
+ {extraStyles && }
+
+ {children}
+
+ >
+)
diff --git a/packages/relay/src/views/nav.tsx b/packages/relay/src/views/nav.tsx
new file mode 100644
index 0000000..6bbc54c
--- /dev/null
+++ b/packages/relay/src/views/nav.tsx
@@ -0,0 +1,26 @@
+export type NavPage = 'dashboard'
+
+interface NavProps {
+ current?: NavPage
+}
+
+export const Nav = ({ current }: NavProps) => (
+
+
+
+)
diff --git a/packages/relay/src/views/shared-styles.ts b/packages/relay/src/views/shared-styles.ts
new file mode 100644
index 0000000..c9d82ca
--- /dev/null
+++ b/packages/relay/src/views/shared-styles.ts
@@ -0,0 +1,283 @@
+// Base CSS tokens + global reset + container + nav styles.
+// Shared across every page served by the relay. The nav CSS here is the canonical
+// copy — landing and dashboard both inherit it, so the navbar is visually identical.
+// Kept as a string so it can be inlined once into — no external file
+// fetch, no FOUC, no drift.
+export const sharedStyles = /* css */ `
+ *,
+ *::before,
+ *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ }
+
+ :root {
+ /* Warm cream surface + cobalt accent + amber signal.
+ Ported from agentcard.402.md; hues shifted from cool 250 to warm 55-75
+ with a saturated cobalt at 265 for accents. */
+ --bg: oklch(0.98 0.008 75);
+ --bg-alt: oklch(0.96 0.012 70);
+ --text: oklch(0.17 0.015 55);
+ --text-2: oklch(0.48 0.008 65);
+ --text-3: oklch(0.55 0.006 65);
+ --border: oklch(0.91 0.008 70);
+ --border-mid: oklch(0.83 0.01 65);
+ --accent: lab(36.3711% 32.2143 -77.8757);
+ --accent-deep: lab(23.9314% 41.9718 -78.2984);
+ --accent-soft: lab(91.5557% 0.535876 -20.4306);
+ --green: oklch(0.42 0.16 145);
+ --green-soft: oklch(0.96 0.025 145);
+ --purple: oklch(0.42 0.18 300);
+ --purple-soft: oklch(0.96 0.025 300);
+ --warn: oklch(0.72 0.14 65);
+ --warn-soft: oklch(0.95 0.05 80);
+ --warn-deep: oklch(0.42 0.16 65);
+ --error: oklch(0.42 0.2 25);
+ --error-soft: oklch(0.96 0.03 20);
+ --signal-deep: lab(44% 22 74);
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 16px;
+ --space-lg: 24px;
+ --space-xl: 32px;
+ --space-2xl: 48px;
+ --radius: 6px;
+ --radius-lg: 10px;
+ --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
+ }
+
+ html {
+ scroll-behavior: smooth;
+ }
+
+ body {
+ font-family: 'Red Hat Text', system-ui, sans-serif;
+ color: var(--text);
+ background: var(--bg);
+ font-size: 0.95rem;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+ }
+
+ body::before {
+ content: '';
+ display: block;
+ height: 2px;
+ background: var(--accent);
+ }
+
+ a {
+ color: var(--accent);
+ text-decoration: none;
+ }
+ a:hover {
+ text-decoration: underline;
+ }
+
+ :focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ .container {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 0 clamp(1rem, 5vw, 2.5rem);
+ }
+
+ /* ══════════════════════════════════════════
+ NAV — canonical source, used by every page
+ ══════════════════════════════════════════ */
+ .nav {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: oklch(0.98 0.008 75 / 0.88);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border-bottom: 1px solid var(--border);
+ height: 56px;
+ transition: box-shadow 200ms var(--ease-out-quart);
+ }
+ .nav.scrolled {
+ box-shadow: 0 1px 12px oklch(0.17 0.015 55 / 0.06);
+ }
+
+ .nav-inner {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 0 clamp(1rem, 5vw, 2.5rem);
+ display: flex;
+ align-items: center;
+ height: 100%;
+ gap: var(--space-lg);
+ }
+
+ .nav-brand {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ font-family: 'JetBrains Mono', monospace;
+ font-weight: 500;
+ font-size: 0.875rem;
+ letter-spacing: -0.02em;
+ color: var(--text);
+ }
+ .nav-brand:hover {
+ text-decoration: none;
+ }
+
+ .nav-links {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ margin-left: auto;
+ font-size: 0.75rem;
+ color: var(--text-2);
+ }
+ .nav-links a {
+ color: inherit;
+ transition: color 150ms;
+ }
+ .nav-links a:hover {
+ color: var(--text);
+ text-decoration: none;
+ }
+ .nav-links a[aria-current='page'] {
+ color: var(--text);
+ font-weight: 500;
+ }
+
+ .nav-cta {
+ display: inline-flex;
+ align-items: center;
+ margin-left: auto;
+ padding: var(--space-sm) var(--space-md);
+ background: var(--accent);
+ color: var(--bg);
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition:
+ background-color 150ms,
+ transform 80ms;
+ }
+ .nav-cta:hover {
+ background: var(--accent-deep);
+ text-decoration: none;
+ }
+ .nav-cta:active {
+ transform: scale(0.97);
+ }
+
+ @media (min-width: 768px) {
+ .nav-links {
+ gap: var(--space-lg);
+ font-size: 0.875rem;
+ }
+ .nav-cta {
+ margin-left: 0;
+ }
+ }
+
+ @media (max-width: 480px) {
+ .nav {
+ height: auto;
+ }
+ .nav-inner {
+ flex-wrap: wrap;
+ padding-top: 10px;
+ padding-bottom: 6px;
+ column-gap: var(--space-sm);
+ row-gap: var(--space-xs);
+ }
+ .nav-cta {
+ margin-left: auto;
+ padding: 6px 12px;
+ font-size: 0.8125rem;
+ }
+ .nav-links {
+ order: 3;
+ width: 100%;
+ margin: 0;
+ gap: var(--space-lg);
+ font-size: 0.75rem;
+ overflow-x: auto;
+ scrollbar-width: none;
+ -webkit-overflow-scrolling: touch;
+ scroll-snap-type: x proximity;
+ -webkit-mask-image: linear-gradient(
+ to right,
+ transparent,
+ black 12px,
+ black calc(100% - 12px),
+ transparent
+ );
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ black 12px,
+ black calc(100% - 12px),
+ transparent
+ );
+ }
+ .nav-links::-webkit-scrollbar {
+ display: none;
+ }
+ .nav-links a {
+ white-space: nowrap;
+ scroll-snap-align: start;
+ padding: 6px 0;
+ }
+ }
+
+ /* Very narrow phones — tighten CTA further so it doesn't crowd the brand. */
+ @media (max-width: 360px) {
+ .nav-cta {
+ padding: 6px 10px;
+ }
+ }
+
+ /* ══════════════════════════════════════════
+ TOUCH TARGETS — enforce 44px on coarse pointers
+ Matches Apple HIG / Material. Desktop mouse users
+ keep the denser default.
+ ══════════════════════════════════════════ */
+ @media (pointer: coarse) {
+ .nav-cta,
+ .nav-links a,
+ .btn-primary,
+ .btn-outline,
+ .register-btn,
+ .network-label,
+ .eco-link,
+ .copy-btn,
+ .window-toggle button,
+ .rank-toggle button {
+ min-height: 44px;
+ }
+ .nav-links a {
+ display: inline-flex;
+ align-items: center;
+ }
+ .copy-btn {
+ display: inline-flex;
+ align-items: center;
+ padding-left: var(--space-md);
+ padding-right: var(--space-md);
+ }
+ }
+`
diff --git a/packages/relay/tsconfig.json b/packages/relay/tsconfig.json
index 15a5e83..c8df646 100644
--- a/packages/relay/tsconfig.json
+++ b/packages/relay/tsconfig.json
@@ -7,6 +7,8 @@
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
+ "jsx": "react-jsx",
+ "jsxImportSource": "@kitajs/html",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts
index a1d3d98..e434cef 100644
--- a/packages/shared/src/db/schema.ts
+++ b/packages/shared/src/db/schema.ts
@@ -82,5 +82,7 @@ export const transactions = pgTable(
index('idx_transactions_seller_date').on(table.sellerId, table.createdAt),
index('idx_transactions_workflow').on(table.workflowId),
index('idx_transactions_buyer').on(table.buyerAddress),
+ index('idx_transactions_buyer_network').on(table.buyerNetwork),
+ index('idx_transactions_seller_network').on(table.sellerNetwork),
],
)
diff --git a/packages/shared/src/networks/index.ts b/packages/shared/src/networks/index.ts
index ffcf96c..818291d 100644
--- a/packages/shared/src/networks/index.ts
+++ b/packages/shared/src/networks/index.ts
@@ -54,6 +54,19 @@ export const networks: ResolvedNetwork[] = enabledChains.map((def) =>
export const supportedCaip2s: string[] = networks.map((n) => n.caip2)
+// Static caip2 → slug lookup over ALL known chains (mainnet + testnet),
+// independent of FACILITATOR_* env gating. Use for env-free concerns like
+// cost comparison where we only need the gas-schedule slug.
+const caip2ToSlug = new Map()
+for (const def of CHAINS) {
+ caip2ToSlug.set(def.mainnet.caip2, def.slug)
+ caip2ToSlug.set(def.testnet.caip2, def.slug)
+}
+
+export function resolveSlugByCaip2(caip2: string): ChainDefinition['slug'] | undefined {
+ return caip2ToSlug.get(caip2)
+}
+
const byCaip2 = new Map()
for (let i = 0; i < networks.length; i++) {
byCaip2.set(networks[i].caip2, { network: networks[i], def: enabledChains[i] })
@@ -114,3 +127,4 @@ export type {
export { UnsupportedNetworkError } from './errors'
export { isChainEnabled, resolveNetworkEnv, validateNetworkEnv } from './env'
export { getCircleIrisUrl, getCircleAttestationUrl } from './cctp'
+export { getGasAllowanceBySlug, calculateFeesBySlug } from './gas-schedule'