Skip to content

Commit b8d1d12

Browse files
authored
Merge pull request #343 from anonfedora/feat/memo-types-middleware-and-examples
features
2 parents a1b6d08 + c4cbefb commit b8d1d12

38 files changed

Lines changed: 1629 additions & 324 deletions

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,22 @@ Places a manage-sell offer on the Stellar DEX. This example creates an offer to
199199
npx ts-node scripts/examples/place_dex_offer.ts
200200
```
201201

202+
### `query_contract.ts` — soroban_query
203+
204+
Performs a read-only query on a Soroban contract without broadcasting. This example calls `get_state` on a deployed escrow contract to verify state after deployment. Pass the contract address via `CONTRACT_ID`:
205+
206+
```bash
207+
CONTRACT_ID=C... npx ts-node scripts/examples/query_contract.ts
208+
```
209+
210+
### `fee_bump.ts` — fee_bump
211+
212+
Wraps a transaction in a fee-bump envelope for sponsored retry flows. This is useful when the agent needs to pay fees on behalf of a transaction signed by a different account. Pass the inner transaction XDR via `INNER_TX_XDR`:
213+
214+
```bash
215+
INNER_TX_XDR=AAAA... npx ts-node scripts/examples/fee_bump.ts
216+
```
217+
202218
---
203219

204220
## E2E Tests

backend/agent.ts

Lines changed: 133 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,26 @@ export interface AgentResult {
127127
correlationId?: string;
128128
}
129129

130+
// ─── Middleware types ─────────────────────────────────────────────────────────────
131+
132+
/**
133+
* Task middleware function type.
134+
*
135+
* Middleware functions are executed in registration order before the task
136+
* is dispatched to the appropriate tool. Each middleware receives the task
137+
* and a `next` function to continue execution. Calling `next()` passes control
138+
* to the next middleware or the actual task execution. If a middleware returns
139+
* a result without calling `next()`, it short-circuits execution.
140+
*
141+
* @param task - The task to be executed
142+
* @param next - Function to call the next middleware or the actual task
143+
* @returns The result of task execution or middleware short-circuit
144+
*/
145+
export type TaskMiddleware = (
146+
task: AgentTask,
147+
next: () => Promise<AgentResult>
148+
) => Promise<AgentResult>;
149+
130150
// ─── Payload sanitisation ─────────────────────────────────────────────────────
131151

132152
const SECRET_KEY_RE = /^(?<prefix>.*?["':\s]?)(?<secret>S[ A-Z2-7]{55})(?<suffix>["'\s]?.*)$/i;
@@ -178,6 +198,9 @@ export class PayFiAgent extends EventEmitter {
178198
// reference — EventEmitter requires identity equality for removal.
179199
private readonly _boundHandlers = new Map<string, (...args: unknown[]) => void>();
180200

201+
// Middleware array for pre/post task execution hooks
202+
private middlewares: TaskMiddleware[] = [];
203+
181204
constructor() {
182205
super();
183206

@@ -337,6 +360,22 @@ export class PayFiAgent extends EventEmitter {
337360
logger.info("Agent draining — rejecting new tasks");
338361
}
339362

363+
/**
364+
* Register a middleware function for pre/post task execution hooks.
365+
*
366+
* Middleware functions are executed in registration order before the task
367+
* is dispatched to the appropriate tool. Each middleware can:
368+
* - Inspect and modify the task
369+
* - Short-circuit execution by returning a result without calling next()
370+
* - Call next() to continue to the next middleware or actual task execution
371+
*
372+
* @param middleware - Middleware function to register
373+
*/
374+
use(middleware: TaskMiddleware): void {
375+
this.middlewares.push(middleware);
376+
logger.info("Middleware registered", { totalMiddlewares: this.middlewares.length });
377+
}
378+
340379
async waitForPendingTasks(): Promise<void> {
341380
if (this.activeTasks === 0 && this.taskQueue.length === 0) return;
342381
logger.info("Waiting for pending tasks to finish", {
@@ -450,74 +489,116 @@ export class PayFiAgent extends EventEmitter {
450489
): Promise<AgentResult> {
451490
this.activeTasks++;
452491
taskLog.info({ taskType: task.type }, "Running task");
453-
try {
454-
let data: unknown;
455-
456-
switch (task.type) {
457-
case "stellar_payment": {
458-
const p = task.payload as Record<string, unknown>;
459-
assertWithinSpendingLimit(p?.amount);
460-
const paymentResult = await this.paymentTool.execute(task.payload);
461-
data = {
462-
...paymentResult,
463-
network: config.STELLAR_NETWORK,
464-
};
465-
break;
466-
}
467492

468-
case "soroban_invoke": {
469-
data = await this.sorobanTool.execute(task.payload);
470-
break;
471-
}
493+
// ── Compose middleware chain ────────────────────────────────────────────────
494+
const executeTask = async (): Promise<AgentResult> => {
495+
try {
496+
let data: unknown;
497+
498+
switch (task.type) {
499+
case "stellar_payment": {
500+
const p = task.payload as Record<string, unknown>;
501+
assertWithinSpendingLimit(p?.amount);
502+
const paymentResult = await this.paymentTool.execute(task.payload);
503+
data = {
504+
...paymentResult,
505+
network: config.STELLAR_NETWORK,
506+
};
507+
break;
508+
}
472509

473-
case "soroban_query":
474-
data = await this.sorobanQueryTool.query(task.payload);
475-
break;
510+
case "soroban_invoke": {
511+
data = await this.sorobanTool.execute(task.payload);
512+
break;
513+
}
476514

477-
case "x402_respond": {
478-
const p = task.payload as Record<string, unknown>;
479-
assertWithinSpendingLimit(p?.amount);
480-
data = await this.x402Tool.respond(task.payload);
481-
break;
482-
}
515+
case "soroban_query":
516+
data = await this.sorobanQueryTool.query(task.payload);
517+
break;
483518

484-
case "account_info":
485-
data = await this.accountInfoTool.fetch();
486-
break;
519+
case "x402_respond": {
520+
const p = task.payload as Record<string, unknown>;
521+
assertWithinSpendingLimit(p?.amount);
522+
data = await this.x402Tool.respond(task.payload);
523+
break;
524+
}
487525

488-
case "change_trust":
489-
data = await this.trustlineTool.execute(task.payload);
490-
break;
526+
case "account_info":
527+
data = await this.accountInfoTool.fetch();
528+
break;
529+
530+
case "change_trust":
531+
data = await this.trustlineTool.execute(task.payload);
532+
break;
491533

492-
case "multisig_payment":
493-
data = await this.multiSigTool.execute(task.payload);
534+
case "multisig_payment":
535+
data = await this.multiSigTool.execute(task.payload);
494536
break;
495537

496538

497-
case "batch_payment":
498-
data = await this.batchPaymentTool.execute(task.payload);
499-
break;
539+
case "batch_payment":
540+
data = await this.batchPaymentTool.execute(task.payload);
541+
break;
500542

501-
case "balance_check":
502-
data = await this.balanceCheckTool.getBalance(task.payload);
503-
break;
543+
case "balance_check":
544+
data = await this.balanceCheckTool.getBalance(task.payload);
545+
break;
504546

505-
case "path_payment":
506-
data = await this.pathPaymentTool.execute(task.payload);
507-
break;
547+
case "path_payment":
548+
data = await this.pathPaymentTool.execute(task.payload);
549+
break;
508550

509-
case "fee_bump":
510-
data = await this.feeBumpTool.execute(task.payload);
511-
break;
551+
case "fee_bump":
552+
data = await this.feeBumpTool.execute(task.payload);
553+
break;
512554

513-
case "dex_offer":
514-
data = await this.dexOfferTool.execute(task.payload);
515-
break;
555+
case "dex_offer":
556+
data = await this.dexOfferTool.execute(task.payload);
557+
break;
516558

517-
default:
518-
throw new Error(`Unknown task type: ${(task as AgentTask).type}`);
559+
default:
560+
throw new Error(`Unknown task type: ${(task as AgentTask).type}`);
561+
}
562+
563+
taskLog.info({ taskType: task.type }, "Task completed");
564+
const result: AgentResult = { success: true, taskType: task.type, data, correlationId };
565+
this.emit("task:complete", result);
566+
567+
saveResult({ ...result, timestamp: new Date().toISOString() });
568+
void dispatchWebhook(result);
569+
570+
return result;
571+
} catch (err) {
572+
const message = err instanceof Error ? err.message : String(err);
573+
const safe = redactSecretString(message);
574+
const sanitized = sanitizePayload(task.payload);
575+
taskLog.error(
576+
{ taskType: task.type, error: safe, sanitizedPayload: sanitized },
577+
"Task failed"
578+
);
579+
const result: AgentResult = {
580+
success: false,
581+
taskType: task.type,
582+
error: safe,
583+
errorType: getErrorType(err),
584+
correlationId,
585+
};
586+
this.emit("task:failed", result);
587+
void dispatchWebhook(result);
588+
return result;
519589
}
590+
};
520591

592+
// Build middleware chain: middleware[n] -> middleware[n-1] -> ... -> executeTask
593+
let chain: () => Promise<AgentResult> = executeTask;
594+
for (let i = this.middlewares.length - 1; i >= 0; i--) {
595+
const middleware = this.middlewares[i];
596+
const next: () => Promise<AgentResult> = chain;
597+
chain = () => middleware(task, next);
598+
}
599+
600+
try {
601+
return await chain();
521602
taskLog.info({ taskType: task.type }, "Task completed");
522603
const result: AgentResult = { success: true, taskType: task.type, data, correlationId };
523604
this.emit("task:complete", result);

backend/tools/PathPaymentTool.ts

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,8 @@ export const PathPaymentInputSchema = z.object({
4242
.regex(/^(?!0(\.0+)?$)\d+(\.\d{1,7})?$/, "destMinAmount must be a valid Stellar decimal")
4343
.refine((v) => parseFloat(v) > 0, "destMinAmount must be greater than zero"),
4444
path: z.array(AssetSchema).optional().default([]),
45-
memo: z
46-
.string()
47-
.refine((v) => Buffer.byteLength(v, "utf8") <= 28, "Memo must be at most 28 bytes")
48-
.optional(),
45+
memoType: z.enum(["text", "id", "hash", "return"]).optional().default("text"),
46+
memo: z.union([z.string(), z.number()]).optional(),
4947
});
5048

5149
export type PathPaymentInput = z.infer<typeof PathPaymentInputSchema>;
@@ -60,6 +58,67 @@ function toAsset(a: { code: string; issuer?: string | undefined }): Asset {
6058
return new Asset(a.code, a.issuer);
6159
}
6260

61+
/**
62+
* Build a Stellar Memo object based on memoType and value.
63+
*
64+
* @param memoType - Type of memo: "text", "id", "hash", or "return"
65+
* @param memoValue - Memo value (string for text/return/hash, number for id)
66+
* @returns Memo instance or null if memoValue is undefined
67+
*/
68+
function buildMemo(memoType: string, memoValue: string | number | undefined): Memo | null {
69+
if (memoValue === undefined) {
70+
return null;
71+
}
72+
73+
switch (memoType) {
74+
case "id":
75+
if (typeof memoValue !== "number") {
76+
throw new Error("Memo ID must be a number");
77+
}
78+
// Convert to unsigned 64-bit integer
79+
const id = BigInt(memoValue);
80+
if (id < 0n || id > 18446744073709551615n) {
81+
throw new Error("Memo ID must be a 64-bit unsigned integer (0 to 2^64-1)");
82+
}
83+
return Memo.id(id.toString());
84+
case "hash":
85+
if (typeof memoValue !== "string") {
86+
throw new Error("Memo hash must be a string");
87+
}
88+
// Remove 0x prefix if present and validate length
89+
const hashHex = memoValue.replace(/^0x/, "");
90+
if (hashHex.length !== 64) {
91+
throw new Error("Memo hash must be a 32-byte hex string (64 hex characters)");
92+
}
93+
if (!/^[0-9a-fA-F]{64}$/.test(hashHex)) {
94+
throw new Error("Memo hash must contain only valid hex characters");
95+
}
96+
return Memo.hash(hashHex);
97+
case "return":
98+
if (typeof memoValue !== "string") {
99+
throw new Error("Memo return must be a string");
100+
}
101+
// Remove 0x prefix if present and validate length
102+
const returnHex = memoValue.replace(/^0x/, "");
103+
if (returnHex.length !== 64) {
104+
throw new Error("Memo return must be a 32-byte hex string (64 hex characters)");
105+
}
106+
if (!/^[0-9a-fA-F]{64}$/.test(returnHex)) {
107+
throw new Error("Memo return must contain only valid hex characters");
108+
}
109+
return Memo.return(returnHex);
110+
case "text":
111+
default:
112+
if (typeof memoValue !== "string") {
113+
throw new Error("Memo text must be a string");
114+
}
115+
if (Buffer.byteLength(memoValue, "utf8") > 28) {
116+
throw new Error("Memo text must be at most 28 bytes");
117+
}
118+
return Memo.text(memoValue);
119+
}
120+
}
121+
63122
// ─── Tool implementation ──────────────────────────────────────────────────────
64123

65124
export class PathPaymentTool {
@@ -103,8 +162,11 @@ export class PathPaymentTool {
103162
})
104163
);
105164

106-
if (input.memo) {
107-
builder.addMemo(Memo.text(input.memo));
165+
if (input.memo !== undefined) {
166+
const memo = buildMemo(input.memoType, input.memo);
167+
if (memo) {
168+
builder.addMemo(memo);
169+
}
108170
}
109171

110172
return builder.setTimeout(30).build();

0 commit comments

Comments
 (0)