Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/facilitator-server/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ services:
# Optional SVM support
# - SVM_PRIVATE_KEY=${SVM_PRIVATE_KEY}
# - SVM_NETWORKS=${SVM_NETWORKS:-solana-devnet}
# Optional Starknet support (opt-in, disabled by default)
# - STARKNET_NETWORKS=${STARKNET_NETWORKS:-starknet-mainnet}
# - STARKNET_RPC_URL_STARKNET_MAINNET=${STARKNET_RPC_URL_STARKNET_MAINNET}
# - STARKNET_PAYMASTER_ENDPOINT_STARKNET_MAINNET=${STARKNET_PAYMASTER_ENDPOINT_STARKNET_MAINNET}
# - STARKNET_PAYMASTER_API_KEY=${STARKNET_PAYMASTER_API_KEY}
# - STARKNET_SPONSOR_ADDRESS_STARKNET_MAINNET=${STARKNET_SPONSOR_ADDRESS_STARKNET_MAINNET}
# Optional RPC API keys (for better rate limits)
# - ALCHEMY_API_KEY=${ALCHEMY_API_KEY}
# - INFURA_API_KEY=${INFURA_API_KEY}
Expand Down
5 changes: 5 additions & 0 deletions examples/facilitator-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
* - CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET: For CDP signer
* - EVM_PRIVATE_KEY, SVM_PRIVATE_KEY: For private key signer (fallback)
* - EVM_RPC_URL_BASE, EVM_RPC_URL_BASE_SEPOLIA: RPC URLs
* - STARKNET_NETWORKS: Comma-separated Starknet networks to enable (opt-in, default: disabled)
* - STARKNET_RPC_URL_STARKNET_MAINNET, STARKNET_RPC_URL_STARKNET_SEPOLIA: Starknet RPC URLs
* - STARKNET_PAYMASTER_ENDPOINT_STARKNET_MAINNET, STARKNET_PAYMASTER_ENDPOINT_STARKNET_SEPOLIA: Starknet paymaster endpoints
* - STARKNET_PAYMASTER_API_KEY, STARKNET_PAYMASTER_API_KEY_STARKNET_MAINNET, STARKNET_PAYMASTER_API_KEY_STARKNET_SEPOLIA: Paymaster API key(s)
* - STARKNET_SPONSOR_ADDRESS, STARKNET_SPONSOR_ADDRESS_STARKNET_MAINNET, STARKNET_SPONSOR_ADDRESS_STARKNET_SEPOLIA: Sponsor address(es), required for enabled Starknet networks
* - UPTO_VERIFY_BALANCE_CHECK: "true" to enforce on-chain balance preflight in /verify for upto
* - BEARER_TOKEN: Required bearer token for /verify and /settle
* - BEARER_TOKENS: Optional comma-separated bearer token list (overrides BEARER_TOKEN)
Expand Down
33 changes: 5 additions & 28 deletions examples/facilitator-server/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import {
SVM_PRIVATE_KEY,
CDP_ACCOUNT_NAME,
} from "@daydreamsai/facilitator/config";
import {
buildStarknetConfigs,
type StarknetConfig,
} from "./starknet-config.js";

