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
30 changes: 29 additions & 1 deletion src/operationQueue.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,24 @@
/**
* Named priority levels for queued operations.
*
* Higher numeric value = higher urgency; the drain loop processes items in
* descending priority order (HIGH before NORMAL before LOW).
*
* Export so callers can reference the constants without magic numbers.
*/
export const OperationPriority = {
LOW: 1,
NORMAL: 5,
HIGH: 10,
} as const;

export type OperationPriorityValue = (typeof OperationPriority)[keyof typeof OperationPriority];

type QueuedOperation = {
id: string;
method: string;
args: unknown[];
priority: OperationPriorityValue;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
executor: (args: unknown[]) => Promise<unknown>;
Expand Down Expand Up @@ -38,11 +55,18 @@ export class OperationQueue {
/**
* Enqueue an operation. Executes immediately when online; buffers when offline.
* The returned promise resolves/rejects once the operation completes.
*
* @param method - Human-readable name for the operation (used for debugging).
* @param args - Arguments forwarded to `executor`.
* @param executor - Async function that performs the actual work.
* @param priority - Urgency level; defaults to {@link OperationPriority.NORMAL}.
* Higher-priority operations are drained first.
*/
enqueue<T>(
method: string,
args: unknown[],
executor: (args: unknown[]) => Promise<T>
executor: (args: unknown[]) => Promise<T>,
priority: OperationPriorityValue = OperationPriority.NORMAL,
): Promise<T> {
if (this._online) {
return executor(args);
Expand All @@ -52,6 +76,7 @@ export class OperationQueue {
id: String(++_nextId),
method,
args,
priority,
resolve: resolve as (v: unknown) => void,
reject,
executor: executor as (args: unknown[]) => Promise<unknown>,
Expand All @@ -78,6 +103,9 @@ export class OperationQueue {
}

private async _drain(): Promise<void> {
// Sort descending by priority so HIGH (10) ops execute before NORMAL (5) and LOW (1).
this._queue.sort((a, b) => b.priority - a.priority);

while (this._queue.length > 0) {
const op = this._queue.shift();
if (!op) break;
Expand Down
65 changes: 65 additions & 0 deletions src/sponsorship.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,25 @@ export class InsufficientReserveError extends StellarSplitError {
import { checkSponsorReserve as _checkSponsorReserve } from "./preflightChecker.js";
import type { SponsorshipConfig, SponsorReserveCheckResult } from "./types.js";

// ---------------------------------------------------------------------------
// SponsorshipUsed event
// ---------------------------------------------------------------------------

/**
* Event payload emitted after a sponsored transaction is successfully submitted.
*
* The `feeSource` field identifies the **sponsor** account that covered the
* transaction fee — not the submitter of the envelope.
*/
export interface SponsorshipUsedEvent {
/** Stellar address of the account that sponsored the reserves/fees. */
feeSource: string;
/** Stellar address of the newly-onboarded account whose reserves were sponsored. */
newAccount: string;
/** Transaction hash returned by the RPC node after submission. */
txHash: string;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -205,3 +224,49 @@ export async function checkSponsorshipReserve(
options?.throwOnInsufficient ?? true,
);
}

// ---------------------------------------------------------------------------
// submitSponsoredTransaction
// ---------------------------------------------------------------------------

/**
* Submit a signed sponsored-reserve transaction to the Soroban RPC node and
* emit a {@link SponsorshipUsedEvent}.
*
* **Fee attribution fix**: `feeSource` in the emitted event is set to the
* `sponsor` address — the account that funded the reserves — NOT the address
* that called this function. The sponsor is always the source account of the
* transaction envelope (set by {@link buildSponsoredOnboarding}), so it is
* read directly from `tx.source` without any additional Horizon calls.
*
* @param tx - Fully-signed sponsored transaction (built by
* {@link buildSponsoredOnboarding}).
* @param newAccount - Stellar address of the newly-onboarded account.
* @param rpcUrl - Soroban RPC endpoint to submit to.
* @param onEvent - Optional callback invoked with the {@link SponsorshipUsedEvent}
* after successful submission.
* @returns The emitted {@link SponsorshipUsedEvent} (including the `txHash`).
*/
export async function submitSponsoredTransaction(
tx: Transaction,
newAccount: string,
rpcUrl: string,
onEvent?: (event: SponsorshipUsedEvent) => void,
): Promise<SponsorshipUsedEvent> {
const { rpc } = await import("@stellar/stellar-sdk");

const server = new rpc.Server(rpcUrl);
const result = await server.sendTransaction(tx);

// tx.source is the sponsor — set by buildSponsoredOnboarding as the
// TransactionBuilder source account. This is the correct feeSource for
// sponsored-reserve transactions.
const event: SponsorshipUsedEvent = {
feeSource: tx.source, // ← sponsor, not the submitter's address
newAccount,
txHash: result.hash,
};

onEvent?.(event);
return event;
}
79 changes: 79 additions & 0 deletions src/templateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,26 @@ function writeBrowserStore(store: Record<string, InvoiceTemplate>): void {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store, bigintReplacer));
}

// ---------------------------------------------------------------------------
// Version history (in-memory; no persistence layer)
// ---------------------------------------------------------------------------

/**
* A single versioned snapshot of a template's content.
*/
export interface TemplateVersion {
/** Monotonically increasing version number, starting at 1. */
version: number;
/** The template content at this version. */
content: string;
}

/**
* In-memory store mapping template ID → ordered list of {@link TemplateVersion}s.
* Index 0 holds v1, index N-1 holds the latest version.
*/
const _versionHistory = new Map<string, TemplateVersion[]>();

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -102,3 +122,62 @@ export function deleteTemplate(name: string): void {
writeNodeStore(store);
}
}

// ---------------------------------------------------------------------------
// Versioned template API
// ---------------------------------------------------------------------------

/**
* Update a template's content.
*
* - The new content is stored as the next version (version 1 on first call).
* - All previous versions are retained in {@link _versionHistory} so they can
* be retrieved via {@link getTemplate}.
* - Version numbers are integers starting at 1 and increment by 1 on every
* call regardless of whether the content actually changed.
*
* @param id - Unique template identifier.
* @param content - New template content string.
* @returns The new version number assigned to this content.
*/
export function updateTemplate(id: string, content: string): number {
const history = _versionHistory.get(id) ?? [];
const nextVersion = history.length + 1;
history.push({ version: nextVersion, content });
_versionHistory.set(id, history);
return nextVersion;
}

/**
* Retrieve a template's content by ID and optional version.
*
* @param id - Unique template identifier.
* @param version - Specific version to retrieve. When omitted (or 0), the
* latest version is returned.
* @returns The {@link TemplateVersion} for the requested version, or `null` if
* the template does not exist or the version is out of range.
*/
export function getTemplate(id: string, version?: number): TemplateVersion | null {
const history = _versionHistory.get(id);
if (!history || history.length === 0) return null;

if (!version) {
// Return the latest version when no version is specified.
return history[history.length - 1];
}

// Versions are 1-indexed; array is 0-indexed.
const entry = history[version - 1];
return entry ?? null;
}

/**
* Retrieve the full version history for a template.
*
* @param id - Unique template identifier.
* @returns Ordered list of {@link TemplateVersion}s (oldest first), or an
* empty array when the template has no recorded history.
*/
export function getTemplateHistory(id: string): TemplateVersion[] {
return _versionHistory.get(id) ?? [];
}
102 changes: 102 additions & 0 deletions test/broadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,106 @@ describe("InvoiceStateBroadcaster", () => {
it("should export createInvoiceStateBroadcaster function", () => {
expect(createInvoiceStateBroadcaster).toBeDefined();
});

// ---------------------------------------------------------------------------
// Message ordering tests
// ---------------------------------------------------------------------------

it("delivers three messages to a subscriber in the order they were broadcast", () => {
const received: Invoice[] = [];
broadcaster.subscribe("order-test", (_, invoice) => {
received.push(invoice);
});

const makeInvoice = (id: string): Invoice => ({
id,
creator: "GABC123...",
recipients: [{ address: "GDEF456...", amount: 1000n }],
token: "USDC_CONTRACT",
deadline: 1234567890,
funded: 0n,
status: "Pending",
payments: [],
recurring: false,
});

const inv1 = makeInvoice("1");
const inv2 = makeInvoice("2");
const inv3 = makeInvoice("3");

broadcaster.broadcast("order-test", inv1);
broadcaster.broadcast("order-test", inv2);
broadcaster.broadcast("order-test", inv3);

expect(received).toHaveLength(3);
expect(received[0].id).toBe("1");
expect(received[1].id).toBe("2");
expect(received[2].id).toBe("3");
});

it("a subscriber added after some broadcasts does not receive missed messages", () => {
const lateReceived: Invoice[] = [];

const makeInvoice = (id: string): Invoice => ({
id,
creator: "GABC123...",
recipients: [{ address: "GDEF456...", amount: 1000n }],
token: "USDC_CONTRACT",
deadline: 1234567890,
funded: 0n,
status: "Pending",
payments: [],
recurring: false,
});

// Broadcast first message BEFORE the late subscriber joins
broadcaster.broadcast("late-test", makeInvoice("early"));

// Late subscriber registers after the first broadcast
broadcaster.subscribe("late-test", (_, invoice) => {
lateReceived.push(invoice);
});

// Broadcast a second message AFTER the late subscriber joins
broadcaster.broadcast("late-test", makeInvoice("late"));

// Late subscriber must only receive the message sent after it joined
expect(lateReceived).toHaveLength(1);
expect(lateReceived[0].id).toBe("late");
});

it("removing a subscriber mid-sequence stops delivery for subsequent messages only", () => {
const received: string[] = [];

const makeInvoice = (id: string): Invoice => ({
id,
creator: "GABC123...",
recipients: [{ address: "GDEF456...", amount: 1000n }],
token: "USDC_CONTRACT",
deadline: 1234567890,
funded: 0n,
status: "Pending",
payments: [],
recurring: false,
});

const unsubscribe = broadcaster.subscribe("mid-unsub-test", (_, invoice) => {
received.push(invoice.id);
});

// First message — subscriber is still active
broadcaster.broadcast("mid-unsub-test", makeInvoice("msg1"));

// Unsubscribe between broadcasts
unsubscribe();

// Second message — subscriber has been removed
broadcaster.broadcast("mid-unsub-test", makeInvoice("msg2"));

// Third message — subscriber has been removed
broadcaster.broadcast("mid-unsub-test", makeInvoice("msg3"));

// Only the first message should have been received
expect(received).toEqual(["msg1"]);
});
});
Loading