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
133 changes: 133 additions & 0 deletions src/indexer/contractReader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
Account,
BASE_FEE,
Contract,
TransactionBuilder,
nativeToScVal,
rpc,
scValToNative,
type xdr,
} from '@stellar/stellar-sdk';
import { environment } from '../config/environment.js';
import { sorobanServer } from './sorobanClient.js';

/**
* Read-only contract calls, used to fill fields the contract's events omit.
*
* Some `#[contractevent]` payloads are narrower than the struct they describe —
* `SubscriptionPlanCreatedEvent` has no `description`, and
* `InvoiceCreatedEvent` has neither `description` nor `expires_at` — while our
* schema requires them. The values are read back from contract storage with a
* simulated (never submitted) invocation.
*
* Every read here is best-effort: callers fall back to a placeholder rather
* than throwing, because a handler that throws loses the event entirely (the
* poller advances its cursor past events whose handler failed), whereas a row
* with a placeholder description is visible and repairable.
*/

// Simulation never submits, so the source account is only a structural
// requirement of the envelope and does not need to exist or hold a balance.
// The all-zero ed25519 key is the conventional stand-in.
const NULL_SOURCE_ACCOUNT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF';

let cachedNetworkPassphrase: string | undefined;

/**
* Read from the RPC rather than configured, so the indexer cannot end up
* simulating against a different network than the one it polls for events.
*/
const networkPassphrase = async (): Promise<string> => {
cachedNetworkPassphrase ??= (await sorobanServer.getNetwork()).passphrase;
return cachedNetworkPassphrase;
};

const simulateRead = async (method: string, args: xdr.ScVal[]): Promise<unknown> => {
const contractId = environment.stellar.contractId;
if (!contractId || contractId.trim() === '') {
throw new Error('STELLAR_CONTRACT_ID environment variable is unset or empty');
}

const transaction = new TransactionBuilder(new Account(NULL_SOURCE_ACCOUNT, '0'), {
fee: BASE_FEE,
networkPassphrase: await networkPassphrase(),
})
.addOperation(new Contract(contractId).call(method, ...args))
.setTimeout(30)
.build();

const simulation = await sorobanServer.simulateTransaction(transaction);

if (rpc.Api.isSimulationError(simulation)) {
throw new Error(`${method} simulation failed: ${simulation.error}`);
}
if (!simulation.result?.retval) {
throw new Error(`${method} simulation returned no value`);
}

return scValToNative(simulation.result.retval);
};

/** Reads one field off a `scValToNative`-decoded contract struct. */
const structField = (value: unknown, field: string): unknown => {
if (value instanceof Map) return value.get(field);
if (typeof value === 'object' && value !== null) return (value as Record<string, unknown>)[field];
return undefined;
};

const optionalString = (value: unknown): string | null =>
typeof value === 'string' && value.trim() !== '' ? value : null;

export interface OnChainInvoiceDetails {
/** The description the contract stored; `InvoiceCreatedEvent` does not carry it. */
description: string | null;
expiresAt: Date | null;
}

/**
* Reads back the parts of `get_invoice` that `InvoiceCreatedEvent` leaves out.
* Returns null if the read fails for any reason, including the invoice having
* been pruned from contract storage — callers must cope with not knowing.
*/
export const fetchInvoiceDetails = async (
invoiceId: number,
): Promise<OnChainInvoiceDetails | null> => {
try {
const invoice = await simulateRead('get_invoice', [nativeToScVal(invoiceId, { type: 'u64' })]);
const expiresAt = structField(invoice, 'expires_at');

return {
description: optionalString(structField(invoice, 'description')),
// `Option<u64>` decodes to undefined when None.
expiresAt:
typeof expiresAt === 'bigint' || typeof expiresAt === 'number'
? new Date(Number(expiresAt) * 1000)
: null,
};
} catch (error) {
console.warn(
`Could not read invoice ${invoiceId} from the contract:`,
error instanceof Error ? error.message : error,
);
return null;
}
};