type EvmSignerConfig = FacilitatorConfig["evmSigners"] extends
| (infer T)[]
Expand All @@ -41,11 +45,6 @@ type SvmSignerConfig = FacilitatorConfig["svmSigners"] extends
| undefined
? T
: never;
type StarknetConfig = FacilitatorConfig["starknetConfigs"] extends
| (infer T)[]
| undefined
? T
: never;
type NetworkId = EvmSignerConfig["networks"];
const UPTO_VERIFY_BALANCE_CHECK =
process.env.UPTO_VERIFY_BALANCE_CHECK === "true";
Expand All @@ -60,29 +59,7 @@ async function createDefaultSigners(): Promise<{
starknetConfigs: StarknetConfig[];
}> {
const networkSetups = getNetworkSetups();
const starknetNetworkSetups = getStarknetNetworkSetups();

const starknetConfigs: StarknetConfig[] = [];
for (const network of starknetNetworkSetups) {
if (!network.rpcUrl) {
console.warn(`⚠️ No RPC URL for ${network.name} - skipping`);
continue;
}
if (!network.paymasterEndpoint) {
console.warn(`⚠️ No paymaster endpoint for ${network.name} - skipping`);
continue;
}

starknetConfigs.push({
network: network.caip as StarknetConfig["network"],
rpcUrl: network.rpcUrl,
paymasterEndpoint: network.paymasterEndpoint,
...(network.paymasterApiKey
? { paymasterApiKey: network.paymasterApiKey }
: {}),
sponsorAddress: network.sponsorAddress,
});
}
const starknetConfigs = buildStarknetConfigs(getStarknetNetworkSetups());

if (USE_CDP) {
// CDP Signer (preferred)
Expand Down
41 changes: 41 additions & 0 deletions examples/facilitator-server/src/starknet-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { FacilitatorConfig } from "@daydreamsai/facilitator";
import type { StarknetNetworkSetup } from "@daydreamsai/facilitator/config";

export type StarknetConfig = FacilitatorConfig["starknetConfigs"] extends
| (infer T)[]
| undefined
? T
: never;

/**
* Converts validated Starknet network setup entries into facilitator configs.
* Networks with unresolved runtime dependencies are skipped defensively.
*/
export function buildStarknetConfigs(
starknetNetworkSetups: StarknetNetworkSetup[]
): StarknetConfig[] {
const starknetConfigs: StarknetConfig[] = [];

for (const network of starknetNetworkSetups) {
if (!network.rpcUrl) {
console.warn(`⚠️ No RPC URL for ${network.name} - skipping`);
continue;
}
if (!network.paymasterEndpoint) {
console.warn(`⚠️ No paymaster endpoint for ${network.name} - skipping`);
continue;
}

starknetConfigs.push({
network: network.caip as StarknetConfig["network"],
rpcUrl: network.rpcUrl,
paymasterEndpoint: network.paymasterEndpoint,
...(network.paymasterApiKey
? { paymasterApiKey: network.paymasterApiKey }
: {}),
sponsorAddress: network.sponsorAddress,
});
}

return starknetConfigs;
}
60 changes: 60 additions & 0 deletions examples/facilitator-server/tests/e2e-ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ const serverDir = resolve(testsDir, "..");
const DATABASE_URL = process.env.DATABASE_URL;
const PORT = Number(process.env.PORT ?? "18090");
const BEARER_TOKEN = process.env.BEARER_TOKEN ?? "e2e-test-token";
const STARKNET_NETWORKS = "starknet-mainnet";
const STARKNET_MAINNET_CAIP = "starknet:SN_MAIN";
const STARKNET_MAINNET_RPC_URL = "https://starknet-mainnet.example.com";
const STARKNET_MAINNET_PAYMASTER_ENDPOINT =
"https://starknet.paymaster.avnu.fi";
const STARKNET_MAINNET_SPONSOR = "0xstarknet-mainnet-sponsor";

if (!DATABASE_URL) {
throw new Error("DATABASE_URL is required for e2e test");
Expand Down Expand Up @@ -74,6 +80,46 @@ async function waitForRecord(pool: Pool, timeoutMs = 15_000): Promise<void> {
throw new Error("No /verify tracking row found in Postgres");
}

function assertStarknetSupportedPayload(payload: unknown): void {
const supported = payload as {
kinds?: Array<{
network?: string;
scheme?: string;
x402Version?: number;
extra?: { paymasterEndpoint?: string; sponsorAddress?: string };
}>;
signers?: Record<string, string[]>;
};

const starknetKind = supported.kinds?.find(
(kind) =>
kind.network === STARKNET_MAINNET_CAIP && kind.scheme === "exact"
);

assert.ok(starknetKind, "Expected /supported to include Starknet exact kind");
assert.equal(
starknetKind?.x402Version,
2,
"Expected Starknet supported kind to be x402 v2"
);
assert.equal(
starknetKind?.extra?.paymasterEndpoint,
STARKNET_MAINNET_PAYMASTER_ENDPOINT,
"Expected Starknet paymaster endpoint in /supported extra metadata"
);
assert.equal(
starknetKind?.extra?.sponsorAddress,
STARKNET_MAINNET_SPONSOR,
"Expected Starknet sponsor address in /supported extra metadata"
);

const starknetSigners = supported.signers?.["starknet:*"] ?? [];
assert.ok(
starknetSigners.includes(STARKNET_MAINNET_SPONSOR),
"Expected Starknet sponsor address in /supported signers"
);
}

async function run(): Promise<void> {
const baseUrl = `http://127.0.0.1:${PORT}`;
const pool = new Pool({ connectionString: DATABASE_URL });
Expand All @@ -97,6 +143,11 @@ async function run(): Promise<void> {
BEARER_TOKEN,
EVM_PRIVATE_KEY: privateKey,
EVM_NETWORKS: process.env.EVM_NETWORKS ?? "base-sepolia",
STARKNET_NETWORKS,
STARKNET_RPC_URL_STARKNET_MAINNET: STARKNET_MAINNET_RPC_URL,
STARKNET_PAYMASTER_ENDPOINT_STARKNET_MAINNET:
STARKNET_MAINNET_PAYMASTER_ENDPOINT,
STARKNET_SPONSOR_ADDRESS_STARKNET_MAINNET: STARKNET_MAINNET_SPONSOR,
},
stdout: "pipe",
stderr: "pipe",
Expand All @@ -109,6 +160,15 @@ async function run(): Promise<void> {
await waitForServer(baseUrl);
await pool.query("TRUNCATE TABLE resource_call_records");

const supportedResponse = await fetch(`${baseUrl}/supported`);
assert.equal(
supportedResponse.status,
200,
"Expected /supported to return 200"
);
const supportedPayload = await supportedResponse.json();
assertStarknetSupportedPayload(supportedPayload);

const verifyResponse = await fetch(`${baseUrl}/verify`, {
method: "POST",
headers: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from "bun:test";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildStarknetConfigs } from "../src/starknet-config.js";

const __filename = fileURLToPath(import.meta.url);
const testsDir = resolve(__filename, "..");
const serverDir = resolve(testsDir, "..");

const E2E_PRIVATE_KEY =
"0x0000000000000000000000000000000000000000000000000000000000000001";

describe("Starknet setup validation", () => {
it("fails fast with an actionable error when sponsor is missing", () => {
const processResult = Bun.spawnSync({
cmd: ["bun", "-e", 'import "./src/setup.ts";'],
cwd: serverDir,
env: {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
EVM_PRIVATE_KEY: E2E_PRIVATE_KEY,
EVM_NETWORKS: "base-sepolia",
STARKNET_NETWORKS: "starknet-mainnet",
STARKNET_RPC_URL_STARKNET_MAINNET: "https://starknet-mainnet.example.com",
},
stdout: "pipe",
stderr: "pipe",
});

const stdout = new TextDecoder().decode(processResult.stdout);
const stderr = new TextDecoder().decode(processResult.stderr);
const combinedOutput = `${stdout}\n${stderr}`;

expect(processResult.exitCode).not.toBe(0);
expect(combinedOutput).toContain(
"Missing Starknet sponsor address for starknet-mainnet"
);
expect(combinedOutput).toContain(
"STARKNET_SPONSOR_ADDRESS_STARKNET_MAINNET"
);
});

it("skips Starknet networks with unresolved RPC URLs", () => {
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args.map((value) => String(value)).join(" "));
};

try {
const configs = buildStarknetConfigs([
{
name: "starknet-mainnet",
caip: "starknet:SN_MAIN",
rpcUrl: undefined,
paymasterEndpoint: "https://starknet.paymaster.avnu.fi",
paymasterApiKey: "api-key",
sponsorAddress: "0xmainnet-sponsor",
},
]);

expect(configs).toEqual([]);
expect(warnings).toContain("⚠️ No RPC URL for starknet-mainnet - skipping");
} finally {
console.warn = originalWarn;
}
});

it("skips Starknet networks with unresolved paymaster endpoints", () => {
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args.map((value) => String(value)).join(" "));
};

try {
const configs = buildStarknetConfigs([
{
name: "starknet-sepolia",
caip: "starknet:SN_SEPOLIA",
rpcUrl: "https://starknet-sepolia.example.com",
paymasterEndpoint: undefined,
paymasterApiKey: "api-key",
sponsorAddress: "0xsepolia-sponsor",
},
]);

expect(configs).toEqual([]);
expect(warnings).toContain(
"⚠️ No paymaster endpoint for starknet-sepolia - skipping"
);
} finally {
console.warn = originalWarn;
}
});
});
Loading