From c153cddacd9f5b3171411350b62e9cda98662d14 Mon Sep 17 00:00:00 2001 From: 0xsteins <0xsteins@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:51:16 +0100 Subject: [PATCH 1/4] Implement SO4 event indexing dispatch --- .../src/mappings/mappingHandlers.ts | 1159 +++++++++++++++-- 1 file changed, 1086 insertions(+), 73 deletions(-) diff --git a/apps/s03-indexer/src/mappings/mappingHandlers.ts b/apps/s03-indexer/src/mappings/mappingHandlers.ts index c3620e6..54c1c8e 100644 --- a/apps/s03-indexer/src/mappings/mappingHandlers.ts +++ b/apps/s03-indexer/src/mappings/mappingHandlers.ts @@ -1,104 +1,1117 @@ -import { MarketTokenTransfer } from "../types"; +import { + AdlEvent, + Deposit, + FeeClaim, + FundingFeeClaim, + Liquidation, + Market, + MarketConfigSnapshot, + MarketTokenTransfer, + Order, + Position, + PositionChange, + ProtocolContract, + ReferralCode, + ReferralOwnershipTransfer, + Token, + TraderReferral, + UiFeeAccrual, + Withdrawal, +} from "../types"; import { SorobanEvent } from "@subql/types-stellar"; -import { xdr } from "@stellar/stellar-sdk"; -import { Address, scValToBigInt } from "@stellar/stellar-base"; +import { Address, scValToBigInt, xdr } from "@stellar/stellar-sdk"; -export async function handleEvent(event: SorobanEvent): Promise { - logger.info( - `New SO4 contract event found at block ${event.ledger!.sequence.toString()}`, - ); +type EntityClass = { + create(record: Record): T; + get(id: string): Promise; +}; - // Token and market-token transfers use topic shape [event_name, from, to]. - // Amounts are stored as strings to preserve protocol-scale precision. - if (event.topic.length < 3) { - logger.info(`Event ${event.id} does not match transfer topic shape, skipping`); - return; - } +type Saveable = { + save(): Promise; +}; - const { - topic: [eventName, from, to], - } = event; +type EventMeta = { + id: string; + contractAddress: string; + contractKey?: string; + eventName: string; + ledger: number; + timestamp: Date; + transactionHash: string; +}; - // Check if the topic values are actually addresses before decoding - // ScVal.switch() tells us the type - we need 'scvAddress' type - const fromType = from.switch().name; - const toType = to.switch().name; - if (fromType !== 'scvAddress' || toType !== 'scvAddress') { - logger.info(`Event topic is not an address type, skipping. from: ${fromType}, to: ${toType}`); - return; - } +export type DecodedEvent = EventMeta & { + topic: xdr.ScVal[]; + values: DecodedTuple; +}; - // Check if address types are supported (0 = accountId, 1 = contractId) - // Skip if unsupported type (e.g., type 4 or others) - const fromAddressType = from.address().switch().value; - const toAddressType = to.address().switch().value; - if (fromAddressType !== 0 && fromAddressType !== 1) { - logger.info(`Unsupported from address type: ${fromAddressType}, skipping event`); - return; +type DecodedValue = + | string + | boolean + | string[] + | DecodedTuple + | DecodedValue[] + | undefined; + +type DecodedTuple = { + list: DecodedValue[]; + named: Record; +}; + +type ConfiguredContracts = { + contractKeysByAddress: Map; + protocolAddresses: Set; + tokenAddresses: Set; + marketTokenAddresses: Set; + marketMetadataByToken: Map; +}; + +type MarketMetadata = { + name: string; + marketToken: string; + indexToken: string; + longToken: string; + shortToken: string; +}; + +type ContractConfig = { + network?: { name?: string }; + contracts?: Record; + tokens?: Record; + markets?: MarketMetadata[]; +}; + +const ZERO_ADDRESS = "00000000000000000000000000000000000000000000000000000000"; +const SO4_CONTRACTS: ContractConfig = { + network: { name: "testnet" }, + contracts: { + role_store: "CBSUAIAMIFFS4AXQYZ7KR7FNO7IMKAPS5WF4DXANVXDTPKH2F7YUIN6Q", + data_store: "CCZ3VKBEDLNBO2JM3EXL3SNBDJOV5BTN52FVQPER7F6D5GCE53PITQ3J", + oracle: "CBEMTV23SIJJBIST3V5HTMWHR4MHYGHNBIG4M26U4LGUJTWZXTFSVQEY", + market_factory: "CBGX3EJFI3JRHSN5B533O2L5P57JFPTCRS55IPWFS5BNDXLJLXDWA5Z2", + deposit_handler: "CDWOFIP4YQJGMCYAOWLSRBAWN2OTJUG2I5WOFC32O2TX2SRU56RWBE5C", + withdrawal_handler: "CBRWM6PNRRFL5RSTJH6HWEXBTMWCGLQRO45NTRDB6BBABWXZ4ZE7DGTO", + order_handler: "CC35OFZVWUTAZPV3B6UKSDVAVORZEWUUMOMTHO33H4YR4C5FKPEFODKY", + liquidation_handler: "CBXUAR5GCHIRFQL75WTZS3FLA6SMWDPIKG4EKNPWVQVNGVFXBHGTJHTM", + adl_handler: "CACFPG3QAKG6DCAJSOP7YGDTM44NV6NPI3SKAG7GUGIV6DMGXPCAMMME", + fee_handler: "CC4P3FJ7EAH6F3RYJPQ2T7VIB4I7UJ4EEYGVWTZVXTAUN647QRVSDHS4", + referral_storage: "CDHTPQO4RRJ6OUBIW3GDXTIVLVOMIKPJC65PGDJH2G5OLDJRE5KTROWK", + reader: "CC6OZUHF3LVO6PNP3V2EB36ORB3YSVYSH3LWD3RFLO4NUO3BYCXSWSYC", + exchange_router: "CBD6BQSQFROWIIT5QCYN7KL5LJJWUIH7CEWUSZIFMUJO6NPXE6CVGYNW", + }, + tokens: { + TUSDC: "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES", + TWBTC: "CCFTOPHUPSUDO2MB4X5D3XYJ2HRJ7NJPAW4UVPAVN7ZLE63EZLSMXDUO", + TETH: "CAJ6BZKGFT47ALGMVFZZGAOXBV2RWIVYVCU4WJCQIURKRNXU346RWVAU", + TXLM: "CAHNXBBSXVMGI6G3FUBY3OTNWKQ7434FDDEEE7ZT733WIW6NUZL4ONU6", + faucet: "CCWXXBKXHHP5DXC6TYVIL22XUNHD5A75O6WM5D2KM5PY45IOV5VDMARJ", + }, + markets: [ + { + name: "TETH/TUSDC", + marketToken: "CCBUUSYZJTGVA6PYUNQDFPZFHTBZ2QSHOUO7YAGRQVA46T3ZLSIYULS4", + indexToken: "CAJ6BZKGFT47ALGMVFZZGAOXBV2RWIVYVCU4WJCQIURKRNXU346RWVAU", + longToken: "CAJ6BZKGFT47ALGMVFZZGAOXBV2RWIVYVCU4WJCQIURKRNXU346RWVAU", + shortToken: "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES", + }, + { + name: "TWBTC/TUSDC", + marketToken: "CDDVSLBGGDV2UOFN5W72R4LW7ABYL7H7ZWVSFHGMXXB3D52ZYANC5G3L", + indexToken: "CCFTOPHUPSUDO2MB4X5D3XYJ2HRJ7NJPAW4UVPAVN7ZLE63EZLSMXDUO", + longToken: "CCFTOPHUPSUDO2MB4X5D3XYJ2HRJ7NJPAW4UVPAVN7ZLE63EZLSMXDUO", + shortToken: "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES", + }, + { + name: "TXLM/TUSDC", + marketToken: "CDIBR7BDCDWGAG3CC6PBKRSLMISPYKNDGE57DCZO5TMTLZK34TMGKFQQ", + indexToken: "CAHNXBBSXVMGI6G3FUBY3OTNWKQ7434FDDEEE7ZT733WIW6NUZL4ONU6", + longToken: "CAHNXBBSXVMGI6G3FUBY3OTNWKQ7434FDDEEE7ZT733WIW6NUZL4ONU6", + shortToken: "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES", + }, + ], +}; + +const CONFIGURED_CONTRACTS = buildConfiguredContracts(SO4_CONTRACTS); +const INTEGER_TYPES = new Set([ + "scvU32", + "scvI32", + "scvU64", + "scvI64", + "scvU128", + "scvI128", + "scvU256", + "scvI256", +]); +const TOKEN_EVENTS = new Set(["transfer", "mint", "burn", "approve", "claim", "faucet_claim"]); + +export async function handleEvent(event: SorobanEvent): Promise { + try { + const decoded = decodeSorobanEvent(event); + if (!decoded) { + return; + } + + if (!isConfiguredContract(decoded.contractAddress)) { + logger.info( + `Skipping SO4 event ${event.id} from unconfigured contract ${decoded.contractAddress}`, + ); + return; + } + + await dispatchEvent(decoded); + } catch (error) { + logger.warn( + JSON.stringify({ + msg: "Skipping malformed SO4 event", + eventId: event.id, + error: error instanceof Error ? error.message : String(error), + }), + ); } - if (toAddressType !== 0 && toAddressType !== 1) { - logger.info(`Unsupported to address type: ${toAddressType}, skipping event`); - return; +} + +export async function dispatchEvent(event: DecodedEvent): Promise { + switch (event.eventName) { + case "mkt_new": + await handleMarketCreated(event); + return; + case "dep_crt": + case "dep_exe": + case "dep_can": + await handleDeposit(event); + return; + case "wth_crt": + case "wth_exe": + case "wth_can": + await handleWithdrawal(event); + return; + case "ord_crt": + case "ord_exe": + case "ord_can": + case "ord_upd": + case "ord_frz": + await handleOrder(event); + return; + case "pos_inc": + case "pos_dec": + await handlePosition(event); + return; + case "liq_req": + case "liq_exe": + await handleLiquidation(event); + return; + case "adl_req": + case "adl_exe": + await handleAdl(event); + return; + case "fee_clm": + await handleFeeClaim(event); + return; + case "fnd_clm": + await handleFundingClaim(event); + return; + case "ui_fee_acc": + case "ui_fee_clm": + case "ui_fee_set": + await handleUiFee(event); + return; + case "ref_reg": + case "ref_set": + case "ref_xfr": + await handleReferral(event); + return; + default: + if (TOKEN_EVENTS.has(event.eventName)) { + await handleTokenEvent(event); + return; + } + logger.info( + JSON.stringify({ + msg: "Skipping unknown SO4 event", + eventId: event.id, + contractId: event.contractAddress, + eventName: event.eventName, + }), + ); } +} - // Stellar StrKey addresses are case-sensitive uppercase base32. Keep the - // canonical decoded form so app filters can match the real account/contract. - const fromAccount = decodeAddress(from); - const toAccount = decodeAddress(to); +export function decodeSorobanEvent(event: SorobanEvent): DecodedEvent | undefined { + const eventName = decodeTopicName(event.topic[0]); + if (!eventName) { + logger.info(`Skipping event ${event.id}: first topic is not a symbol/string`); + return undefined; + } - // Check if event.value is an integer type before converting - const valueType = event.value.switch().name; - const integerTypes = ['scvU32', 'scvI32', 'scvU64', 'scvI64', 'scvU128', 'scvI128', 'scvU256', 'scvI256']; - if (!integerTypes.includes(valueType)) { - logger.info(`Event value is not an integer type: ${valueType}, skipping event`); - return; + const contractAddress = decodeContractId(event.contractId); + if (!contractAddress) { + logger.info(`Skipping event ${event.id}: missing contract id`); + return undefined; } - // Store the emitting contract as its canonical "C..." StrKey so it lines up - // with the contract ids in config and with the decoded from/to addresses. - const contractAddress = event.contractId - ? Address.contract(event.contractId.contractId() as unknown as Buffer).toString() - : ""; - const transfer = MarketTokenTransfer.create({ + return { id: event.id, contractAddress, - from: fromAccount, - to: toAccount, - account: fromAccount, - transferType: decodeTopicName(eventName), - amount: scValToBigInt(event.value).toString(), - ledger: event.ledger!.sequence, + contractKey: CONFIGURED_CONTRACTS.contractKeysByAddress.get(contractAddress), + eventName, + ledger: event.ledger?.sequence ?? 0, timestamp: new Date(event.ledgerClosedAt), - transactionHash: event.txHash, - }); + transactionHash: event.txHash ?? "", + topic: event.topic, + values: decodeTuple(event.value), + }; +} - await transfer.save(); +export function decodeTopicName(scVal: xdr.ScVal | undefined): string | undefined { + if (!scVal) { + return undefined; + } + + const valueType = scVal.switch().name; + if (valueType === "scvSymbol") { + return scVal.sym().toString(); + } + if (valueType === "scvString") { + return scVal.str().toString(); + } + return undefined; } -// scValToNative not works, temp solution -function decodeAddress(scVal: xdr.ScVal): string { - const address = scVal.address(); +export function decodeAddress(scVal: xdr.ScVal | undefined): string | undefined { + if (!scVal || scVal.switch().name !== "scvAddress") { + return undefined; + } - // Must check which arm of the union is set FIRST using switch() - // Calling .accountId() or .contractId() directly throws if that arm isn't set - // ScAddressType enum: 0 = accountId, 1 = contractId + const address = scVal.address(); const addressType = address.switch().value; if (addressType === 0) { return Address.account(address.accountId().ed25519()).toString(); - } else if (addressType === 1) { + } + if (addressType === 1) { return Address.contract(address.contractId() as unknown as Buffer).toString(); } - throw new Error(`Unknown address type: ${addressType}`); + return undefined; } -function decodeTopicName(scVal: xdr.ScVal): string { - const valueType = scVal.switch().name; - if (valueType === "scvSymbol") { - return scVal.sym().toString(); +export function decodeBytesN32(scVal: xdr.ScVal | undefined): string | undefined { + if (!scVal || scVal.switch().name !== "scvBytes") { + return undefined; } - if (valueType === "scvString") { + + const bytes = Buffer.from(scVal.bytes()); + if (bytes.length !== 32) { + return undefined; + } + return bytes.toString("hex"); +} + +export function decodeBoolean(scVal: xdr.ScVal | undefined): boolean | undefined { + if (!scVal) { + return undefined; + } + if (scVal.switch().name === "scvBool") { + return scVal.b(); + } + return undefined; +} + +export function decodeInteger(scVal: xdr.ScVal | undefined): string | undefined { + if (!scVal || !INTEGER_TYPES.has(scVal.switch().name)) { + return undefined; + } + return scValToBigInt(scVal).toString(); +} + +export function decodeTuple(scVal: xdr.ScVal | undefined): DecodedTuple { + const empty = { list: [], named: {} }; + if (!scVal) { + return empty; + } + + if (scVal.switch().name === "scvVec") { + const list = (scVal.vec() ?? []).map((value) => decodeScVal(value)); + return { list, named: namedTuple(list) }; + } + + if (scVal.switch().name === "scvMap") { + const named: Record = {}; + for (const entry of scVal.map() ?? []) { + const key = decodeTopicName(entry.key()) ?? decodeString(entry.key()); + if (key) { + named[key] = decodeScVal(entry.val()); + } + } + return { list: [], named }; + } + + return { list: [decodeScVal(scVal)], named: {} }; +} + +function decodeScVal(scVal: xdr.ScVal): DecodedValue { + const typeName = scVal.switch().name; + if (typeName === "scvSymbol" || typeName === "scvString") { + return decodeString(scVal); + } + if (typeName === "scvAddress") { + return decodeAddress(scVal); + } + if (INTEGER_TYPES.has(typeName)) { + return decodeInteger(scVal); + } + if (typeName === "scvBool") { + return decodeBoolean(scVal); + } + if (typeName === "scvBytes") { + return decodeBytesN32(scVal) ?? Buffer.from(scVal.bytes()).toString("hex"); + } + if (typeName === "scvVec") { + return (scVal.vec() ?? []).map((value) => decodeScVal(value)); + } + if (typeName === "scvMap") { + return decodeTuple(scVal); + } + return typeName; +} + +function decodeString(scVal: xdr.ScVal | undefined): string | undefined { + if (!scVal) { + return undefined; + } + if (scVal.switch().name === "scvString") { return scVal.str().toString(); } - return valueType; + if (scVal.switch().name === "scvSymbol") { + return scVal.sym().toString(); + } + return undefined; +} + +function decodeContractId(contractId: SorobanEvent["contractId"]): string | undefined { + if (!contractId) { + return undefined; + } + + return Address.contract(contractId.contractId() as unknown as Buffer).toString(); +} + +function namedTuple(list: DecodedValue[]): Record { + const named: Record = {}; + for (let index = 0; index < list.length - 1; index += 2) { + const key = list[index]; + if (typeof key === "string") { + named[key] = list[index + 1]; + } + } + return named; +} + +function isConfiguredContract(contractAddress: string): boolean { + return ( + CONFIGURED_CONTRACTS.protocolAddresses.has(contractAddress) || + CONFIGURED_CONTRACTS.tokenAddresses.has(contractAddress) || + CONFIGURED_CONTRACTS.marketTokenAddresses.has(contractAddress) + ); +} + +async function handleMarketCreated(event: DecodedEvent): Promise { + const marketToken = fieldString(event, ["market_token", "marketToken"], 0) ?? event.contractAddress; + const metadata = CONFIGURED_CONTRACTS.marketMetadataByToken.get(marketToken); + const marketKey = fieldString(event, ["market", "market_key", "key"], 1) ?? marketToken; + const id = marketId(marketKey); + const contract = await ensureProtocolContract(event.contractAddress, event); + + await ensureToken(marketToken, event, "market"); + if (metadata) { + await ensureToken(metadata.indexToken, event, "index"); + await ensureToken(metadata.longToken, event, "long"); + await ensureToken(metadata.shortToken, event, "short"); + } + + const market = await upsert(Market, id, { + id, + key: marketKey, + contractId: contract.id, + marketTokenId: marketToken, + indexTokenId: metadata?.indexToken, + longTokenId: metadata?.longToken, + shortTokenId: metadata?.shortToken, + name: fieldString(event, ["name"], 2) ?? metadata?.name, + status: "ACTIVE", + createdBy: fieldString(event, ["account", "creator", "sender"], 3), + createdLedger: event.ledger, + createdTimestamp: event.timestamp, + createdTransactionHash: event.transactionHash, + }); + + const snapshotId = deterministicId("market-config", event.id); + const snapshot = await upsert(MarketConfigSnapshot, snapshotId, { + id: snapshotId, + marketId: market.id, + key: market.key, + version: parseOptionalNumber(fieldString(event, ["version"], 4)), + rawConfig: JSON.stringify(event.values.named), + ledger: event.ledger, + timestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await snapshot.save(); + market.latestConfigSnapshotId = snapshot.id; + await market.save(); +} + +async function handleDeposit(event: DecodedEvent): Promise { + const key = lifecycleKey(event, "deposit"); + const market = await ensureMarketForEvent(event); + const account = requiredAccount(event); + const id = deterministicId("deposit", key); + const status = statusForEvent(event.eventName, "deposit"); + const deposit = await upsert(Deposit, id, { + id, + key, + marketId: market.id, + account, + receiver: fieldString(event, ["receiver"], 3), + status, + longTokenAmount: fieldString(event, ["long_amount", "longTokenAmount"], 4), + shortTokenAmount: fieldString(event, ["short_amount", "shortTokenAmount"], 5), + minMarketTokens: fieldString(event, ["min_market_tokens", "minMarketTokens"], 6), + marketTokenAmount: fieldString(event, ["market_token_amount", "marketTokenAmount"], 7), + executionFee: fieldString(event, ["execution_fee", "executionFee"], 8), + }); + + setLifecycleFields(deposit, event, status); + await deposit.save(); +} + +async function handleWithdrawal(event: DecodedEvent): Promise { + const key = lifecycleKey(event, "withdrawal"); + const market = await ensureMarketForEvent(event); + const account = requiredAccount(event); + const id = deterministicId("withdrawal", key); + const status = statusForEvent(event.eventName, "withdrawal"); + const withdrawal = await upsert(Withdrawal, id, { + id, + key, + marketId: market.id, + account, + receiver: fieldString(event, ["receiver"], 3), + status, + marketTokenAmount: fieldString(event, ["market_token_amount", "marketTokenAmount"], 4), + minLongTokenAmount: fieldString(event, ["min_long_amount", "minLongTokenAmount"], 5), + minShortTokenAmount: fieldString(event, ["min_short_amount", "minShortTokenAmount"], 6), + longTokenAmount: fieldString(event, ["long_amount", "longTokenAmount"], 7), + shortTokenAmount: fieldString(event, ["short_amount", "shortTokenAmount"], 8), + executionFee: fieldString(event, ["execution_fee", "executionFee"], 9), + }); + + setLifecycleFields(withdrawal, event, status); + await withdrawal.save(); +} + +async function handleOrder(event: DecodedEvent): Promise { + const key = lifecycleKey(event, "order"); + const market = await ensureMarketForEvent(event); + const account = requiredAccount(event); + const id = deterministicId("order", key); + const status = statusForEvent(event.eventName, "order"); + const order = await upsert(Order, id, { + id, + key, + marketId: market.id, + account, + receiver: fieldString(event, ["receiver"], 3), + positionKey: fieldString(event, ["position_key", "positionKey"], 4), + orderType: fieldString(event, ["order_type", "orderType"], 5) ?? event.eventName, + status, + isLong: fieldBoolean(event, ["is_long", "isLong"], 6), + collateralTokenId: fieldString(event, ["collateral_token", "collateralToken"], 7), + swapPath: serializeOptional(fieldValue(event, ["swap_path", "swapPath"], 8)), + sizeDeltaUsd: fieldString(event, ["size_delta_usd", "sizeDeltaUsd"], 9), + collateralDeltaAmount: fieldString(event, ["collateral_delta_amount", "collateralDeltaAmount"], 10), + triggerPrice: fieldString(event, ["trigger_price", "triggerPrice"], 11), + acceptablePrice: fieldString(event, ["acceptable_price", "acceptablePrice"], 12), + executionFee: fieldString(event, ["execution_fee", "executionFee"], 13), + referralCode: fieldString(event, ["referral_code", "referralCode"], 14), + }); + + setOrderLifecycleFields(order, event, status); + await order.save(); +} + +async function handlePosition(event: DecodedEvent): Promise { + const market = await ensureMarketForEvent(event); + const account = requiredAccount(event); + const key = fieldString(event, ["position_key", "positionKey", "key"], 0) + ?? `${account}:${market.id}:${fieldString(event, ["collateral_token", "collateralToken"], 3) ?? "collateral"}:${fieldBoolean(event, ["is_long", "isLong"], 4) ? "long" : "short"}`; + const id = deterministicId("position", key); + const isLong = fieldBoolean(event, ["is_long", "isLong"], 4) ?? false; + const position = await upsert(Position, id, { + id, + key, + marketId: market.id, + account, + collateralTokenId: fieldString(event, ["collateral_token", "collateralToken"], 3), + isLong, + status: event.eventName === "pos_dec" ? "DECREASED" : "OPEN", + sizeUsd: fieldString(event, ["next_size_usd", "size_usd", "sizeUsd"], 5), + collateralAmount: fieldString(event, ["next_collateral_amount", "collateral_amount", "collateralAmount"], 6), + averagePrice: fieldString(event, ["average_price", "averagePrice"], 7), + entryFundingRate: fieldString(event, ["entry_funding_rate", "entryFundingRate"], 8), + reserveAmount: fieldString(event, ["reserve_amount", "reserveAmount"], 9), + realizedPnlUsd: fieldString(event, ["realized_pnl_usd", "realizedPnlUsd"], 10), + realizedPnlAmount: fieldString(event, ["realized_pnl_amount", "realizedPnlAmount"], 11), + latestOrderKey: fieldString(event, ["order_key", "orderKey"], 12), + openedLedger: event.eventName === "pos_inc" ? event.ledger : undefined, + openedTimestamp: event.eventName === "pos_inc" ? event.timestamp : undefined, + openedTransactionHash: event.eventName === "pos_inc" ? event.transactionHash : undefined, + updatedLedger: event.ledger, + updatedTimestamp: event.timestamp, + updatedTransactionHash: event.transactionHash, + }); + + await position.save(); + await savePositionChange(event, market.id, position.id, account, event.eventName === "pos_inc" ? "INCREASE" : "DECREASE"); +} + +async function handleLiquidation(event: DecodedEvent): Promise { + const market = await ensureMarketForEvent(event); + const account = requiredAccount(event); + const key = fieldString(event, ["liquidation_key", "position_key", "key"], 0) ?? event.id; + const id = deterministicId("liquidation", key); + const liquidation = await upsert(Liquidation, id, { + id, + key, + marketId: market.id, + positionId: deterministicId("position", fieldString(event, ["position_key", "positionKey"], 1) ?? key), + account, + liquidator: fieldString(event, ["liquidator"], 3), + collateralTokenId: fieldString(event, ["collateral_token", "collateralToken"], 4), + status: event.eventName === "liq_exe" ? "EXECUTED" : "REQUESTED", + isLong: fieldBoolean(event, ["is_long", "isLong"], 5), + sizeDeltaUsd: fieldString(event, ["size_delta_usd", "sizeDeltaUsd"], 6), + collateralLiquidatedAmount: fieldString(event, ["collateral_liquidated_amount", "collateralLiquidatedAmount"], 7), + remainingCollateralAmount: fieldString(event, ["remaining_collateral_amount", "remainingCollateralAmount"], 8), + liquidationPrice: fieldString(event, ["liquidation_price", "liquidationPrice"], 9), + pnlUsd: fieldString(event, ["pnl_usd", "pnlUsd"], 10), + priceImpactUsd: fieldString(event, ["price_impact_usd", "priceImpactUsd"], 11), + liquidationFeeUsd: fieldString(event, ["liquidation_fee_usd", "liquidationFeeUsd"], 12), + ledger: event.ledger, + timestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await liquidation.save(); +} + +async function handleAdl(event: DecodedEvent): Promise { + const market = await ensureMarketForEvent(event); + const account = requiredAccount(event); + const key = fieldString(event, ["adl_key", "position_key", "key"], 0) ?? event.id; + const id = deterministicId("adl", key); + const adl = await upsert(AdlEvent, id, { + id, + key, + marketId: market.id, + positionId: deterministicId("position", fieldString(event, ["position_key", "positionKey"], 1) ?? key), + account, + collateralTokenId: fieldString(event, ["collateral_token", "collateralToken"], 3), + status: event.eventName === "adl_exe" ? "EXECUTED" : "REQUESTED", + isLong: fieldBoolean(event, ["is_long", "isLong"], 4), + sizeReductionUsd: fieldString(event, ["size_reduction_usd", "sizeReductionUsd"], 5), + collateralReductionAmount: fieldString(event, ["collateral_reduction_amount", "collateralReductionAmount"], 6), + executionPrice: fieldString(event, ["execution_price", "executionPrice"], 7), + pnlUsd: fieldString(event, ["pnl_usd", "pnlUsd"], 8), + ledger: event.ledger, + timestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await adl.save(); +} + +async function handleFeeClaim(event: DecodedEvent): Promise { + const id = deterministicId("fee-claim", fieldString(event, ["key"], 0) ?? event.id); + const claim = await upsert(FeeClaim, id, { + id, + key: fieldString(event, ["key"], 0) ?? event.id, + marketId: maybeMarketId(event), + tokenId: fieldString(event, ["token"], 3), + account: requiredAccount(event), + receiver: fieldString(event, ["receiver"], 2), + feeType: fieldString(event, ["fee_type", "feeType"], 4) ?? event.eventName, + amount: fieldString(event, ["amount"], 5) ?? "0", + amountUsd: fieldString(event, ["amount_usd", "amountUsd"], 6), + status: "CLAIMED", + ledger: event.ledger, + timestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await claim.save(); +} + +async function handleFundingClaim(event: DecodedEvent): Promise { + const key = fieldString(event, ["key", "position_key", "positionKey"], 0) ?? event.id; + const id = deterministicId("funding-claim", key); + const claim = await upsert(FundingFeeClaim, id, { + id, + key, + marketId: maybeMarketId(event), + positionId: deterministicId("position", fieldString(event, ["position_key", "positionKey"], 0) ?? key), + account: requiredAccount(event), + receiver: fieldString(event, ["receiver"], 2), + tokenId: fieldString(event, ["token"], 3), + amount: fieldString(event, ["amount"], 4) ?? "0", + amountUsd: fieldString(event, ["amount_usd", "amountUsd"], 5), + status: "CLAIMED", + ledger: event.ledger, + timestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await claim.save(); +} + +async function handleUiFee(event: DecodedEvent): Promise { + const key = fieldString(event, ["key", "order_key", "orderKey"], 0) ?? event.id; + const id = deterministicId("ui-fee", key, event.eventName); + const accrual = await upsert(UiFeeAccrual, id, { + id, + key, + marketId: maybeMarketId(event), + orderId: deterministicId("order", fieldString(event, ["order_key", "orderKey"], 0) ?? key), + account: requiredAccount(event), + uiFeeReceiver: fieldString(event, ["ui_fee_receiver", "uiFeeReceiver", "receiver"], 2) ?? requiredAccount(event), + tokenId: fieldString(event, ["token"], 3), + amount: fieldString(event, ["amount"], 4) ?? "0", + amountUsd: fieldString(event, ["amount_usd", "amountUsd"], 5), + ledger: event.ledger, + timestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await accrual.save(); +} + +async function handleReferral(event: DecodedEvent): Promise { + if (event.eventName === "ref_reg") { + const code = fieldString(event, ["code", "referral_code", "referralCode"], 0) ?? event.id; + const owner = requiredAccount(event); + const referral = await upsert(ReferralCode, deterministicId("referral", code), { + id: deterministicId("referral", code), + code, + owner, + status: "ACTIVE", + createdLedger: event.ledger, + createdTimestamp: event.timestamp, + createdTransactionHash: event.transactionHash, + }); + await referral.save(); + return; + } + + if (event.eventName === "ref_set") { + const code = fieldString(event, ["code", "referral_code", "referralCode"], 1) ?? event.id; + const trader = requiredAccount(event); + const referrer = fieldString(event, ["referrer", "owner"], 2) ?? trader; + await ensureReferralCode(code, referrer, event); + const id = deterministicId("trader-referral", trader); + const link = await upsert(TraderReferral, id, { + id, + trader, + referralCodeId: deterministicId("referral", code), + referrer, + status: "ACTIVE", + createdLedger: event.ledger, + createdTimestamp: event.timestamp, + createdTransactionHash: event.transactionHash, + updatedLedger: event.ledger, + updatedTimestamp: event.timestamp, + updatedTransactionHash: event.transactionHash, + }); + await link.save(); + return; + } + + const code = fieldString(event, ["code", "referral_code", "referralCode"], 0) ?? event.id; + const previousOwner = fieldString(event, ["previous_owner", "previousOwner"], 1) ?? requiredAccount(event); + const newOwner = fieldString(event, ["new_owner", "newOwner"], 2) ?? previousOwner; + const referral = await ensureReferralCode(code, newOwner, event); + referral.owner = newOwner; + await referral.save(); + + const transfer = await upsert(ReferralOwnershipTransfer, deterministicId("referral-transfer", event.id), { + id: deterministicId("referral-transfer", event.id), + referralCodeId: referral.id, + code, + previousOwner, + newOwner, + ledger: event.ledger, + timestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await transfer.save(); +} + +async function handleTokenEvent(event: DecodedEvent): Promise { + const contractAddress = event.contractAddress; + const token = await ensureToken(contractAddress, event, tokenTypeForAddress(contractAddress)); + const amount = fieldString(event, ["amount"], 2) ?? fieldString(event, ["value"], 2) ?? "0"; + const from = fieldString(event, ["from"], 0) ?? decodeAddress(event.topic[1]) ?? zeroForMint(event.eventName); + const to = fieldString(event, ["to"], 1) ?? decodeAddress(event.topic[2]); + const account = fieldString(event, ["account"], 0) ?? from ?? to; + const id = deterministicId("token-event", event.id); + + const transfer = await upsert(MarketTokenTransfer, id, { + id, + tokenId: token.id, + contractAddress, + from, + to, + account, + transferType: event.eventName, + amount, + ledger: event.ledger, + timestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await transfer.save(); +} + +async function savePositionChange( + event: DecodedEvent, + marketIdValue: string, + positionId: string, + account: string, + changeType: string, +): Promise { + const id = deterministicId("position-change", event.id); + const change = await upsert(PositionChange, id, { + id, + key: fieldString(event, ["position_key", "positionKey", "key"], 0) ?? event.id, + marketId: marketIdValue, + positionId, + account, + orderId: fieldString(event, ["order_key", "orderKey"], 12) + ? deterministicId("order", fieldString(event, ["order_key", "orderKey"], 12)!) + : undefined, + changeType, + status: "EXECUTED", + isLong: fieldBoolean(event, ["is_long", "isLong"], 4), + sizeDeltaUsd: fieldString(event, ["size_delta_usd", "sizeDeltaUsd"], 5), + nextSizeUsd: fieldString(event, ["next_size_usd", "nextSizeUsd"], 6), + collateralDeltaAmount: fieldString(event, ["collateral_delta_amount", "collateralDeltaAmount"], 7), + nextCollateralAmount: fieldString(event, ["next_collateral_amount", "nextCollateralAmount"], 8), + executionPrice: fieldString(event, ["execution_price", "executionPrice"], 9), + indexTokenPrice: fieldString(event, ["index_token_price", "indexTokenPrice"], 10), + pnlUsd: fieldString(event, ["pnl_usd", "pnlUsd"], 11), + priceImpactUsd: fieldString(event, ["price_impact_usd", "priceImpactUsd"], 12), + borrowingFeeUsd: fieldString(event, ["borrowing_fee_usd", "borrowingFeeUsd"], 13), + fundingFeeAmount: fieldString(event, ["funding_fee_amount", "fundingFeeAmount"], 14), + positionFeeAmount: fieldString(event, ["position_fee_amount", "positionFeeAmount"], 15), + ledger: event.ledger, + timestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await change.save(); +} + +async function ensureMarketForEvent(event: DecodedEvent): Promise { + const key = fieldString(event, ["market", "market_key", "marketKey"], 1) + ?? firstKnownMarketToken(event) + ?? event.contractAddress; + const id = marketId(key); + const existing = await Market.get(id); + if (existing) { + return existing; + } + + const metadata = CONFIGURED_CONTRACTS.marketMetadataByToken.get(key); + return upsert(Market, id, { + id, + key, + marketTokenId: metadata?.marketToken ?? key, + indexTokenId: metadata?.indexToken, + longTokenId: metadata?.longToken, + shortTokenId: metadata?.shortToken, + name: metadata?.name, + status: "DISCOVERED", + createdLedger: event.ledger, + createdTimestamp: event.timestamp, + createdTransactionHash: event.transactionHash, + }); +} + +async function ensureProtocolContract(address: string, event: DecodedEvent): Promise { + const key = CONFIGURED_CONTRACTS.contractKeysByAddress.get(address) ?? address; + return upsert(ProtocolContract, deterministicId("contract", address), { + id: deterministicId("contract", address), + key, + address, + contractType: key, + name: key, + network: SO4_CONTRACTS.network?.name, + firstSeenLedger: event.ledger, + firstSeenTimestamp: event.timestamp, + transactionHash: event.transactionHash, + }); +} + +async function ensureToken(address: string, event: DecodedEvent, tokenType?: string): Promise { + const token = await upsert(Token, address, { + id: address, + contractId: CONFIGURED_CONTRACTS.protocolAddresses.has(address) + ? deterministicId("contract", address) + : undefined, + address, + symbol: tokenSymbol(address), + tokenType, + firstSeenLedger: event.ledger, + firstSeenTimestamp: event.timestamp, + transactionHash: event.transactionHash, + }); + await token.save(); + return token; +} + +async function ensureReferralCode(code: string, owner: string, event: DecodedEvent): Promise { + const id = deterministicId("referral", code); + const referral = await upsert(ReferralCode, id, { + id, + code, + owner, + status: "ACTIVE", + createdLedger: event.ledger, + createdTimestamp: event.timestamp, + createdTransactionHash: event.transactionHash, + }); + await referral.save(); + return referral; +} + +async function upsert( + entity: EntityClass, + id: string, + record: Record, +): Promise { + const existing = await entity.get(id); + if (existing) { + Object.assign(existing, withoutUndefined(record)); + return existing; + } + + return entity.create(withoutUndefined(record)); +} + +function withoutUndefined(record: Record): Record { + const clean: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) { + clean[key] = value; + } + } + return clean; +} + +function setLifecycleFields( + entity: Deposit | Withdrawal, + event: DecodedEvent, + status: string, +): void { + if (status === "CREATED") { + entity.createdLedger = event.ledger; + entity.createdTimestamp = event.timestamp; + entity.createdTransactionHash = event.transactionHash; + return; + } + if (status === "EXECUTED") { + entity.executedLedger = event.ledger; + entity.executedTimestamp = event.timestamp; + entity.executedTransactionHash = event.transactionHash; + return; + } + entity.cancelledLedger = event.ledger; + entity.cancelledTimestamp = event.timestamp; + entity.cancelledTransactionHash = event.transactionHash; + entity.cancellationReason = fieldString(event, ["reason"], 10); +} + +function setOrderLifecycleFields(entity: Order, event: DecodedEvent, status: string): void { + if (status === "CREATED") { + entity.createdLedger = event.ledger; + entity.createdTimestamp = event.timestamp; + entity.createdTransactionHash = event.transactionHash; + return; + } + if (status === "UPDATED") { + entity.updatedLedger = event.ledger; + entity.updatedTimestamp = event.timestamp; + entity.updatedTransactionHash = event.transactionHash; + return; + } + if (status === "FROZEN") { + entity.frozenLedger = event.ledger; + entity.frozenTimestamp = event.timestamp; + entity.frozenTransactionHash = event.transactionHash; + return; + } + if (status === "EXECUTED") { + entity.executedLedger = event.ledger; + entity.executedTimestamp = event.timestamp; + entity.executedTransactionHash = event.transactionHash; + return; + } + entity.cancelledLedger = event.ledger; + entity.cancelledTimestamp = event.timestamp; + entity.cancelledTransactionHash = event.transactionHash; + entity.cancellationReason = fieldString(event, ["reason"], 14); +} + +function statusForEvent(eventName: string, type: "deposit" | "withdrawal" | "order"): string { + if (eventName.endsWith("_crt")) { + return "CREATED"; + } + if (eventName.endsWith("_exe")) { + return "EXECUTED"; + } + if (eventName.endsWith("_upd")) { + return "UPDATED"; + } + if (eventName.endsWith("_frz")) { + return "FROZEN"; + } + return type === "order" ? "CANCELLED" : "CANCELLED"; +} + +function lifecycleKey(event: DecodedEvent, fallback: string): string { + return fieldString(event, ["key", `${fallback}_key`, `${fallback}Key`], 0) ?? event.id; +} + +function requiredAccount(event: DecodedEvent): string { + return fieldString(event, ["account", "owner", "trader"], 2) + ?? fieldString(event, ["user"], 0) + ?? decodeAddress(event.topic[1]) + ?? event.contractAddress; +} + +function fieldString(event: DecodedEvent, names: string[], index?: number): string | undefined { + const value = fieldValue(event, names, index); + if (typeof value === "string") { + return value; + } + if (typeof value === "boolean") { + return value.toString(); + } + return undefined; +} + +function fieldBoolean(event: DecodedEvent, names: string[], index?: number): boolean | undefined { + const value = fieldValue(event, names, index); + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + return value === "true" ? true : value === "false" ? false : undefined; + } + return undefined; +} + +function fieldValue(event: DecodedEvent, names: string[], index?: number): DecodedValue { + for (const name of names) { + if (event.values.named[name] !== undefined) { + return event.values.named[name]; + } + } + if (index !== undefined) { + return event.values.list[index]; + } + return undefined; +} + +function maybeMarketId(event: DecodedEvent): string | undefined { + const key = fieldString(event, ["market", "market_key", "marketKey"], 1) ?? firstKnownMarketToken(event); + return key ? marketId(key) : undefined; +} + +function marketId(key: string): string { + return deterministicId("market", key); +} + +function deterministicId(...parts: string[]): string { + return parts.filter(Boolean).join(":"); +} + +function firstKnownMarketToken(event: DecodedEvent): string | undefined { + for (const value of event.values.list) { + if (typeof value === "string" && CONFIGURED_CONTRACTS.marketTokenAddresses.has(value)) { + return value; + } + } + return undefined; +} + +function serializeOptional(value: DecodedValue): string | undefined { + if (value === undefined) { + return undefined; + } + return typeof value === "string" ? value : JSON.stringify(value); +} + +function parseOptionalNumber(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function zeroForMint(eventName: string): string | undefined { + return eventName === "mint" ? ZERO_ADDRESS : undefined; +} + +function tokenTypeForAddress(address: string): string | undefined { + if (CONFIGURED_CONTRACTS.marketTokenAddresses.has(address)) { + return "market"; + } + if (CONFIGURED_CONTRACTS.tokenAddresses.has(address)) { + return CONFIGURED_CONTRACTS.contractKeysByAddress.get(address) ?? "token"; + } + return undefined; +} + +function tokenSymbol(address: string): string | undefined { + return CONFIGURED_CONTRACTS.contractKeysByAddress.get(address); +} + +function buildConfiguredContracts(config: ContractConfig): ConfiguredContracts { + const contractKeysByAddress = new Map(); + const protocolAddresses = new Set(); + const tokenAddresses = new Set(); + const marketTokenAddresses = new Set(); + const marketMetadataByToken = new Map(); + + for (const [key, address] of Object.entries(config.contracts ?? {})) { + protocolAddresses.add(address); + contractKeysByAddress.set(address, key); + } + for (const [key, address] of Object.entries(config.tokens ?? {})) { + tokenAddresses.add(address); + contractKeysByAddress.set(address, key); + } + for (const market of config.markets ?? []) { + marketTokenAddresses.add(market.marketToken); + marketMetadataByToken.set(market.marketToken, market); + for (const [key, address] of [ + [`${market.name}:market`, market.marketToken], + [`${market.name}:index`, market.indexToken], + [`${market.name}:long`, market.longToken], + [`${market.name}:short`, market.shortToken], + ]) { + contractKeysByAddress.set(address, contractKeysByAddress.get(address) ?? key); + } + } + + return { + contractKeysByAddress, + protocolAddresses, + tokenAddresses, + marketTokenAddresses, + marketMetadataByToken, + }; } From 6a9e8bb81df4a84c2bf12b84b7f953119b5a6372 Mon Sep 17 00:00:00 2001 From: 0xsteins <0xsteins@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:51:29 +0100 Subject: [PATCH 2/4] Cover SO4 mapping dispatch with unit tests --- apps/s03-indexer/package.json | 2 +- .../s03-indexer/tests/mappingHandlers.test.ts | 223 ++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 apps/s03-indexer/tests/mappingHandlers.test.ts diff --git a/apps/s03-indexer/package.json b/apps/s03-indexer/package.json index 1fc7ca5..2ac0f22 100644 --- a/apps/s03-indexer/package.json +++ b/apps/s03-indexer/package.json @@ -12,7 +12,7 @@ "start:docker": "bun run start", "dev": "bun run codegen && bun run build && bun run start", "prepack": "rm -rf dist && bun run build", - "test": "subql build && subql-node-stellar test" + "test": "subql build && bun test tests" }, "homepage": "https://github.com/subquery/stellar-subql-starter", "repository": "github:subquery/stellar-subql-starter", diff --git a/apps/s03-indexer/tests/mappingHandlers.test.ts b/apps/s03-indexer/tests/mappingHandlers.test.ts new file mode 100644 index 0000000..0fa3fb2 --- /dev/null +++ b/apps/s03-indexer/tests/mappingHandlers.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { dispatchEvent, type DecodedEvent } from "../src/mappings/mappingHandlers"; + +const marketToken = "CCBUUSYZJTGVA6PYUNQDFPZFHTBZ2QSHOUO7YAGRQVA46T3ZLSIYULS4"; +const handlerContract = "CDWOFIP4YQJGMCYAOWLSRBAWN2OTJUG2I5WOFC32O2TX2SRU56RWBE5C"; +const account = "GBXRE3IOBATED5NREPWDFL5GGFL4UEOM2QRKAEWA25JIMUXYTKA74GNU"; + +type StoreBucket = Map>; + +const buckets = new Map(); +const logs: string[] = []; + +beforeEach(() => { + buckets.clear(); + logs.length = 0; + + (globalThis as Record).store = { + async set(entity: string, id: string, value: Record) { + bucket(entity).set(id, { ...value }); + }, + async get(entity: string, id: string) { + return bucket(entity).get(id); + }, + async getOneByField(entity: string, field: string, value: unknown) { + return [...bucket(entity).values()].find((record) => record[field] === value); + }, + async getByField(entity: string, field: string, value: unknown) { + return [...bucket(entity).values()].filter((record) => record[field] === value); + }, + async getByFields() { + return []; + }, + async remove(entity: string, id: string) { + bucket(entity).delete(id); + }, + }; + + (globalThis as Record).logger = { + info(message: string) { + logs.push(message); + }, + warn(message: string) { + logs.push(message); + }, + }; +}); + +describe("SO4 event dispatch", () => { + test("indexes a market creation event idempotently", async () => { + const event = so4Event("mkt_new", { + market_token: marketToken, + market: marketToken, + creator: account, + name: "TETH/TUSDC", + }); + + await dispatchEvent(event); + await dispatchEvent(event); + + expect(records("Market")).toHaveLength(1); + expect(records("MarketConfigSnapshot")).toHaveLength(1); + expect(records("Market")[0].id).toBe(`market:${marketToken}`); + }); + + test("indexes deposit lifecycle updates by deterministic key", async () => { + await dispatchEvent(so4Event("dep_crt", lifecyclePayload("dep-1"))); + await dispatchEvent(so4Event("dep_exe", lifecyclePayload("dep-1"))); + + const [deposit] = records("Deposit"); + expect(records("Deposit")).toHaveLength(1); + expect(deposit.id).toBe("deposit:dep-1"); + expect(deposit.status).toBe("EXECUTED"); + expect(deposit.createdLedger).toBe(100); + expect(deposit.executedLedger).toBe(100); + }); + + test("indexes withdrawal lifecycle updates", async () => { + await dispatchEvent(so4Event("wth_crt", lifecyclePayload("wth-1"))); + await dispatchEvent(so4Event("wth_can", lifecyclePayload("wth-1", { reason: "expired" }))); + + const [withdrawal] = records("Withdrawal"); + expect(records("Withdrawal")).toHaveLength(1); + expect(withdrawal.id).toBe("withdrawal:wth-1"); + expect(withdrawal.status).toBe("CANCELLED"); + expect(withdrawal.cancellationReason).toBe("expired"); + }); + + test("indexes order lifecycle updates", async () => { + await dispatchEvent( + so4Event("ord_crt", lifecyclePayload("ord-1", { order_type: "MARKET", is_long: true })), + ); + await dispatchEvent( + so4Event("ord_upd", lifecyclePayload("ord-1", { order_type: "MARKET", acceptable_price: "2000" })), + ); + + const [order] = records("Order"); + expect(records("Order")).toHaveLength(1); + expect(order.id).toBe("order:ord-1"); + expect(order.status).toBe("UPDATED"); + expect(order.isLong).toBe(true); + expect(order.acceptablePrice).toBe("2000"); + }); + + test("indexes position changes and current position state", async () => { + await dispatchEvent( + so4Event("pos_inc", { + position_key: "pos-1", + market: marketToken, + account, + collateral_token: marketToken, + is_long: true, + next_size_usd: "500000000000000000000000000000000", + }), + ); + + expect(records("Position")).toHaveLength(1); + expect(records("PositionChange")).toHaveLength(1); + expect(records("Position")[0].id).toBe("position:pos-1"); + expect(records("PositionChange")[0].changeType).toBe("INCREASE"); + }); + + test("indexes liquidation and ADL events", async () => { + await dispatchEvent( + so4Event("liq_exe", { + liquidation_key: "liq-1", + market: marketToken, + account, + liquidator: account, + is_long: false, + }), + ); + await dispatchEvent( + so4Event("adl_req", { + adl_key: "adl-1", + market: marketToken, + account, + is_long: true, + }), + ); + + expect(records("Liquidation")[0].status).toBe("EXECUTED"); + expect(records("AdlEvent")[0].status).toBe("REQUESTED"); + }); + + test("indexes fee and referral events", async () => { + await dispatchEvent(so4Event("fee_clm", { key: "fee-1", account, amount: "42" })); + await dispatchEvent(so4Event("ref_reg", { code: "STEINS", account })); + await dispatchEvent(so4Event("ref_set", { trader: account, code: "STEINS", referrer: account })); + + expect(records("FeeClaim")[0].amount).toBe("42"); + expect(records("ReferralCode")[0].code).toBe("STEINS"); + expect(records("TraderReferral")[0].referralCodeId).toBe("referral:STEINS"); + }); + + test("indexes token/faucet transfer-style events", async () => { + await dispatchEvent( + so4Event("transfer", { + from: account, + to: "GCFXWMLJTQG5DGOUBWH4WZRK45ZKQGZBIWOAZ5QIRDR5UPB7YAE5VEW3", + amount: "1000", + }, marketToken), + ); + + const [transfer] = records("MarketTokenTransfer"); + expect(transfer.id).toBe("token-event:event-transfer"); + expect(transfer.transferType).toBe("transfer"); + expect(transfer.amount).toBe("1000"); + }); + + test("logs and skips unknown events", async () => { + await dispatchEvent(so4Event("mystery", {})); + + expect(records("Market")).toHaveLength(0); + expect(logs.some((message) => message.includes("Skipping unknown SO4 event"))).toBe(true); + }); +}); + +function so4Event( + eventName: string, + named: Record, + contractAddress = handlerContract, +): DecodedEvent { + return { + id: `event-${eventName}`, + contractAddress, + eventName, + ledger: 100, + timestamp: new Date("2026-06-24T12:00:00Z"), + transactionHash: `tx-${eventName}`, + topic: [], + values: { + list: Object.values(named), + named, + }, + }; +} + +function lifecyclePayload( + key: string, + extra: Record = {}, +): Record { + return { + key, + market: marketToken, + account, + receiver: account, + amount: "100", + ...extra, + }; +} + +function records(entity: string): Record[] { + return [...bucket(entity).values()]; +} + +function bucket(entity: string): StoreBucket { + let value = buckets.get(entity); + if (!value) { + value = new Map(); + buckets.set(entity, value); + } + return value; +} From bf968b6a3634c34ea8347b2a9f392c7824d66700 Mon Sep 17 00:00:00 2001 From: 0xsteins <0xsteins@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:51:38 +0100 Subject: [PATCH 3/4] Document SO4 indexer entity IDs --- apps/s03-indexer/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/s03-indexer/README.md b/apps/s03-indexer/README.md index 694da52..fb05173 100644 --- a/apps/s03-indexer/README.md +++ b/apps/s03-indexer/README.md @@ -143,6 +143,25 @@ bun run indexer:build The repository uses the root Bun lockfile only. Do not add `package-lock.json`, `yarn.lock`, or `pnpm-lock.yaml` files. +## Event Entity IDs + +SO4 event mappings use deterministic IDs so replaying a ledger updates the same +rows instead of duplicating data. IDs are namespaced by entity and stable +protocol keys where the event exposes them: + +- `market:` +- `deposit:` +- `withdrawal:` +- `order:` +- `position:` +- `liquidation:` +- `adl:` +- `token-event:` + +Immutable event records that do not have a protocol key use the SubQuery event +ID inside a namespace, for example `position-change:` and +`market-config:`. Unknown or malformed events are logged and skipped. + ## Useful Query After the stack is running, try this query in the GraphQL playground: From c344bce8cbf50eb5778ee7f60d276d88e9501d4a Mon Sep 17 00:00:00 2001 From: 0xsteins <0xsteins@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:12:43 +0100 Subject: [PATCH 4/4] Address SO4 indexer review feedback --- apps/s03-indexer/README.md | 16 ++ .../src/mappings/mappingHandlers.ts | 192 +++++++++--------- .../src/test/mappingHandlers.test.ts | 12 -- .../s03-indexer/tests/mappingHandlers.test.ts | 114 ++++++++++- 4 files changed, 220 insertions(+), 114 deletions(-) delete mode 100644 apps/s03-indexer/src/test/mappingHandlers.test.ts diff --git a/apps/s03-indexer/README.md b/apps/s03-indexer/README.md index fb05173..4d7f659 100644 --- a/apps/s03-indexer/README.md +++ b/apps/s03-indexer/README.md @@ -162,6 +162,22 @@ Immutable event records that do not have a protocol key use the SubQuery event ID inside a namespace, for example `position-change:` and `market-config:`. Unknown or malformed events are logged and skipped. +## Event Payload Indices + +SO4 handler contracts publish short-symbol topics with positional tuple payloads. +The indexer decodes `ScMap` payloads by field name, but `ScVec` payloads remain +positional only. The source-verified tuple offsets used by the mappings are: + +- `mkt_new`: `[market_token, index_token, long_token, short_token]` +- `dep_crt`, `wth_crt`, `ord_crt`: `[key, account, market]` +- `dep_exe`, `wth_exe`, `ord_exe`, `ord_can`, `ord_upd`: `[key, account_or_receiver, ...]` +- `pos_inc`: `[position_key, account, size_delta_usd, execution_price]` +- `pos_dec`: `[position_key, account, size_delta_usd, execution_price, pnl_usd]` +- `liq_req`: `[account, market, is_long]` +- `liq_exe`: `[account, market, pnl_usd, execution_price]` +- `adl_req`: `[account, market, is_long, size_delta_usd, pnl_usd]` +- `adl_exe`: `[account, market, size_delta_usd, pnl_usd]` + ## Useful Query After the stack is running, try this query in the GraphQL playground: diff --git a/apps/s03-indexer/src/mappings/mappingHandlers.ts b/apps/s03-indexer/src/mappings/mappingHandlers.ts index 54c1c8e..4907cb5 100644 --- a/apps/s03-indexer/src/mappings/mappingHandlers.ts +++ b/apps/s03-indexer/src/mappings/mappingHandlers.ts @@ -81,57 +81,61 @@ type ContractConfig = { markets?: MarketMetadata[]; }; +declare const require: (path: string) => ContractConfig; + const ZERO_ADDRESS = "00000000000000000000000000000000000000000000000000000000"; -const SO4_CONTRACTS: ContractConfig = { - network: { name: "testnet" }, - contracts: { - role_store: "CBSUAIAMIFFS4AXQYZ7KR7FNO7IMKAPS5WF4DXANVXDTPKH2F7YUIN6Q", - data_store: "CCZ3VKBEDLNBO2JM3EXL3SNBDJOV5BTN52FVQPER7F6D5GCE53PITQ3J", - oracle: "CBEMTV23SIJJBIST3V5HTMWHR4MHYGHNBIG4M26U4LGUJTWZXTFSVQEY", - market_factory: "CBGX3EJFI3JRHSN5B533O2L5P57JFPTCRS55IPWFS5BNDXLJLXDWA5Z2", - deposit_handler: "CDWOFIP4YQJGMCYAOWLSRBAWN2OTJUG2I5WOFC32O2TX2SRU56RWBE5C", - withdrawal_handler: "CBRWM6PNRRFL5RSTJH6HWEXBTMWCGLQRO45NTRDB6BBABWXZ4ZE7DGTO", - order_handler: "CC35OFZVWUTAZPV3B6UKSDVAVORZEWUUMOMTHO33H4YR4C5FKPEFODKY", - liquidation_handler: "CBXUAR5GCHIRFQL75WTZS3FLA6SMWDPIKG4EKNPWVQVNGVFXBHGTJHTM", - adl_handler: "CACFPG3QAKG6DCAJSOP7YGDTM44NV6NPI3SKAG7GUGIV6DMGXPCAMMME", - fee_handler: "CC4P3FJ7EAH6F3RYJPQ2T7VIB4I7UJ4EEYGVWTZVXTAUN647QRVSDHS4", - referral_storage: "CDHTPQO4RRJ6OUBIW3GDXTIVLVOMIKPJC65PGDJH2G5OLDJRE5KTROWK", - reader: "CC6OZUHF3LVO6PNP3V2EB36ORB3YSVYSH3LWD3RFLO4NUO3BYCXSWSYC", - exchange_router: "CBD6BQSQFROWIIT5QCYN7KL5LJJWUIH7CEWUSZIFMUJO6NPXE6CVGYNW", - }, - tokens: { - TUSDC: "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES", - TWBTC: "CCFTOPHUPSUDO2MB4X5D3XYJ2HRJ7NJPAW4UVPAVN7ZLE63EZLSMXDUO", - TETH: "CAJ6BZKGFT47ALGMVFZZGAOXBV2RWIVYVCU4WJCQIURKRNXU346RWVAU", - TXLM: "CAHNXBBSXVMGI6G3FUBY3OTNWKQ7434FDDEEE7ZT733WIW6NUZL4ONU6", - faucet: "CCWXXBKXHHP5DXC6TYVIL22XUNHD5A75O6WM5D2KM5PY45IOV5VDMARJ", - }, - markets: [ - { - name: "TETH/TUSDC", - marketToken: "CCBUUSYZJTGVA6PYUNQDFPZFHTBZ2QSHOUO7YAGRQVA46T3ZLSIYULS4", - indexToken: "CAJ6BZKGFT47ALGMVFZZGAOXBV2RWIVYVCU4WJCQIURKRNXU346RWVAU", - longToken: "CAJ6BZKGFT47ALGMVFZZGAOXBV2RWIVYVCU4WJCQIURKRNXU346RWVAU", - shortToken: "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES", - }, - { - name: "TWBTC/TUSDC", - marketToken: "CDDVSLBGGDV2UOFN5W72R4LW7ABYL7H7ZWVSFHGMXXB3D52ZYANC5G3L", - indexToken: "CCFTOPHUPSUDO2MB4X5D3XYJ2HRJ7NJPAW4UVPAVN7ZLE63EZLSMXDUO", - longToken: "CCFTOPHUPSUDO2MB4X5D3XYJ2HRJ7NJPAW4UVPAVN7ZLE63EZLSMXDUO", - shortToken: "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES", - }, - { - name: "TXLM/TUSDC", - marketToken: "CDIBR7BDCDWGAG3CC6PBKRSLMISPYKNDGE57DCZO5TMTLZK34TMGKFQQ", - indexToken: "CAHNXBBSXVMGI6G3FUBY3OTNWKQ7434FDDEEE7ZT733WIW6NUZL4ONU6", - longToken: "CAHNXBBSXVMGI6G3FUBY3OTNWKQ7434FDDEEE7ZT733WIW6NUZL4ONU6", - shortToken: "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES", - }, - ], -}; +const SO4_CONTRACTS = require("../../config/contracts.testnet.json"); const CONFIGURED_CONTRACTS = buildConfiguredContracts(SO4_CONTRACTS); +const MARKET_CREATED_INDEX = { + marketToken: 0, + indexToken: 1, + longToken: 2, + shortToken: 3, +} as const; +const REQUEST_CREATED_INDEX = { + key: 0, + account: 1, + market: 2, +} as const; +const REQUEST_UPDATED_INDEX = { + key: 0, + account: 1, +} as const; +const POSITION_INCREASE_INDEX = { + key: 0, + account: 1, + sizeDeltaUsd: 2, + executionPrice: 3, +} as const; +const POSITION_DECREASE_INDEX = { + ...POSITION_INCREASE_INDEX, + pnlUsd: 4, +} as const; +const LIQUIDATION_REQUEST_INDEX = { + account: 0, + market: 1, + isLong: 2, +} as const; +const LIQUIDATION_EXECUTED_INDEX = { + account: 0, + market: 1, + pnlUsd: 2, + executionPrice: 3, +} as const; +const ADL_REQUEST_INDEX = { + account: 0, + market: 1, + isLong: 2, + sizeDeltaUsd: 3, + pnlUsd: 4, +} as const; +const ADL_EXECUTED_INDEX = { + account: 0, + market: 1, + sizeDeltaUsd: 2, + pnlUsd: 3, +} as const; const INTEGER_TYPES = new Set([ "scvU32", "scvI32", @@ -331,7 +335,7 @@ export function decodeTuple(scVal: xdr.ScVal | undefined): DecodedTuple { if (scVal.switch().name === "scvVec") { const list = (scVal.vec() ?? []).map((value) => decodeScVal(value)); - return { list, named: namedTuple(list) }; + return { list, named: {} }; } if (scVal.switch().name === "scvMap") { @@ -395,17 +399,6 @@ function decodeContractId(contractId: SorobanEvent["contractId"]): string | unde return Address.contract(contractId.contractId() as unknown as Buffer).toString(); } -function namedTuple(list: DecodedValue[]): Record { - const named: Record = {}; - for (let index = 0; index < list.length - 1; index += 2) { - const key = list[index]; - if (typeof key === "string") { - named[key] = list[index + 1]; - } - } - return named; -} - function isConfiguredContract(contractAddress: string): boolean { return ( CONFIGURED_CONTRACTS.protocolAddresses.has(contractAddress) || @@ -415,9 +408,9 @@ function isConfiguredContract(contractAddress: string): boolean { } async function handleMarketCreated(event: DecodedEvent): Promise { - const marketToken = fieldString(event, ["market_token", "marketToken"], 0) ?? event.contractAddress; + const marketToken = fieldString(event, ["market_token", "marketToken"], MARKET_CREATED_INDEX.marketToken) ?? event.contractAddress; const metadata = CONFIGURED_CONTRACTS.marketMetadataByToken.get(marketToken); - const marketKey = fieldString(event, ["market", "market_key", "key"], 1) ?? marketToken; + const marketKey = fieldString(event, ["market", "market_key", "key"], MARKET_CREATED_INDEX.marketToken) ?? marketToken; const id = marketId(marketKey); const contract = await ensureProtocolContract(event.contractAddress, event); @@ -433,9 +426,9 @@ async function handleMarketCreated(event: DecodedEvent): Promise { key: marketKey, contractId: contract.id, marketTokenId: marketToken, - indexTokenId: metadata?.indexToken, - longTokenId: metadata?.longToken, - shortTokenId: metadata?.shortToken, + indexTokenId: fieldString(event, ["index_token", "indexToken"], MARKET_CREATED_INDEX.indexToken) ?? metadata?.indexToken, + longTokenId: fieldString(event, ["long_token", "longToken"], MARKET_CREATED_INDEX.longToken) ?? metadata?.longToken, + shortTokenId: fieldString(event, ["short_token", "shortToken"], MARKET_CREATED_INDEX.shortToken) ?? metadata?.shortToken, name: fieldString(event, ["name"], 2) ?? metadata?.name, status: "ACTIVE", createdBy: fieldString(event, ["account", "creator", "sender"], 3), @@ -462,8 +455,8 @@ async function handleMarketCreated(event: DecodedEvent): Promise { async function handleDeposit(event: DecodedEvent): Promise { const key = lifecycleKey(event, "deposit"); - const market = await ensureMarketForEvent(event); - const account = requiredAccount(event); + const market = await ensureMarketForEvent(event, REQUEST_CREATED_INDEX.market); + const account = requiredAccount(event, REQUEST_CREATED_INDEX.account); const id = deterministicId("deposit", key); const status = statusForEvent(event.eventName, "deposit"); const deposit = await upsert(Deposit, id, { @@ -471,12 +464,12 @@ async function handleDeposit(event: DecodedEvent): Promise { key, marketId: market.id, account, - receiver: fieldString(event, ["receiver"], 3), + receiver: fieldString(event, ["receiver"], REQUEST_UPDATED_INDEX.account), status, longTokenAmount: fieldString(event, ["long_amount", "longTokenAmount"], 4), shortTokenAmount: fieldString(event, ["short_amount", "shortTokenAmount"], 5), minMarketTokens: fieldString(event, ["min_market_tokens", "minMarketTokens"], 6), - marketTokenAmount: fieldString(event, ["market_token_amount", "marketTokenAmount"], 7), + marketTokenAmount: fieldString(event, ["market_token_amount", "marketTokenAmount"], 2), executionFee: fieldString(event, ["execution_fee", "executionFee"], 8), }); @@ -486,8 +479,8 @@ async function handleDeposit(event: DecodedEvent): Promise { async function handleWithdrawal(event: DecodedEvent): Promise { const key = lifecycleKey(event, "withdrawal"); - const market = await ensureMarketForEvent(event); - const account = requiredAccount(event); + const market = await ensureMarketForEvent(event, REQUEST_CREATED_INDEX.market); + const account = requiredAccount(event, REQUEST_CREATED_INDEX.account); const id = deterministicId("withdrawal", key); const status = statusForEvent(event.eventName, "withdrawal"); const withdrawal = await upsert(Withdrawal, id, { @@ -495,13 +488,13 @@ async function handleWithdrawal(event: DecodedEvent): Promise { key, marketId: market.id, account, - receiver: fieldString(event, ["receiver"], 3), + receiver: fieldString(event, ["receiver"], REQUEST_UPDATED_INDEX.account), status, marketTokenAmount: fieldString(event, ["market_token_amount", "marketTokenAmount"], 4), minLongTokenAmount: fieldString(event, ["min_long_amount", "minLongTokenAmount"], 5), minShortTokenAmount: fieldString(event, ["min_short_amount", "minShortTokenAmount"], 6), - longTokenAmount: fieldString(event, ["long_amount", "longTokenAmount"], 7), - shortTokenAmount: fieldString(event, ["short_amount", "shortTokenAmount"], 8), + longTokenAmount: fieldString(event, ["long_amount", "longTokenAmount"], 2), + shortTokenAmount: fieldString(event, ["short_amount", "shortTokenAmount"], 3), executionFee: fieldString(event, ["execution_fee", "executionFee"], 9), }); @@ -511,8 +504,8 @@ async function handleWithdrawal(event: DecodedEvent): Promise { async function handleOrder(event: DecodedEvent): Promise { const key = lifecycleKey(event, "order"); - const market = await ensureMarketForEvent(event); - const account = requiredAccount(event); + const market = await ensureMarketForEvent(event, REQUEST_CREATED_INDEX.market); + const account = requiredAccount(event, REQUEST_CREATED_INDEX.account); const id = deterministicId("order", key); const status = statusForEvent(event.eventName, "order"); const order = await upsert(Order, id, { @@ -520,7 +513,7 @@ async function handleOrder(event: DecodedEvent): Promise { key, marketId: market.id, account, - receiver: fieldString(event, ["receiver"], 3), + receiver: fieldString(event, ["receiver"], REQUEST_UPDATED_INDEX.account), positionKey: fieldString(event, ["position_key", "positionKey"], 4), orderType: fieldString(event, ["order_type", "orderType"], 5) ?? event.eventName, status, @@ -541,8 +534,8 @@ async function handleOrder(event: DecodedEvent): Promise { async function handlePosition(event: DecodedEvent): Promise { const market = await ensureMarketForEvent(event); - const account = requiredAccount(event); - const key = fieldString(event, ["position_key", "positionKey", "key"], 0) + const account = requiredAccount(event, POSITION_INCREASE_INDEX.account); + const key = fieldString(event, ["position_key", "positionKey", "key"], POSITION_INCREASE_INDEX.key) ?? `${account}:${market.id}:${fieldString(event, ["collateral_token", "collateralToken"], 3) ?? "collateral"}:${fieldBoolean(event, ["is_long", "isLong"], 4) ? "long" : "short"}`; const id = deterministicId("position", key); const isLong = fieldBoolean(event, ["is_long", "isLong"], 4) ?? false; @@ -554,12 +547,12 @@ async function handlePosition(event: DecodedEvent): Promise { collateralTokenId: fieldString(event, ["collateral_token", "collateralToken"], 3), isLong, status: event.eventName === "pos_dec" ? "DECREASED" : "OPEN", - sizeUsd: fieldString(event, ["next_size_usd", "size_usd", "sizeUsd"], 5), + sizeUsd: fieldString(event, ["next_size_usd", "size_usd", "sizeUsd"], POSITION_INCREASE_INDEX.sizeDeltaUsd), collateralAmount: fieldString(event, ["next_collateral_amount", "collateral_amount", "collateralAmount"], 6), averagePrice: fieldString(event, ["average_price", "averagePrice"], 7), entryFundingRate: fieldString(event, ["entry_funding_rate", "entryFundingRate"], 8), reserveAmount: fieldString(event, ["reserve_amount", "reserveAmount"], 9), - realizedPnlUsd: fieldString(event, ["realized_pnl_usd", "realizedPnlUsd"], 10), + realizedPnlUsd: fieldString(event, ["realized_pnl_usd", "realizedPnlUsd"], POSITION_DECREASE_INDEX.pnlUsd), realizedPnlAmount: fieldString(event, ["realized_pnl_amount", "realizedPnlAmount"], 11), latestOrderKey: fieldString(event, ["order_key", "orderKey"], 12), openedLedger: event.eventName === "pos_inc" ? event.ledger : undefined, @@ -575,8 +568,8 @@ async function handlePosition(event: DecodedEvent): Promise { } async function handleLiquidation(event: DecodedEvent): Promise { - const market = await ensureMarketForEvent(event); - const account = requiredAccount(event); + const market = await ensureMarketForEvent(event, LIQUIDATION_REQUEST_INDEX.market); + const account = requiredAccount(event, LIQUIDATION_REQUEST_INDEX.account); const key = fieldString(event, ["liquidation_key", "position_key", "key"], 0) ?? event.id; const id = deterministicId("liquidation", key); const liquidation = await upsert(Liquidation, id, { @@ -588,12 +581,12 @@ async function handleLiquidation(event: DecodedEvent): Promise { liquidator: fieldString(event, ["liquidator"], 3), collateralTokenId: fieldString(event, ["collateral_token", "collateralToken"], 4), status: event.eventName === "liq_exe" ? "EXECUTED" : "REQUESTED", - isLong: fieldBoolean(event, ["is_long", "isLong"], 5), + isLong: fieldBoolean(event, ["is_long", "isLong"], LIQUIDATION_REQUEST_INDEX.isLong), sizeDeltaUsd: fieldString(event, ["size_delta_usd", "sizeDeltaUsd"], 6), collateralLiquidatedAmount: fieldString(event, ["collateral_liquidated_amount", "collateralLiquidatedAmount"], 7), remainingCollateralAmount: fieldString(event, ["remaining_collateral_amount", "remainingCollateralAmount"], 8), - liquidationPrice: fieldString(event, ["liquidation_price", "liquidationPrice"], 9), - pnlUsd: fieldString(event, ["pnl_usd", "pnlUsd"], 10), + liquidationPrice: fieldString(event, ["liquidation_price", "liquidationPrice"], LIQUIDATION_EXECUTED_INDEX.executionPrice), + pnlUsd: fieldString(event, ["pnl_usd", "pnlUsd"], LIQUIDATION_EXECUTED_INDEX.pnlUsd), priceImpactUsd: fieldString(event, ["price_impact_usd", "priceImpactUsd"], 11), liquidationFeeUsd: fieldString(event, ["liquidation_fee_usd", "liquidationFeeUsd"], 12), ledger: event.ledger, @@ -604,8 +597,9 @@ async function handleLiquidation(event: DecodedEvent): Promise { } async function handleAdl(event: DecodedEvent): Promise { - const market = await ensureMarketForEvent(event); - const account = requiredAccount(event); + const adlIndex = event.eventName === "adl_exe" ? ADL_EXECUTED_INDEX : ADL_REQUEST_INDEX; + const market = await ensureMarketForEvent(event, adlIndex.market); + const account = requiredAccount(event, adlIndex.account); const key = fieldString(event, ["adl_key", "position_key", "key"], 0) ?? event.id; const id = deterministicId("adl", key); const adl = await upsert(AdlEvent, id, { @@ -616,11 +610,11 @@ async function handleAdl(event: DecodedEvent): Promise { account, collateralTokenId: fieldString(event, ["collateral_token", "collateralToken"], 3), status: event.eventName === "adl_exe" ? "EXECUTED" : "REQUESTED", - isLong: fieldBoolean(event, ["is_long", "isLong"], 4), - sizeReductionUsd: fieldString(event, ["size_reduction_usd", "sizeReductionUsd"], 5), + isLong: event.eventName === "adl_req" ? fieldBoolean(event, ["is_long", "isLong"], ADL_REQUEST_INDEX.isLong) : undefined, + sizeReductionUsd: fieldString(event, ["size_reduction_usd", "sizeReductionUsd"], adlIndex.sizeDeltaUsd), collateralReductionAmount: fieldString(event, ["collateral_reduction_amount", "collateralReductionAmount"], 6), executionPrice: fieldString(event, ["execution_price", "executionPrice"], 7), - pnlUsd: fieldString(event, ["pnl_usd", "pnlUsd"], 8), + pnlUsd: fieldString(event, ["pnl_usd", "pnlUsd"], adlIndex.pnlUsd), ledger: event.ledger, timestamp: event.timestamp, transactionHash: event.transactionHash, @@ -784,7 +778,7 @@ async function savePositionChange( const id = deterministicId("position-change", event.id); const change = await upsert(PositionChange, id, { id, - key: fieldString(event, ["position_key", "positionKey", "key"], 0) ?? event.id, + key: fieldString(event, ["position_key", "positionKey", "key"], POSITION_INCREASE_INDEX.key) ?? event.id, marketId: marketIdValue, positionId, account, @@ -794,13 +788,13 @@ async function savePositionChange( changeType, status: "EXECUTED", isLong: fieldBoolean(event, ["is_long", "isLong"], 4), - sizeDeltaUsd: fieldString(event, ["size_delta_usd", "sizeDeltaUsd"], 5), + sizeDeltaUsd: fieldString(event, ["size_delta_usd", "sizeDeltaUsd"], POSITION_INCREASE_INDEX.sizeDeltaUsd), nextSizeUsd: fieldString(event, ["next_size_usd", "nextSizeUsd"], 6), collateralDeltaAmount: fieldString(event, ["collateral_delta_amount", "collateralDeltaAmount"], 7), nextCollateralAmount: fieldString(event, ["next_collateral_amount", "nextCollateralAmount"], 8), - executionPrice: fieldString(event, ["execution_price", "executionPrice"], 9), + executionPrice: fieldString(event, ["execution_price", "executionPrice"], POSITION_INCREASE_INDEX.executionPrice), indexTokenPrice: fieldString(event, ["index_token_price", "indexTokenPrice"], 10), - pnlUsd: fieldString(event, ["pnl_usd", "pnlUsd"], 11), + pnlUsd: fieldString(event, ["pnl_usd", "pnlUsd"], POSITION_DECREASE_INDEX.pnlUsd), priceImpactUsd: fieldString(event, ["price_impact_usd", "priceImpactUsd"], 12), borrowingFeeUsd: fieldString(event, ["borrowing_fee_usd", "borrowingFeeUsd"], 13), fundingFeeAmount: fieldString(event, ["funding_fee_amount", "fundingFeeAmount"], 14), @@ -812,8 +806,8 @@ async function savePositionChange( await change.save(); } -async function ensureMarketForEvent(event: DecodedEvent): Promise { - const key = fieldString(event, ["market", "market_key", "marketKey"], 1) +async function ensureMarketForEvent(event: DecodedEvent, marketIndex = 1): Promise { + const key = fieldString(event, ["market", "market_key", "marketKey"], marketIndex) ?? firstKnownMarketToken(event) ?? event.contractAddress; const id = marketId(key); @@ -983,8 +977,8 @@ function lifecycleKey(event: DecodedEvent, fallback: string): string { return fieldString(event, ["key", `${fallback}_key`, `${fallback}Key`], 0) ?? event.id; } -function requiredAccount(event: DecodedEvent): string { - return fieldString(event, ["account", "owner", "trader"], 2) +function requiredAccount(event: DecodedEvent, accountIndex = 2): string { + return fieldString(event, ["account", "owner", "trader"], accountIndex) ?? fieldString(event, ["user"], 0) ?? decodeAddress(event.topic[1]) ?? event.contractAddress; diff --git a/apps/s03-indexer/src/test/mappingHandlers.test.ts b/apps/s03-indexer/src/test/mappingHandlers.test.ts deleted file mode 100644 index 42f41c3..0000000 --- a/apps/s03-indexer/src/test/mappingHandlers.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { subqlTest } from "@subql/testing"; - -/* -// https://academy.subquery.network/build/testing.html -subqlTest( - "testName", // test name - 1000003, // block height to process - [], // dependent entities - [], // expected entities - "handleEvent" //handler name -); -*/ diff --git a/apps/s03-indexer/tests/mappingHandlers.test.ts b/apps/s03-indexer/tests/mappingHandlers.test.ts index 0fa3fb2..1e6bc2e 100644 --- a/apps/s03-indexer/tests/mappingHandlers.test.ts +++ b/apps/s03-indexer/tests/mappingHandlers.test.ts @@ -1,9 +1,24 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { dispatchEvent, type DecodedEvent } from "../src/mappings/mappingHandlers"; +import { + decodeAddress, + decodeBoolean, + decodeBytesN32, + decodeInteger, + decodeSorobanEvent, + decodeTopicName, + decodeTuple, + dispatchEvent, + type DecodedEvent, +} from "../src/mappings/mappingHandlers"; +import { Address, Keypair, nativeToScVal, xdr } from "@stellar/stellar-sdk"; const marketToken = "CCBUUSYZJTGVA6PYUNQDFPZFHTBZ2QSHOUO7YAGRQVA46T3ZLSIYULS4"; +const indexToken = "CAJ6BZKGFT47ALGMVFZZGAOXBV2RWIVYVCU4WJCQIURKRNXU346RWVAU"; +const shortToken = "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES"; const handlerContract = "CDWOFIP4YQJGMCYAOWLSRBAWN2OTJUG2I5WOFC32O2TX2SRU56RWBE5C"; -const account = "GBXRE3IOBATED5NREPWDFL5GGFL4UEOM2QRKAEWA25JIMUXYTKA74GNU"; +const marketFactoryContract = "CBGX3EJFI3JRHSN5B533O2L5P57JFPTCRS55IPWFS5BNDXLJLXDWA5Z2"; +const account = Keypair.random().publicKey(); +const receiver = Keypair.random().publicKey(); type StoreBucket = Map>; @@ -46,6 +61,87 @@ beforeEach(() => { }); describe("SO4 event dispatch", () => { + test("decodes primitive ScVal fixtures", () => { + const keyHex = "11".repeat(32); + + expect(decodeTopicName(xdr.ScVal.scvSymbol("pos_dec"))).toBe("pos_dec"); + expect(decodeAddress(Address.fromString(account).toScVal())).toBe(account); + expect(decodeAddress(Address.fromString(marketToken).toScVal())).toBe(marketToken); + expect(decodeBytesN32(xdr.ScVal.scvBytes(Buffer.from(keyHex, "hex")))).toBe(keyHex); + expect(decodeBoolean(xdr.ScVal.scvBool(true))).toBe(true); + expect(decodeInteger(nativeToScVal(-7n, { type: "i128" }))).toBe("-7"); + expect(decodeInteger(nativeToScVal(42n, { type: "u128" }))).toBe("42"); + }); + + test("decodes ScMap payloads as named fields and Vec payloads as positional only", () => { + const mapTuple = decodeTuple( + xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("amount"), + val: nativeToScVal(42n, { type: "u128" }), + }), + ]), + ); + const vecTuple = decodeTuple( + xdr.ScVal.scvVec([ + xdr.ScVal.scvSymbol("amount"), + nativeToScVal(42n, { type: "u128" }), + ]), + ); + + expect(mapTuple.named.amount).toBe("42"); + expect(mapTuple.list).toHaveLength(0); + expect(vecTuple.list).toEqual(["amount", "42"]); + expect(vecTuple.named).toEqual({}); + }); + + test("decodes raw market event payloads with empty named fields", () => { + const decoded = decodeSorobanEvent( + rawEvent( + "mkt_new", + xdr.ScVal.scvVec([ + Address.fromString(marketToken).toScVal(), + Address.fromString(indexToken).toScVal(), + Address.fromString(indexToken).toScVal(), + Address.fromString(shortToken).toScVal(), + ]), + marketFactoryContract, + ), + ); + + expect(decoded?.eventName).toBe("mkt_new"); + expect(decoded?.contractAddress).toBe(marketFactoryContract); + expect(decoded?.values.named).toEqual({}); + expect(decoded?.values.list).toEqual([marketToken, indexToken, indexToken, shortToken]); + }); + + test("indexes a raw positional position decrease event with source-verified indices", async () => { + const positionKey = "22".repeat(32); + const decoded = decodeSorobanEvent( + rawEvent( + "pos_dec", + xdr.ScVal.scvVec([ + xdr.ScVal.scvBytes(Buffer.from(positionKey, "hex")), + Address.fromString(account).toScVal(), + nativeToScVal(500n, { type: "i128" }), + nativeToScVal(2000n, { type: "i128" }), + nativeToScVal(-25n, { type: "i128" }), + ]), + ), + ); + + expect(decoded?.values.named).toEqual({}); + await dispatchEvent(decoded!); + + const [position] = records("Position"); + const [change] = records("PositionChange"); + expect(position.id).toBe(`position:${positionKey}`); + expect(position.account).toBe(account); + expect(change.sizeDeltaUsd).toBe("500"); + expect(change.executionPrice).toBe("2000"); + expect(change.pnlUsd).toBe("-25"); + }); + test("indexes a market creation event idempotently", async () => { const event = so4Event("mkt_new", { market_token: marketToken, @@ -156,7 +252,7 @@ describe("SO4 event dispatch", () => { await dispatchEvent( so4Event("transfer", { from: account, - to: "GCFXWMLJTQG5DGOUBWH4WZRK45ZKQGZBIWOAZ5QIRDR5UPB7YAE5VEW3", + to: receiver, amount: "1000", }, marketToken), ); @@ -221,3 +317,15 @@ function bucket(entity: string): StoreBucket { } return value; } + +function rawEvent(eventName: string, value: xdr.ScVal, contractAddress = handlerContract) { + return { + id: `raw-${eventName}`, + topic: [xdr.ScVal.scvSymbol(eventName)], + value, + contractId: Address.fromString(contractAddress).toScAddress(), + ledger: { sequence: 100 }, + ledgerClosedAt: "2026-06-24T12:00:00Z", + txHash: `tx-${eventName}`, + } as never; +}