diff --git a/src/clients/validator-api/schemas/api/scan-proxy.ts b/src/clients/validator-api/schemas/api/scan-proxy.ts index ace475ee..639e6f7b 100644 --- a/src/clients/validator-api/schemas/api/scan-proxy.ts +++ b/src/clients/validator-api/schemas/api/scan-proxy.ts @@ -24,20 +24,15 @@ export const GetMemberTrafficStatusResponseSchema = z.object({ export type GetMemberTrafficStatusResponse = z.infer; // Mining Rounds Schemas +// Canonical format uses snake_case (Validator API convention) export const OpenMiningRoundSchema = z.object({ contract: z.object({ contract_id: z.string(), template_id: z.string(), created_event_blob: z.string(), payload: z.object({ - opensAt: z.string().optional(), - roundNumber: z.number().optional(), - round_number: z.number().optional(), - round: z - .object({ - number: z.string().optional(), - }) - .optional(), + opensAt: z.string(), + round_number: z.number(), }), }), domain_id: z.string(), @@ -45,12 +40,7 @@ export const OpenMiningRoundSchema = z.object({ export const IssuingMiningRoundSchema = z.object({ round_number: z.number(), - contract_id: z.string().optional(), - contract: z - .object({ - contract_id: z.string(), - }) - .optional(), + contract_id: z.string(), }); export const GetOpenAndIssuingMiningRoundsResponseSchema = z.object({ diff --git a/src/utils/amulet/get-amulets-for-transfer.ts b/src/utils/amulet/get-amulets-for-transfer.ts index 5d280b2d..cada306c 100644 --- a/src/utils/amulet/get-amulets-for-transfer.ts +++ b/src/utils/amulet/get-amulets-for-transfer.ts @@ -38,23 +38,6 @@ export interface GetAmuletsForTransferParams { includeAllTransferInputs?: boolean; } -/** Legacy contract format for backward compatibility */ -interface LegacyContract { - payload?: Record; - contract?: { - template_id?: string; - contract_id?: string; - payload?: Record; - contract?: { - template_id?: string; - contract_id?: string; - payload?: Record; - }; - }; - template_id?: string; - contract_id?: string; -} - /** Internal contract representation with extracted data */ interface ContractData { contractId: string; @@ -81,14 +64,6 @@ function isJsActiveContractItem(ctr: unknown): ctr is JsGetActiveContractsRespon return 'createdEvent' in jsActive; } -/** Type guard to check if a contract is a LegacyContract with contract property */ -function isLegacyContractWithContract( - ctr: unknown -): ctr is LegacyContract & { contract: NonNullable } { - if (!isRecord(ctr)) return false; - return isRecord(ctr['contract']); -} - /** Safely extract a string from an unknown value */ function extractString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; @@ -100,6 +75,40 @@ function extractNumericValue(value: unknown): string | number | undefined { return undefined; } +/** + * Extract the effective amount from a contract payload. For Amulets: amount is nested as { initialAmount: string } For + * Coupons: amount is directly a string + */ +function extractAmount(payload: Record, templateId: string): string { + if (templateId.includes('AppRewardCoupon') || templateId.includes('ValidatorRewardCoupon')) { + // For coupons, amount is directly in payload + const amount = extractNumericValue(payload['amount']); + return amount !== undefined ? String(amount) : '0'; + } + + // For amulets, amount is nested: { initialAmount: string } + const amountObj = payload['amount']; + if (isRecord(amountObj)) { + const initialAmount = extractNumericValue(amountObj['initialAmount']); + return initialAmount !== undefined ? String(initialAmount) : '0'; + } + + // Direct amount value (shouldn't happen with current API but handle gracefully) + const directAmount = extractNumericValue(amountObj); + return directAmount !== undefined ? String(directAmount) : '0'; +} + +/** Extract the owner from a contract payload. For Amulets: owner field For Coupons: beneficiary or provider field */ +function extractOwner(payload: Record, templateId: string): string { + if (templateId.includes('AppRewardCoupon') || templateId.includes('ValidatorRewardCoupon')) { + // For coupons, beneficiary is optional and falls back to provider + return extractString(payload['beneficiary']) ?? extractString(payload['provider']) ?? ''; + } + + // For amulets, use owner field + return extractString(payload['owner']) ?? ''; +} + /** * Gets unlocked amulets owned by the sender party that can be used for transfers. Optionally returns all valid transfer * inputs (Amulet, AppRewardCoupon, ValidatorRewardCoupon). @@ -133,94 +142,37 @@ export async function getAmuletsForTransfer(params: GetAmuletsForTransferParams) const allContracts: ContractData[] = []; const contractsArr = Array.isArray(activeContracts) ? activeContracts : []; - contractsArr.forEach((ctr) => { - let payload: Record | undefined; - let templateId: string | undefined; - let contractId: string | undefined; - - // Use type guards for proper runtime validation - if (isJsActiveContractItem(ctr)) { - const jsActiveContract = ctr.contractEntry['JsActiveContract']; - const { createdEvent } = jsActiveContract; - payload = createdEvent.createArgument; - ({ templateId, contractId } = createdEvent); - } else if (isLegacyContractWithContract(ctr)) { - const { contract } = ctr; - ({ payload } = contract); - templateId = contract.contract?.template_id ?? contract.template_id; - contractId = contract.contract?.contract_id ?? contract.contract_id; + for (const ctr of contractsArr) { + // Only handle the canonical JsActiveContract format + if (!isJsActiveContractItem(ctr)) { + continue; } - if (!payload || !templateId || !contractId) return; + const { createdEvent } = ctr.contractEntry.JsActiveContract; + const { templateId, contractId, createArgument: payload } = createdEvent; + + // Skip contracts with missing required fields + if (!contractId || !templateId || !payload) { + continue; + } // Filter for valid transfer input contracts const isUnlockedAmulet = templateId.includes('Splice.Amulet:Amulet') && !templateId.includes('LockedAmulet'); const isAppRewardCoupon = templateId.includes('AppRewardCoupon'); const isValidatorRewardCoupon = templateId.includes('ValidatorRewardCoupon'); - if (!isUnlockedAmulet && !isAppRewardCoupon && !isValidatorRewardCoupon) return; - - allContracts.push({ contractId, templateId, payload }); - }); - - // Helper to extract owner/beneficiary and numeric amount from diverse contract shapes - const extract = (contract: ContractData | LegacyContract) => { - // Get payload using type-safe access - let payload: Record; - if ('templateId' in contract && 'payload' in contract) { - // ContractData - has payload directly - ({ payload } = contract); - } else if ('contract' in contract) { - // LegacyContract with nested structure - payload = contract.contract.contract?.payload ?? contract.contract.payload ?? {}; - } else { - payload = {}; - } - - // Get templateId using type-safe access - const templateId = 'templateId' in contract ? contract.templateId : undefined; - - // Extract owner/beneficiary based on contract type using safe extraction - let ownerFull = ''; - if (templateId?.includes('AppRewardCoupon') || templateId?.includes('ValidatorRewardCoupon')) { - // For coupons, beneficiary is optional and falls back to provider - ownerFull = extractString(payload['beneficiary']) ?? extractString(payload['provider']) ?? ''; - } else { - // For amulets, use owner field - check multiple possible locations - ownerFull = - extractString(payload['owner']) ?? - extractString(payload['partyId']) ?? - extractString(payload['party_id']) ?? - ''; - } - - // Extract amount based on contract type - let rawAmount: string | number = '0'; - if (templateId?.includes('AppRewardCoupon') || templateId?.includes('ValidatorRewardCoupon')) { - // For coupons, amount is directly in payload - rawAmount = extractNumericValue(payload['amount']) ?? '0'; - } else { - // For amulets, amount might be nested - const rawAmountCandidate = - payload['amount'] ?? payload['effective_amount'] ?? payload['effectiveAmount'] ?? payload['initialAmount']; - - const directValue = extractNumericValue(rawAmountCandidate); - if (directValue !== undefined) { - rawAmount = directValue; - } else if (isRecord(rawAmountCandidate)) { - // Amount might be nested in an object with initialAmount - rawAmount = extractNumericValue(rawAmountCandidate['initialAmount']) ?? '0'; - } + if (!isUnlockedAmulet && !isAppRewardCoupon && !isValidatorRewardCoupon) { + continue; } - const numericAmount = typeof rawAmount === 'number' ? rawAmount : parseFloat(rawAmount); - return { owner: ownerFull, numericAmount }; - }; + allContracts.push({ contractId, templateId, payload }); + } - // Filter contracts owned by sender (readAs[0]) and with positive balance + // Filter contracts owned by sender and with positive balance const partyContracts = allContracts.filter((c) => { - const { owner, numericAmount } = extract(c); - return numericAmount > 0 && owner === senderParty; + const owner = extractOwner(c.payload, c.templateId); + const amount = parseFloat(extractAmount(c.payload, c.templateId)); + return amount > 0 && owner === senderParty; }); if (partyContracts.length === 0) { @@ -228,38 +180,17 @@ export async function getAmuletsForTransfer(params: GetAmuletsForTransferParams) } // Sort biggest → smallest so we pick high-value contracts first - partyContracts.sort((a, b) => extract(b).numericAmount - extract(a).numericAmount); - - // Map to the structure expected by buildAmuletInputs (maintaining backward compatibility) - const result = partyContracts.map((c) => { - const { payload, templateId } = c; - - // Extract amount based on contract type using safe extraction - let effectiveAmount = '0'; - if (templateId.includes('AppRewardCoupon') || templateId.includes('ValidatorRewardCoupon')) { - // For coupons, amount is directly in payload - const couponAmount = extractNumericValue(payload['amount']); - effectiveAmount = couponAmount !== undefined ? String(couponAmount) : '0'; - } else { - // For amulets, amount might be nested - const amtObj = payload['amount']; - if (typeof amtObj === 'string') { - effectiveAmount = amtObj; - } else if (typeof amtObj === 'number') { - effectiveAmount = String(amtObj); - } else if (isRecord(amtObj)) { - const nestedAmount = extractNumericValue(amtObj['initialAmount']); - effectiveAmount = nestedAmount !== undefined ? String(nestedAmount) : '0'; - } - } - - return { - contractId: c.contractId, - templateId: c.templateId, - effectiveAmount, - owner: extract(c).owner, - }; + partyContracts.sort((a, b) => { + const amountA = parseFloat(extractAmount(a.payload, a.templateId)); + const amountB = parseFloat(extractAmount(b.payload, b.templateId)); + return amountB - amountA; }); - return result; + // Map to the output structure + return partyContracts.map((c) => ({ + contractId: c.contractId, + templateId: c.templateId, + effectiveAmount: extractAmount(c.payload, c.templateId), + owner: extractOwner(c.payload, c.templateId), + })); } diff --git a/src/utils/contracts/findCreatedEvent.ts b/src/utils/contracts/findCreatedEvent.ts index ea4062eb..b80145f7 100644 --- a/src/utils/contracts/findCreatedEvent.ts +++ b/src/utils/contracts/findCreatedEvent.ts @@ -1,16 +1,21 @@ import { type SubmitAndWaitForTransactionTreeResponse } from '../../clients/ledger-json-api/operations'; +/** Canonical CreatedTreeEvent value structure from the Ledger JSON API */ export interface CreatedTreeEventValue { contractId: string; templateId: string; - contractKey?: unknown; - createArgument?: unknown; - createdEventBlob?: string; - witnessParties?: string[]; - signatories?: string[]; - observers?: string[]; - createdAt?: string; - packageName?: string; + contractKey: string | null; + createArgument: Record; + createdEventBlob: string; + witnessParties: string[]; + signatories: string[]; + observers: string[]; + createdAt: string; + packageName: string; + offset: number; + nodeId: number; + interfaceViews: string[]; + implementedInterfaces?: string[]; } export interface CreatedTreeEventWrapper { @@ -29,18 +34,15 @@ export function findCreatedEventByTemplateId( response: SubmitAndWaitForTransactionTreeResponse, expectedTemplateId: string ): CreatedTreeEventWrapper | undefined { - // Handle both direct structure and nested transaction structure - interface TransactionTreeStructure { - eventsById?: Record; - transaction?: { - eventsById?: Record; - }; - } + // Canonical structure: transactionTree.eventsById + const { transactionTree } = response; - const transactionTree = response.transactionTree as TransactionTreeStructure | undefined; - const eventsById = transactionTree?.eventsById ?? transactionTree?.transaction?.eventsById ?? {}; + const { eventsById } = transactionTree as { eventsById?: Record }; + if (!eventsById || typeof eventsById !== 'object') { + return undefined; + } - // Extract the part after the first ':' from the expected template ID + // Extract the part after the first ':' from the expected template ID for matching const expectedTemplateIdSuffix = expectedTemplateId.includes(':') ? expectedTemplateId.substring(expectedTemplateId.indexOf(':') + 1) : expectedTemplateId; diff --git a/src/utils/mining/mining-rounds.ts b/src/utils/mining/mining-rounds.ts index 45a7566d..7d4610fe 100644 --- a/src/utils/mining/mining-rounds.ts +++ b/src/utils/mining/mining-rounds.ts @@ -16,18 +16,9 @@ async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** Extract round number from a mining round object */ +/** Extract round number from a mining round object (canonical snake_case format) */ function getRoundNumber(miningRound: OpenMiningRound): number { - try { - // Try to get round number from various possible locations - return ( - miningRound.contract.payload.roundNumber ?? - miningRound.contract.payload.round_number ?? - (miningRound.contract.payload.round?.number ? parseInt(miningRound.contract.payload.round.number, 10) : 0) - ); - } catch { - return 0; - } + return miningRound.contract.payload.round_number; } /** Find the latest mining round from a list of open mining rounds */ @@ -103,7 +94,7 @@ export async function getCurrentMiningRoundContext(validatorClient: MiningRoundC const issuingMiningRounds = miningRoundsResponse.issuing_mining_rounds.map((round: IssuingMiningRound) => ({ round: round.round_number, - contractId: round.contract_id ?? round.contract?.contract_id ?? '', + contractId: round.contract_id, })); return { diff --git a/test/unit/amulet/get-amulets-for-transfer.test.ts b/test/unit/amulet/get-amulets-for-transfer.test.ts index 11a44940..4499831d 100644 --- a/test/unit/amulet/get-amulets-for-transfer.test.ts +++ b/test/unit/amulet/get-amulets-for-transfer.test.ts @@ -6,7 +6,7 @@ const createMockLedgerClient = (activeContracts: unknown[]): LedgerJsonApiClient getActiveContracts: jest.fn().mockResolvedValue(activeContracts), }) as unknown as LedgerJsonApiClient; -// New format: JsActiveContract +// Canonical JsActiveContract format const createJsActiveContract = (contractId: string, templateId: string, owner: string, amount: string) => ({ contractEntry: { JsActiveContract: { @@ -22,18 +22,6 @@ const createJsActiveContract = (contractId: string, templateId: string, owner: s }, }); -// Legacy format -const createLegacyContract = (contractId: string, templateId: string, owner: string, amount: string) => ({ - contract: { - contract_id: contractId, - template_id: templateId, - payload: { - owner, - amount: { initialAmount: amount }, - }, - }, -}); - // Contract with numeric initialAmount (number instead of string) const createJsActiveContractWithNumericAmount = ( contractId: string, @@ -276,37 +264,6 @@ describe('getAmuletsForTransfer', () => { }); }); - describe('legacy contract format', () => { - it('handles legacy contract format', async () => { - const mockClient = createMockLedgerClient([ - createLegacyContract('amulet-1', 'pkg:Splice.Amulet:Amulet', 'alice::fp', '100'), - ]); - - const result = await getAmuletsForTransfer({ - jsonApiClient: mockClient, - readAs: ['alice::fp'], - }); - - expect(result).toHaveLength(1); - expect(result[0]?.contractId).toBe('amulet-1'); - expect(result[0]?.effectiveAmount).toBe('100'); - }); - - it('handles mixed formats', async () => { - const mockClient = createMockLedgerClient([ - createJsActiveContract('amulet-new', 'pkg:Splice.Amulet:Amulet', 'alice::fp', '200'), - createLegacyContract('amulet-legacy', 'pkg:Splice.Amulet:Amulet', 'alice::fp', '100'), - ]); - - const result = await getAmuletsForTransfer({ - jsonApiClient: mockClient, - readAs: ['alice::fp'], - }); - - expect(result).toHaveLength(2); - }); - }); - describe('numeric amount handling', () => { it('handles numeric initialAmount in nested amount object', async () => { const mockClient = createMockLedgerClient([ diff --git a/test/unit/contracts/findCreatedEvent.test.ts b/test/unit/contracts/findCreatedEvent.test.ts index 1c4d2b48..60776d65 100644 --- a/test/unit/contracts/findCreatedEvent.test.ts +++ b/test/unit/contracts/findCreatedEvent.test.ts @@ -121,30 +121,4 @@ describe('findCreatedEventByTemplateId', () => { expect(result).toBeDefined(); expect(result?.CreatedTreeEvent.value.contractId).toBe('contract-1'); }); - - it('handles nested transaction structure', () => { - // Some responses may have eventsById nested under transaction - const response = { - transactionTree: { - updateId: 'update-123', - commandId: 'cmd-123', - effectiveAt: '2026-01-01T00:00:00Z', - offset: '100', - transaction: { - eventsById: { - '1': createCreatedTreeEvent('contract-1', 'pkg:Module:Target'), - }, - }, - rootEventIds: ['1'], - synchronizerId: 'sync-123', - traceContext: undefined, - recordTime: '2026-01-01T00:00:00Z', - }, - } as unknown as SubmitAndWaitForTransactionTreeResponse; - - const result = findCreatedEventByTemplateId(response, 'pkg:Module:Target'); - - expect(result).toBeDefined(); - expect(result?.CreatedTreeEvent.value.contractId).toBe('contract-1'); - }); }); diff --git a/test/unit/mining/mining-rounds.test.ts b/test/unit/mining/mining-rounds.test.ts index 7ea935df..23d13591 100644 --- a/test/unit/mining/mining-rounds.test.ts +++ b/test/unit/mining/mining-rounds.test.ts @@ -17,7 +17,7 @@ const createOpenMiningRound = (roundNumber: number, opensAt: Date, contractId = template_id: 'pkg:Splice.Round:OpenMiningRound', created_event_blob: 'blob-123', payload: { - roundNumber, + round_number: roundNumber, opensAt: opensAt.toISOString(), }, }, @@ -27,9 +27,6 @@ const createOpenMiningRound = (roundNumber: number, opensAt: Date, contractId = const createIssuingMiningRound = (roundNumber: number, contractId = `issuing-${roundNumber}`) => ({ round_number: roundNumber, contract_id: contractId, - contract: { - contract_id: contractId, - }, }); describe('mining-rounds', () => { @@ -129,7 +126,9 @@ describe('mining-rounds', () => { ); }); - it('handles rounds without opensAt field', async () => { + it('filters out rounds with malformed opensAt field', async () => { + // Even with strict typing, the runtime may receive malformed data + // The code defensively filters these out const mockClient = createMockValidatorClient({ open_mining_rounds: [ { @@ -138,8 +137,8 @@ describe('mining-rounds', () => { template_id: 'pkg:Splice.Round:OpenMiningRound', created_event_blob: 'blob-123', payload: { - roundNumber: 10, - // opensAt is missing + round_number: 10, + opensAt: '', // Empty string - invalid }, }, domain_id: 'domain-123', @@ -203,34 +202,6 @@ describe('mining-rounds', () => { await expect(getCurrentRoundNumber(mockClient)).rejects.toThrow('No open mining rounds found'); }); - - it('handles various round number formats', async () => { - const now = new Date(); - const pastTime = new Date(now.getTime() - 60000); - - // Test with round.number format - const mockClient = createMockValidatorClient({ - open_mining_rounds: [ - { - contract: { - contract_id: 'contract-10', - template_id: 'pkg:Splice.Round:OpenMiningRound', - created_event_blob: 'blob-123', - payload: { - round: { number: '15' }, - opensAt: pastTime.toISOString(), - }, - }, - domain_id: 'domain-123', - }, - ], - issuing_mining_rounds: [], - }); - - const roundNumber = await getCurrentRoundNumber(mockClient); - - expect(roundNumber).toBe(15); - }); }); // Note: waitForRoundChange is not tested here because it involves