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
23 changes: 23 additions & 0 deletions src/featureDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,26 @@ export async function detectContractFeatures(

return features;
}

// === Fee-bump support

/**
* Fee-bump transactions (CAP-0015) were introduced in Stellar protocol
* version 13. Networks on an older base protocol cannot process them.
*/
export const FEE_BUMP_MIN_PROTOCOL_VERSION = 13;

/** Minimal network info needed to decide fee-bump availability. */
export interface FeeBumpNetworkInfo {
/** The network's current base protocol version. */
protocolVersion: number;
}

/**
* Determine whether a network supports fee-bump transactions, based on its
* base protocol version. No network calls are made; pass a network info object
* (e.g. from Horizon's root endpoint or an RPC `getNetwork` response).
*/
export function detectFeeBumpSupport(networkInfo: FeeBumpNetworkInfo): boolean {
return networkInfo.protocolVersion >= FEE_BUMP_MIN_PROTOCOL_VERSION;
}
42 changes: 39 additions & 3 deletions src/flowVisualizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { InvoiceFlowFetcherNotRegisteredError } from "./errors.js";

export type InvoiceFlowFetcher = (invoiceId: string) => Promise<Invoice>;

export type FlowDiagramMode = "mermaid" | "ascii";

export interface FlowDiagramOptions {
/** Output format. Defaults to "mermaid" (unchanged legacy behavior). */
mode?: FlowDiagramMode;
}

let invoiceFlowFetcher: InvoiceFlowFetcher | null = null;

export function registerInvoiceFlowFetcher(fetcher: InvoiceFlowFetcher): void {
Expand Down Expand Up @@ -37,19 +44,29 @@ function allocatePayments(recipients: Recipient[], funded: bigint): Map<string,

export async function generateFlowDiagram(
invoiceId: string,
getInvoice?: InvoiceFlowFetcher
getInvoice?: InvoiceFlowFetcher,
options?: FlowDiagramOptions
): Promise<string> {
const fetcher = getInvoice ?? invoiceFlowFetcher;
if (!fetcher) {
throw new InvoiceFlowFetcherNotRegisteredError();
}

const invoice = await fetcher(invoiceId);
const creatorId = nodeId("creator", invoice.creator);
const invoiceNodeId = nodeId("invoice", invoice.id);
const totalPaid = invoice.payments.reduce((sum, payment) => sum + payment.amount, 0n);
const funded = totalPaid > invoice.funded ? totalPaid : invoice.funded;
const allocations = allocatePayments(invoice.recipients, funded);

if (options?.mode === "ascii") {
return renderAsciiDiagram(invoice, allocations);
}

return renderMermaidDiagram(invoice, allocations);
}

function renderMermaidDiagram(invoice: Invoice, allocations: Map<string, bigint>): string {
const creatorId = nodeId("creator", invoice.creator);
const invoiceNodeId = nodeId("invoice", invoice.id);
const lines = [
"flowchart LR",
` ${creatorId}["Creator: ${nodeLabel(invoice.creator)}"]`,
Expand Down Expand Up @@ -86,3 +103,22 @@ export async function generateFlowDiagram(

return lines.join("\n");
}

// === ASCII rendering

function renderAsciiDiagram(invoice: Invoice, allocations: Map<string, bigint>): string {
const lines = [
`[Creator: ${invoice.creator}]`,
` --> [Invoice ${invoice.id}]`,
];

for (const [index, recipient] of invoice.recipients.entries()) {
const paid = allocations.get(recipient.address) ?? 0n;
const status = paid >= recipient.amount ? "completed" : "pending";
lines.push(
` --> [Recipient ${index + 1}: ${recipient.address}] (${amountLabel(paid)} / ${amountLabel(recipient.amount)}) ${status}`
);
}

return lines.join("\n");
}
13 changes: 13 additions & 0 deletions test/featureDetection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
detectContractFeatures,
clearFeatureCache,
detectFeeBumpSupport,
} from "../src/featureDetection.js";
import type { ContractFeatures } from "../src/types.js";

Expand Down Expand Up @@ -173,4 +174,16 @@ describe("detectContractFeatures", () => {
// archival (5th, odd = ok) -> true
expect(features.archival).toBe(true);
});
});

describe("detectFeeBumpSupport", () => {
it("returns true for a network whose base protocol supports fee bumps", () => {
expect(detectFeeBumpSupport({ protocolVersion: 13 })).toBe(true);
expect(detectFeeBumpSupport({ protocolVersion: 21 })).toBe(true);
});

it("returns false for a network on a protocol older than fee-bump support", () => {
expect(detectFeeBumpSupport({ protocolVersion: 12 })).toBe(false);
expect(detectFeeBumpSupport({ protocolVersion: 10 })).toBe(false);
});
});
43 changes: 43 additions & 0 deletions test/flowVisualizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,47 @@ describe("generateFlowDiagram", () => {
expect(diagram).toContain("class recipient_1_GRECIPIENTA completed");
expect(diagram).toContain("class recipient_2_GRECIPIENTB pending");
});

it("renders a multi-line ASCII diagram with [label] nodes and --> edges when mode is 'ascii'", async () => {
const invoice: Invoice = {
id: "inv-1",
creator: "GCREATOR",
recipients: [
{ address: "GRECIPIENTA", amount: 100n },
{ address: "GRECIPIENTB", amount: 50n },
],
token: "CUSDC",
deadline: 1_900_000_000,
funded: 120n,
status: "Pending",
payments: [{ payer: "GPAYER", amount: 120n }],
};

const diagram = await generateFlowDiagram("inv-1", async () => invoice, {
mode: "ascii",
});

expect(diagram.split("\n").length).toBeGreaterThan(1);
expect(diagram).toContain("[Creator: GCREATOR]");
expect(diagram).toContain("--> [Invoice inv-1]");
expect(diagram).toContain("--> [Recipient 1: GRECIPIENTA] (100 / 100) completed");
expect(diagram).toContain("--> [Recipient 2: GRECIPIENTB] (20 / 50) pending");
expect(diagram).not.toContain("flowchart LR");
});

it("defaults to Mermaid output when no mode is given", async () => {
const invoice: Invoice = {
id: "inv-1",
creator: "GCREATOR",
recipients: [{ address: "GRECIPIENTA", amount: 100n }],
token: "CUSDC",
deadline: 1_900_000_000,
funded: 100n,
status: "Pending",
payments: [{ payer: "GPAYER", amount: 100n }],
};

const diagram = await generateFlowDiagram("inv-1", async () => invoice);
expect(diagram).toContain("flowchart LR");
});
});