/**
* Reads back the `description` that `SubscriptionPlanCreatedEvent` omits.
* Returns null if the read fails; callers must cope with not knowing.
*/
export const fetchSubscriptionPlanDescription = async (planId: number): Promise<string | null> => {
try {
const plan = await simulateRead('get_subscription_plan', [
nativeToScVal(planId, { type: 'u64' }),
]);
return optionalString(structField(plan, 'description'));
} catch (error) {
console.warn(
`Could not read subscription plan ${planId} from the contract:`,
error instanceof Error ? error.message : error,
);
return null;
}
};
46 changes: 10 additions & 36 deletions src/indexer/handlers/growth.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,21 @@
import prisma from '../../config/prisma.js';
import { recordDailyStats } from '../../services/analytics.services.js';
import {
decodeInvoiceCreatedEventData,
decodeMerchantRegisteredEventData,
decodeSubscribedEventData,
type DecodedEvent,
} from '../types.js';

/**
* Falls back to the indexing time only if the RPC response carried no ledger
* close time — every real `getEvents` response does.
*/
const ledgerCloseTime = (event: DecodedEvent): Date => {
if (!event.ledgerClosedAt) return new Date();
const closedAt = new Date(event.ledgerClosedAt);
return Number.isNaN(closedAt.getTime()) ? new Date() : closedAt;
};
import { decodeMerchantRegisteredEventData, type DecodedEvent } from '../types.js';

export const MERCHANT_REGISTERED_TOPIC = 'merchant_registered_event';
export const INVOICE_CREATED_TOPIC = 'invoice_created_event';
export const SUBSCRIBED_TOPIC = 'subscribed_event';

/**
* Growth events only move PlatformDailyStats' "new X today" counters. The
* point-in-time totals they feed into (how many merchants exist, how many
* invoices are in each status) are counted live at request time off the
* existing tables, so nothing else needs recording here.
* `MerchantRegistered` only moves PlatformDailyStats' "new merchants today"
* counter. The point-in-time total it feeds into (how many merchants exist) is
* counted live at request time off the existing table, so nothing else needs
* recording here.
*
* The other two growth events, `invoice_created_event` and `subscribed_event`,
* used to live here as stats-only handlers. They now project real rows as well,
* so they have moved to ./invoiceCreated.ts and ./subscribed.ts — each still
* increments the same daily counter it did here, from inside its service.
*/
export const handleMerchantRegistered = async (event: DecodedEvent): Promise<void> => {
const data = decodeMerchantRegisteredEventData(event.data);
await recordDailyStats(prisma, new Date(data.timestamp * 1000), { newMerchants: 1 });
};

export const handleInvoiceCreated = async (event: DecodedEvent): Promise<void> => {
// `InvoiceCreatedEvent` is the one growth event the contract emits without a
// timestamp field, so the day comes from the close time of the ledger that
// contained it. Using the indexing time instead would bucket a historical
// replay into whatever day the replay happened to run.
decodeInvoiceCreatedEventData(event.data);
await recordDailyStats(prisma, ledgerCloseTime(event), { newInvoices: 1 });
};

export const handleSubscribed = async (event: DecodedEvent): Promise<void> => {
const data = decodeSubscribedEventData(event.data);
await recordDailyStats(prisma, new Date(data.timestamp * 1000), { newSubscriptions: 1 });
};
31 changes: 19 additions & 12 deletions src/indexer/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,38 @@ import {
TICKET_PURCHASED_TOPIC,
TICKET_RESOLD_TOPIC,
} from './ticketing.js';
import { handleMerchantRegistered, MERCHANT_REGISTERED_TOPIC } from './growth.js';
import { handleInvoiceCreated, INVOICE_CREATED_TOPIC } from './invoiceCreated.js';
import {
handleInvoiceCreated,
handleMerchantRegistered,
handleSubscribed,
INVOICE_CREATED_TOPIC,
MERCHANT_REGISTERED_TOPIC,
SUBSCRIBED_TOPIC,
} from './growth.js';
handleSubscriptionPlanCreated,
SUBSCRIPTION_PLAN_CREATED_TOPIC,
} from './subscriptionPlanCreated.js';
import { handleSubscribed, SUBSCRIBED_TOPIC } from './subscribed.js';
import {
handleInvoicePartiallyRefunded,
handleInvoiceRefunded,
INVOICE_PARTIALLY_REFUNDED_TOPIC,
INVOICE_REFUNDED_TOPIC,
} from './refunds.js';

// One handler per topic: registerEventHandler overwrites on a repeated topic,
// so a second registration would silently replace the first, not run alongside it.

