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
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ export type {
ResourceDelta,
} from "./simulationDiff.js";

// Payment velocity tracking
export { trackVelocity } from "./velocityTracker.js";
export type { VelocityReport, InvoiceVelocity, PaymentTrend } from "./velocityTracker.js";
export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js";
export type { Sep41TokenCapabilities } from "./sep41Adapter.js";

Expand Down
140 changes: 140 additions & 0 deletions src/velocityTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { StellarSplitClient } from "./client.js";

/** Trend classification for invoice payment velocity. */
export type PaymentTrend = "accelerating" | "steady" | "stalling";

/** Details for a single invoice's payment velocity. */
export interface InvoiceVelocity {
invoiceId: string;
paymentsPerDay: number;
trend: PaymentTrend;
}

/** Report on payment velocity across all invoices for an address. */
export interface VelocityReport {
address: string;
invoices: InvoiceVelocity[];
}

/**
* Analyze payment velocity for all invoices created by an address.
*
* Fetches all invoices for the given creator address and computes:
* - Payment rate (payments per day)
* - Trend classification based on first-half vs second-half payment rates
*
* @param address - Stellar address of the invoice creator
* @param client - StellarSplitClient instance
* @returns Report containing velocity metrics for each invoice
*/
export async function trackVelocity(
address: string,
client: StellarSplitClient
): Promise<VelocityReport> {
const invoices: InvoiceVelocity[] = [];
let cursor: string | null = null;

// Fetch all invoices created by this address
while (true) {
const result = await client.getInvoicesByCreator(address, {
cursor: cursor ?? undefined,
limit: 50,
});

for (const invoiceId of result.items) {
const invoice = await client.getInvoice(invoiceId);

if (invoice.payments.length === 0) {
invoices.push({
invoiceId,
paymentsPerDay: 0,
trend: "steady",
});
continue;
}

const paymentsPerDay = calculatePaymentsPerDay(invoice.payments);
const trend = classifyTrend(invoice.payments);

invoices.push({
invoiceId,
paymentsPerDay,
trend,
});
}

if (!result.nextCursor) break;
cursor = result.nextCursor;
}

return { address, invoices };
}

/**
* Calculate the average payment rate (payments per day) from payment timestamps.
*/
function calculatePaymentsPerDay(payments: Array<{ timestamp?: number }>): number {
const withTimestamps = payments.filter((p) => p.timestamp !== undefined);

if (withTimestamps.length < 2) {
return 0;
}

const timestamps = withTimestamps.map((p) => p.timestamp!).sort((a, b) => a - b);
const first = timestamps[0]!;
const last = timestamps[timestamps.length - 1]!;

const daysElapsed = (last - first) / (24 * 3600);
if (daysElapsed === 0) return 0;

return withTimestamps.length / daysElapsed;
}

/**
* Classify payment trend by comparing first-half vs second-half payment rates.
*/
function classifyTrend(payments: Array<{ timestamp?: number }>): PaymentTrend {
const withTimestamps = payments.filter((p) => p.timestamp !== undefined);

if (withTimestamps.length < 2) {
return "steady";
}

const timestamps = withTimestamps.map((p) => p.timestamp!).sort((a, b) => a - b);
const midpoint = Math.floor(timestamps.length / 2);

const firstHalf = timestamps.slice(0, midpoint);
const secondHalf = timestamps.slice(midpoint);

if (firstHalf.length === 0 || secondHalf.length === 0) {
return "steady";
}

const firstHalfRate = calculateRate(firstHalf);
const secondHalfRate = calculateRate(secondHalf);

// If rates differ by less than 20%, consider it steady
const threshold = 0.2;
const rateDifference = Math.abs(secondHalfRate - firstHalfRate) / Math.max(firstHalfRate, 1);

if (rateDifference < threshold) {
return "steady";
}

return secondHalfRate > firstHalfRate ? "accelerating" : "stalling";
}

/**
* Calculate payment rate (payments per day) for a subset of timestamps.
*/
function calculateRate(timestamps: number[]): number {
if (timestamps.length < 2) return 0;

const first = timestamps[0]!;
const last = timestamps[timestamps.length - 1]!;

const daysElapsed = (last - first) / (24 * 3600);
if (daysElapsed === 0) return 0;

return timestamps.length / daysElapsed;
}
166 changes: 166 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,3 +956,169 @@ describe("resolveCloneChain", () => {
await expect(client.resolveCloneChain("x")).rejects.toThrow("clone chain depth exceeded");
});
});

