Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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