// Volume-moving events: they update MerchantAnalytics, TokenAnalytics and the
// protocol-wide PlatformDailyStats rollup.
registerEventHandler(INVOICE_PAID_TOPIC, handleInvoicePaid);
registerEventHandler(SUBSCRIPTION_CHARGED_TOPIC, handleSubscriptionCharged);
registerEventHandler(TICKET_PURCHASED_TOPIC, handleTicketPurchased);
registerEventHandler(TICKET_RESOLD_TOPIC, handleTicketResold);

// Growth events: they only move PlatformDailyStats' "new X today" counters.
// Growth event: only moves PlatformDailyStats' "new merchants today" counter.
registerEventHandler(MERCHANT_REGISTERED_TOPIC, handleMerchantRegistered);

// Creation events: they project the on-chain record into its own table and, for
// invoices and subscriptions, still move the same daily "new X today" counter
// they moved when they were stats-only handlers.
registerEventHandler(INVOICE_CREATED_TOPIC, handleInvoiceCreated);
registerEventHandler(SUBSCRIPTION_PLAN_CREATED_TOPIC, handleSubscriptionPlanCreated);
registerEventHandler(SUBSCRIBED_TOPIC, handleSubscribed);

// Refund events: they adjust the invoice only. Volume is deliberately not
Expand All @@ -40,10 +47,10 @@ registerEventHandler(INVOICE_REFUNDED_TOPIC, handleInvoiceRefunded);
registerEventHandler(INVOICE_PARTIALLY_REFUNDED_TOPIC, handleInvoicePartiallyRefunded);

// Intentionally unhandled, and left to log "no handler registered":
// - subscription_plan_created_event / event_created_event: growth events with
// no dedicated daily counter in PlatformDailyStats. Plan and event totals
// are point-in-time counts, and adding daily counters for them is a
// follow-up if a dashboard ever asks for the trend.
// - event_created_event: a growth event with no dedicated daily counter in
// PlatformDailyStats. Event totals are point-in-time counts, and adding a
// daily counter for them is a follow-up if a dashboard ever asks for the
// trend.
// - status and governance events (merchant_status_changed_event,
// role_granted_event, contract_paused_event, fee_set_event, ...): out of
// scope for analytics indexing.
32 changes: 32 additions & 0 deletions src/indexer/handlers/invoiceCreated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { applyInvoiceCreated } from '../../services/invoice.services.js';
import { fetchInvoiceDetails } from '../contractReader.js';
import { ledgerCloseTime } from '../ledgerTime.js';
import { decodeInvoiceCreatedEventData, type DecodedEvent } from '../types.js';

// Confirmed against a live testnet event: Soroban's `#[contractevent]` macro
// publishes a single fixed first topic, the struct name in lower snake case.
export const INVOICE_CREATED_TOPIC = 'invoice_created_event';