describe("trackVelocity", () => {
it("calculates payments per day from payment timestamps", async () => {
const { trackVelocity } = await import("../src/velocityTracker.js");

const client = new StellarSplitClient({
rpcUrl: "https://example.com",
networkPassphrase: "Test Network",
contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()),
});

const now = Math.floor(Date.now() / 1000);
const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";

vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({
items: ["inv1"],
nextCursor: null,
total: 1,
});

vi.spyOn(client, "getInvoice").mockResolvedValue({
id: "inv1",
creator: creatorAddr,
recipients: [],
token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
deadline: now + 86_400,
funded: 1_000_000n,
status: "Pending" as const,
payments: [
{ payer: "GPAYER1", amount: 100_000n, timestamp: now },
{ payer: "GPAYER2", amount: 100_000n, timestamp: now + 43_200 }, // 12 hours later
{ payer: "GPAYER3", amount: 100_000n, timestamp: now + 86_400 }, // 1 day later
],
} as any);

const report = await trackVelocity(creatorAddr, client);

expect(report.address).toBe(creatorAddr);
expect(report.invoices).toHaveLength(1);
expect(report.invoices[0]!.invoiceId).toBe("inv1");
expect(report.invoices[0]!.paymentsPerDay).toBeGreaterThan(0);
expect(report.invoices[0]!.paymentsPerDay).toBeLessThan(10);
});

it("classifies stalling trend for decreasing payment rate", async () => {
const { trackVelocity } = await import("../src/velocityTracker.js");

const client = new StellarSplitClient({
rpcUrl: "https://example.com",
networkPassphrase: "Test Network",
contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()),
});

const now = Math.floor(Date.now() / 1000);
const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";

vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({
items: ["inv1"],
nextCursor: null,
total: 1,
});

// Payments concentrated early (stalling pattern)
vi.spyOn(client, "getInvoice").mockResolvedValue({
id: "inv1",
creator: creatorAddr,
recipients: [],
token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
deadline: now + 864_000,
funded: 1_000_000n,
status: "Pending" as const,
payments: [
{ payer: "GPAYER1", amount: 100_000n, timestamp: now },
{ payer: "GPAYER2", amount: 100_000n, timestamp: now + 3_600 },
{ payer: "GPAYER3", amount: 100_000n, timestamp: now + 7_200 },
{ payer: "GPAYER4", amount: 100_000n, timestamp: now + 432_000 }, // 5 days later
{ payer: "GPAYER5", amount: 100_000n, timestamp: now + 435_600 },
],
} as any);

const report = await trackVelocity(creatorAddr, client);

expect(report.invoices[0]!.trend).toBe("stalling");
});

it("classifies accelerating trend for increasing payment rate", async () => {
const { trackVelocity } = await import("../src/velocityTracker.js");

const client = new StellarSplitClient({
rpcUrl: "https://example.com",
networkPassphrase: "Test Network",
contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()),
});

const now = Math.floor(Date.now() / 1000);
const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";

vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({
items: ["inv1"],
nextCursor: null,
total: 1,
});

// Payments concentrated later (accelerating pattern)
vi.spyOn(client, "getInvoice").mockResolvedValue({
id: "inv1",
creator: creatorAddr,
recipients: [],
token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
deadline: now + 864_000,
funded: 1_000_000n,
status: "Pending" as const,
payments: [
{ payer: "GPAYER1", amount: 100_000n, timestamp: now },
{ payer: "GPAYER2", amount: 100_000n, timestamp: now + 172_800 }, // First half ends here (5 payments / 2 = 2.5)
{ payer: "GPAYER3", amount: 100_000n, timestamp: now + 345_600 },
{ payer: "GPAYER4", amount: 100_000n, timestamp: now + 432_000 },
{ payer: "GPAYER5", amount: 100_000n, timestamp: now + 439_200 },
],
} as any);

const report = await trackVelocity(creatorAddr, client);

expect(report.invoices[0]!.trend).toBe("accelerating");
});

it("classifies steady trend for constant payment rate", async () => {
const { trackVelocity } = await import("../src/velocityTracker.js");

const client = new StellarSplitClient({
rpcUrl: "https://example.com",
networkPassphrase: "Test Network",
contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()),
});

const now = Math.floor(Date.now() / 1000);
const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";

vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({
items: ["inv1"],
nextCursor: null,
total: 1,
});

// Evenly distributed payments
vi.spyOn(client, "getInvoice").mockResolvedValue({
id: "inv1",
creator: creatorAddr,
recipients: [],
token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
deadline: now + 864_000,
funded: 1_000_000n,
status: "Pending" as const,
payments: [
{ payer: "GPAYER1", amount: 100_000n, timestamp: now },
{ payer: "GPAYER2", amount: 100_000n, timestamp: now + 86_400 },
{ payer: "GPAYER3", amount: 100_000n, timestamp: now + 172_800 },
{ payer: "GPAYER4", amount: 100_000n, timestamp: now + 259_200 },
],
} as any);

const report = await trackVelocity(creatorAddr, client);

expect(report.invoices[0]!.trend).toBe("steady");
});
});
Loading