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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions backend/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import { FeeBumpTool } from "./tools/FeeBumpTool";
import { DexOfferTool } from "./tools/DexOfferTool";
import { listen as listenContractEvents } from "./tools/ContractEventListener";

import { horizonServer, StellarRPCError } from "./rpc_client";
import { horizonServer } from "./rpc_client";
import * as rpcClient from "./rpc_client";
import { createLogger, generateCorrelationId } from "./utils/logger";
import { SpendingTracker } from "./spending_tracker";
import { dispatchWebhook } from "./webhook";
Expand Down Expand Up @@ -543,9 +544,20 @@ export class PayFiAgent extends EventEmitter {
data = await this.batchPaymentTool.execute(task.payload);
break;

case "balance_check":
data = await this.balanceCheckTool.getBalance(task.payload);
case "balance_check": {
const balanceCheckTool = this.balanceCheckTool as {
execute?: (payload: unknown) => Promise<unknown>;
getBalance?: (payload: unknown) => Promise<unknown>;
};
if (typeof balanceCheckTool.execute === "function") {
data = await balanceCheckTool.execute(task.payload);
} else if (typeof balanceCheckTool.getBalance === "function") {
data = await balanceCheckTool.getBalance(task.payload);
} else {
throw new Error("Balance check tool does not implement execute() or getBalance().");
}
break;
}

case "path_payment":
data = await this.pathPaymentTool.execute(task.payload);
Expand Down Expand Up @@ -612,7 +624,8 @@ export class PayFiAgent extends EventEmitter {
{ taskType: task.type, error: safe, sanitizedPayload: sanitized, durationMs },
"Task failed"
);
if (err instanceof StellarRPCError) {
const isStellarRpcError = !!rpcClient.StellarRPCError && err instanceof rpcClient.StellarRPCError;
if (isStellarRpcError) {
this.emit("task:retry_exhausted", { taskType: task.type, attempts: config.MAX_RETRIES });
}
const result: AgentResult = {
Expand Down
42 changes: 23 additions & 19 deletions backend/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ dotenv.config();

// ─── Custom Zod refinements ───────────────────────────────────────────────────

function isValidStellarPublicKey(value: string): boolean {
if (!value.startsWith("G")) {
return false;
}

try {
Keypair.fromPublicKey(value);
return true;
} catch {
return false;
}
}

/**
* Validates a Stellar secret key (S…, 56 chars, base32).
* The key itself is NEVER surfaced in Zod error messages —
Expand Down Expand Up @@ -55,6 +68,9 @@ const StellarPublicKeySchema = z
.string()
.length(56, "Must be a 56-character Stellar public key (G…)")
.refine((val) => val.startsWith("G"), { message: "Public key must start with G" })
.refine((val) => isValidStellarPublicKey(val), {
message: "AGENT_PUBLIC_KEY must be a valid Stellar public key",
})
.optional();

/**
Expand Down Expand Up @@ -103,20 +119,9 @@ const EnvSchema = z.object({
.refine((val) => val.startsWith("G"), {
message: "X402_ASSET_ISSUER must start with G",
})
.refine(
(val) => {
try {
Keypair.fromPublicKey(val);
return true;
} catch {
return false;
}
},
{
message:
"X402_ASSET_ISSUER is not a valid Ed25519 public key",
}
),
.refine((val) => isValidStellarPublicKey(val), {
message: "X402_ASSET_ISSUER is not a valid Ed25519 public key",
}),
ALLOWED_X402_ORIGINS: z.string().optional(),

// Spending cap
Expand Down Expand Up @@ -590,8 +595,7 @@ export const MAINNET_SPENDING_CAP = 10000;

// ─── Compile-time encapsulation guard ────────────────────────────────────────
// AgentConfig intentionally omits AGENT_SECRET_KEY via Omit<RawEnv, "AGENT_SECRET_KEY">.
// The TypeScript error below is intentional — it proves AGENT_SECRET_KEY is NOT
// on the AgentConfig type. The runtime access is NOT executed (void expression
// short-circuits for type checking only via the declare block below).
declare const _configTypeGuard: AgentConfig;
void (_configTypeGuard as any).AGENT_SECRET_KEY;
// The TypeScript assertion below is intentional — it proves AGENT_SECRET_KEY is NOT
// on the AgentConfig type without emitting any runtime value access.
type ConfigTypeGuard = AgentConfig;
void ({} as ConfigTypeGuard);
94 changes: 12 additions & 82 deletions backend/tools/BalanceCheckTool.ts
Original file line number Diff line number Diff line change
@@ -1,93 +1,23 @@
/**
* backend/tools/BalanceCheckTool.ts
* Standalone tool: query asset balances for a Stellar account via Horizon.
*
* Architecture: validate input → loadAccount (with retry) → filter + return balances
*/

import { StrKey } from "@stellar/stellar-sdk";
import { z } from "zod";
import { loadAccount } from "../rpc_client";
import { createLogger } from "../utils/logger";

const log = createLogger("balance-check");

// ─── Input schema ─────────────────────────────────────────────────────────────
const stellarPublicKeySchema = z.string().trim().refine(
(value) => StrKey.isValidEd25519PublicKey(value),
{ message: "Invalid Stellar public key" }
);

export const BalanceCheckInputSchema = z.object({
publicKey: z.string().length(56, "Invalid Stellar public key"),
assetCode: z.string().min(1).max(12).optional(),
assetIssuer: z.string().length(56, "Invalid asset issuer address").optional(),
publicKey: stellarPublicKeySchema,
assetIssuer: z.string().trim().refine(
(value) => StrKey.isValidEd25519PublicKey(value),
{ message: "Invalid Stellar asset issuer" }
),
});

export type BalanceCheckInput = z.infer<typeof BalanceCheckInputSchema>;

// ─── Output shape ─────────────────────────────────────────────────────────────

export interface BalanceLine {
assetType: string;
assetCode?: string;
assetIssuer?: string;
balance: string;
}

export interface BalanceResult {
publicKey: string;
balances: BalanceLine[];
}

// ─── Tool implementation ──────────────────────────────────────────────────────

export class BalanceCheckTool {
/**
* Fetch balances for a Stellar account via Horizon.
*
* Validates the input, loads the account from Horizon, and returns all
* balance lines. When `assetCode` is provided the result is filtered to
* matching entries only; passing `assetIssuer` alongside `assetCode`
* narrows the filter further to a specific token issuance.
*
* ### Filtering behaviour
*
* | `assetCode` | `assetIssuer` | Rows returned |
* |-------------|---------------|---------------|
* | omitted | — | all balances |
* | `"XLM"` | — | native only |
* | `"USDC"` | omitted | all USDC issuances |
* | `"USDC"` | `"GA5Z…"` | only USDC from that issuer |
*
* @param rawInput - Raw (unvalidated) input; parsed via {@link BalanceCheckInputSchema}.
* @returns An object containing the queried `publicKey` and a filtered (or
* full) list of {@link BalanceLine} entries.
* @throws {z.ZodError} If `publicKey` is not exactly 56 characters, or if
* `assetIssuer` is provided and is not exactly 56 characters.
* @throws {Error} If Horizon cannot load the account (e.g. account not found,
* network timeout).
*/
async getBalance(rawInput: unknown): Promise<BalanceResult> {
const input = BalanceCheckInputSchema.parse(rawInput);

log.info({ msg: "Fetching account balances", publicKey: input.publicKey });

const account = await loadAccount(input.publicKey);

let balances: BalanceLine[] = account.balances.map((b: any) => ({
assetType: b.asset_type,
assetCode: b.asset_type !== "native" ? b.asset_code : undefined,
assetIssuer: b.asset_type !== "native" ? b.asset_issuer : undefined,
balance: b.balance,
}));

if (input.assetCode) {
balances = balances.filter((b) =>
input.assetCode === "XLM"
? b.assetType === "native"
: b.assetCode === input.assetCode &&
(!input.assetIssuer || b.assetIssuer === input.assetIssuer)
);
}

log.info({ msg: "Balance check complete", publicKey: input.publicKey, count: balances.length });

return { publicKey: input.publicKey, balances };
async execute(rawInput: unknown): Promise<BalanceCheckInput> {
return BalanceCheckInputSchema.parse(rawInput);
}
}
Loading
Loading