This guide explains how third-party developers (accounting tools, ERP systems, DeFi aggregators) can query the Invoice Liquidity Network (ILN) contract state, subscribe to real-time events, and submit transactions on behalf of users.
- Authentication Patterns
- Querying Invoice State
- Listening to Contract Events (Horizon Streaming)
- Submitting Invoices Programmatically
- Building a Custom LP Dashboard
- Handling Multi-Token Precision
- Horizon API Rate Limits & Best Practices
ILN supports two primary transaction signing models depending on the environment:
- Use Case: Web applications, client-facing dashboards (e.g., Freighter wallet).
- Behavior: The dApp builds a transaction, passes it to Freighter for the user's signature, and submits it. The application never accesses the user's private key.
- Safety: Highly secure, zero liability for developers.
- Use Case: Background sync services, accounting tools, automated payment systems.
- Behavior: The server uses a secret key (
S...) stored in environment variables to sign transactions programmatically. - Safety: Secure the secret key using hardware security modules (HSM), AWS Secrets Manager, or Google Cloud Secret Manager. Never hardcode keys.
Use the @invoice-liquidity/sdk to query details of any invoice by ID:
import { ILNSdk, ILN_TESTNET } from "@invoice-liquidity/sdk";
// Initialize SDK
const sdk = new ILNSdk({
contractId: ILN_TESTNET.contractId,
rpcUrl: ILN_TESTNET.rpcUrl,
networkPassphrase: ILN_TESTNET.networkPassphrase,
});
async function fetchInvoiceDetails(invoiceId: bigint) {
try {
const invoice = await sdk.getInvoice(invoiceId);
console.log("Invoice Details:", {
id: invoice.id.toString(),
freelancer: invoice.freelancer,
payer: invoice.payer,
amount: invoice.amount.toString(),
dueDate: new Date(invoice.dueDate * 1000).toISOString(),
discountRate: `${(invoice.discountRate / 100).toFixed(2)}%`,
status: invoice.status, // "Pending" | "Funded" | "Paid" | "Defaulted"
funder: invoice.funder,
fundedAt: invoice.fundedAt ? new Date(invoice.fundedAt * 1000).toISOString() : null,
});
} catch (error) {
console.error("Failed to query invoice:", error);
}
}
fetchInvoiceDetails(1n);You can query the contract directly using the stellar-cli interface:
stellar contract invoke \
--id CD3TE3IAHM737P236XZL2OYU275ZKD6MN7YH7PYYAXYIGEH55OPEWYJC \
--source-account S... \
--network testnet \
-- \
get_invoice \
--invoice_id 1Instead of polling, third-party applications should subscribe to Soroban events using the Horizon streaming API or the RPC server /events endpoint.
Use the @stellar/stellar-sdk to stream event records matching the ILN contract ID:
import { rpc } from "@stellar/stellar-sdk";
import { ILN_TESTNET } from "@invoice-liquidity/sdk";
const server = new rpc.Server(ILN_TESTNET.rpcUrl);
async function startEventStream() {
console.log("Subscribing to ILN contract events...");
// Poll or stream events using getEvents
let lastLedger = (await server.getLatestLedger()).sequence;
setInterval(async () => {
try {
const response = await server.getEvents({
startLedger: lastLedger,
filters: [
{
type: "contract",
contractIds: [ILN_TESTNET.contractId],
},
],
limit: 10,
});
for (const event of response.events) {
console.log("Received Contract Event:", {
id: event.id,
ledger: event.ledger,
topic: event.topic.map((t) => t.toString()), // topics tell you which event (e.g. submit_invoice)
value: event.value,
});
}
if (response.events.length > 0) {
lastLedger = Math.max(...response.events.map((e) => e.ledger)) + 1;
}
} catch (error) {
console.error("Error fetching contract events:", error);
}
}, 5000);
}
startEventStream();import { ILNSdk, ILN_TESTNET, createFreighterSigner } from "@invoice-liquidity/sdk";
const sdk = new ILNSdk({
...ILN_TESTNET,
signer: createFreighterSigner(),
});
async function submitUserInvoice() {
const invoiceId = await sdk.submitInvoice({
freelancer: "GB...", // User's public key
payer: "GD...",
amount: 100_000_000n, // Decimals-scaled bigint
dueDate: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60), // 30 days
discountRate: 500, // 5.00%
});
console.log(`Invoice submitted successfully. ID: ${invoiceId}`);
}import { ILNSdk, ILN_TESTNET, createKeypairSigner } from "@invoice-liquidity/sdk";
const sdk = new ILNSdk({
...ILN_TESTNET,
signer: createKeypairSigner(process.env.STELLAR_SECRET_KEY!),
});
async function submitSystemInvoice() {
const invoiceId = await sdk.submitInvoice({
freelancer: "GB...", // The system's or delegated user's public address
payer: "GD...",
amount: 500_000_000n,
dueDate: Math.floor(Date.now() / 1000) + (14 * 24 * 60 * 60),
discountRate: 300, // 3.00%
});
console.log(`Server-side invoice submission successful. ID: ${invoiceId}`);
}stellar contract invoke \
--id CD3TE3IAHM737P236XZL2OYU275ZKD6MN7YH7PYYAXYIGEH55OPEWYJC \
--source-account S... \
--network testnet \
-- \
submit_invoice \
--freelancer GB... \
--payer GD... \
--amount 100000000 \
--due_date 1782800000 \
--discount_rate 500For DeFi aggregators and Liquidity Providers (LPs), monitoring yield and funding invoices requires extracting status data.
import { ILNSdk, ILN_TESTNET } from "@invoice-liquidity/sdk";
const sdk = new ILNSdk(ILN_TESTNET);
async function getLPMetrics(lpAddress: string, activeInvoiceIds: bigint[]) {
let totalFunded = 0n;
let expectedReturn = 0n;
for (const id of activeInvoiceIds) {
const invoice = await sdk.getInvoice(id);
if (invoice.funder === lpAddress) {
totalFunded += invoice.amount;
// Calculate return based on [discount rate](glossary.md#discount-rate) (e.g. 500 [basis points](glossary.md#basis-points-bps) = 5.00%)
const discountAmount = (invoice.amount * BigInt(invoice.discountRate)) / 10000n;
if (invoice.status === "Paid") {
expectedReturn += discountAmount;
}
}
}
console.log(`LP Portfolio Summary for ${lpAddress}:`);
console.log(`Total Funded: ${totalFunded.toString()}`);
console.log(`Total Earned Yield: ${expectedReturn.toString()}`);
}ILN accepts multiple allowlisted tokens (e.g. USDC, EURC, and XLM). Each asset has distinct decimals:
| Token | Decimals | Format Code | Smallest Unit (Stroop/Base Unit) |
|---|---|---|---|
| USDC | 6 | USDC | 1 = 0.000001 USDC |
| EURC | 6 | EURC | 1 = 0.000001 EURC |
| XLM | 7 | XLM | 1 = 0.0000001 XLM |
Always process amounts as bigint base units on-chain, and format them using token-aware utilities:
const DECIMALS_MAP = {
USDC: 6,
EURC: 6,
XLM: 7,
} as const;
export function toBaseUnits(amountString: string, token: keyof typeof DECIMALS_MAP): bigint {
const decimals = DECIMALS_MAP[token];
const parts = amountString.split(".");
const whole = BigInt(parts[0] || "0");
const fractionStr = (parts[1] || "").slice(0, decimals).padEnd(decimals, "0");
const fraction = BigInt(fractionStr);
const scale = 10n ** BigInt(decimals);
return whole * scale + fraction;
}
export function toDisplayAmount(baseUnits: bigint, token: keyof typeof DECIMALS_MAP): string {
const decimals = DECIMALS_MAP[token];
const scale = 10n ** BigInt(decimals);
const whole = baseUnits / scale;
const fraction = baseUnits % scale;
const fractionStr = fraction.toString().padStart(decimals, "0").replace(/0+$/, "");
return fractionStr ? `${whole}.${fractionStr}` : `${whole}`;
}
// Examples:
console.log(toBaseUnits("100.50", "USDC")); // 100500000n
console.log(toBaseUnits("100.50", "XLM")); // 1005000000nPublic Horizon endpoints enforce rate limits to maintain network stability.
- Handle HTTP 429 (Too Many Requests):
Implement exponential backoff when requests fail.
async function callWithRetry<T>(fn: () => Promise<T>, retries = 5, delay = 1000): Promise<T> { try { return await fn(); } catch (error: any) { if (error.response?.status === 429 && retries > 0) { console.warn(`Rate limited. Retrying in ${delay}ms...`); await new Promise((resolve) => setTimeout(resolve, delay)); return callWithRetry(fn, retries - 1, delay * 2); } throw error; } }
- Reuse Client Connections:
Do not instantiate
rpc.Serveror Horizon clients inside loops. Cache the instance. - Use Dedicated RPC Nodes for Production:
Public infrastructure (
https://soroban-testnet.stellar.org) is for development. Use dedicated services (e.g. Blockdaemon, NowNodes) for production. - Cache Static Data:
Invoice state for
PaidorDefaultedinvoices never changes. Cache these in a local database (e.g. SQLite/PostgreSQL) instead of querying RPC servers repeatedly.