/**
* Normalizes the event at the indexer edge and delegates all persistence to
* applyInvoiceCreated.
*
* Part of that normalization is reading back what `InvoiceCreatedEvent` leaves
* out: it carries neither the invoice's description nor its expiry, and both
* matter downstream — the description is a required column and the strongest
* signal available for correlating this event with an off-chain invoice row.
* The read is best-effort and returns null on failure; keeping it here rather
* than in the service also keeps the Soroban RPC client out of the HTTP app's
* module graph, since nothing but the indexer reaches this path.
*
* `InvoiceCreatedEvent` is also one of the events the contract emits without a
* timestamp field, so the occurrence time comes from the close time of the
* ledger that contained it. Using the indexing time instead would attribute a
* historical replay to whatever day the replay happened to run.
*/
export const handleInvoiceCreated = async (event: DecodedEvent): Promise<void> => {
const data = decodeInvoiceCreatedEventData(event.data);
const onChain = await fetchInvoiceDetails(data.invoiceId);

await applyInvoiceCreated(data, event.txHash, ledgerCloseTime(event), onChain);
};
20 changes: 11 additions & 9 deletions src/indexer/handlers/not-yet-implemented.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
* there is no behavior change here, only documentation.
*
* None of these topic strings have been observed against a live deployment —
* they are inferred from the one confirmed convention in this codebase
* (`#[contractevent] InvoicePaidEvent` -> topic "InvoicePaid", see
* ../handlers/invoicePaid.ts). Do not build a decoder from this file alone;
* verify the actual event payload shape against the deployed contract first.
* they are inferred from the naming convention that the handled events do
* confirm (`#[contractevent] InvoicePaidEvent` -> topic "invoice_paid_event").
* Do not build a decoder from this file alone; verify the actual event payload
* shape against a real testnet event first. That check is not a formality: the
* payload is routinely narrower than the struct it is named after —
* `SubscriptionPlanCreatedEvent` omits the plan's `description`, and
* `InvoiceCreatedEvent` omits both `description` and any timestamp.
*
* When wiring one of these for real: add a decoder to ../types.ts, a handler
* to ../handlers/ (see invoicePaid.ts for the pattern), register it in
Expand Down Expand Up @@ -43,7 +46,6 @@
* merchant.account_restricted <- AccountRestricted
*
* ---- On-chain events: Invoice Lifecycle (beyond InvoicePaid) ----
* invoice.created (on-chain) <- InvoiceCreated
* invoice.payment_split_routed <- PaymentSplitRouted
* invoice.refunded / partially_refunded <- InvoiceRefunded / InvoicePartiallyRefunded
* invoice.cancelled (on-chain) <- InvoiceCancelled
Expand All @@ -52,12 +54,12 @@
* the real invoice.amended call site)
* invoice.fiat_priced <- FiatInvoicePriced
*
* ---- On-chain events: Subscription Lifecycle (no service exists at all) ----
* subscription_plan.created <- SubscriptionPlanCreated
* ---- On-chain events: Subscription Lifecycle ----
* subscription_plan.deactivated <- PlanDeactivated
* subscription.created <- Subscribed
* subscription.charged <- SubscriptionCharged
* subscription.cancelled <- SubscriptionCancelled
* (subscription_plan.created, subscription.created and subscription.charged are
* implemented — see ../handlers/subscriptionPlanCreated.ts, ./subscribed.ts and
* ./subscriptionCharged.ts)
*
* ---- On-chain events: Account Contract / Withdrawals ----
* account.initialized / verified <- AccountInitialized / AccountVerified
Expand Down
9 changes: 9 additions & 0 deletions src/indexer/handlers/subscribed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { applySubscribed } from '../../services/subscription.services.js';
import { decodeSubscribedEventData, type DecodedEvent } from '../types.js';

// Confirmed against a live testnet event.
export const SUBSCRIBED_TOPIC = 'subscribed_event';

export const handleSubscribed = async (event: DecodedEvent): Promise<void> => {
await applySubscribed(decodeSubscribedEventData(event.data), event.txHash);
};
20 changes: 20 additions & 0 deletions src/indexer/handlers/subscriptionPlanCreated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { applySubscriptionPlanCreated } from '../../services/subscription.services.js';
import { fetchSubscriptionPlanDescription } from '../contractReader.js';
import { decodeSubscriptionPlanCreatedEventData, type DecodedEvent } from '../types.js';

// Confirmed against a live testnet event.
export const SUBSCRIPTION_PLAN_CREATED_TOPIC = 'subscription_plan_created_event';

/**
* `SubscriptionPlanCreatedEvent` omits the plan's `description`, which is a
* required column here and in the contract's own struct, so it is read back off
* the contract at the indexer edge. The read is best-effort and returns null on
* failure; the service stores a placeholder in that case rather than dropping
* the plan.
*/
export const handleSubscriptionPlanCreated = async (event: DecodedEvent): Promise<void> => {
const data = decodeSubscriptionPlanCreatedEventData(event.data);
const description = await fetchSubscriptionPlanDescription(data.planId);

await applySubscriptionPlanCreated(data, event.txHash, description);
};
16 changes: 16 additions & 0 deletions src/indexer/ledgerTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { DecodedEvent } from './types.js';

/**
* Close time of the ledger that contained an event.
*
* Used by handlers for the events the contract emits without their own
* timestamp field, so a historical replay attributes them to when they
* actually happened rather than to whenever the replay ran. Falls back to the
* indexing time only if the RPC response carried no ledger close time — every
* real `getEvents` response does.
*/
export const ledgerCloseTime = (event: DecodedEvent): Date => {
if (!event.ledgerClosedAt) return new Date();
const closedAt = new Date(event.ledgerClosedAt);
return Number.isNaN(closedAt.getTime()) ? new Date() : closedAt;
};
Loading
Loading