Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 4 additions & 14 deletions src/clients/validator-api/schemas/api/scan-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,23 @@ export const GetMemberTrafficStatusResponseSchema = z.object({
export type GetMemberTrafficStatusResponse = z.infer<typeof GetMemberTrafficStatusResponseSchema>;

// 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(),
});

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({
Expand Down
197 changes: 64 additions & 133 deletions src/utils/amulet/get-amulets-for-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,6 @@
includeAllTransferInputs?: boolean;
}

/** Legacy contract format for backward compatibility */
interface LegacyContract {
payload?: Record<string, unknown>;
contract?: {
template_id?: string;
contract_id?: string;
payload?: Record<string, unknown>;
contract?: {
template_id?: string;
contract_id?: string;
payload?: Record<string, unknown>;
};
};
template_id?: string;
contract_id?: string;
}

/** Internal contract representation with extracted data */
interface ContractData {
contractId: string;
Expand All @@ -81,14 +64,6 @@
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<LegacyContract['contract']> } {
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;
Expand All @@ -100,6 +75,40 @@
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<string, unknown>, 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<string, unknown>, 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).
Expand Down Expand Up @@ -133,133 +142,55 @@
const allContracts: ContractData[] = [];
const contractsArr = Array.isArray(activeContracts) ? activeContracts : [];

contractsArr.forEach((ctr) => {
let payload: Record<string, unknown> | 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) {

Check warning on line 155 in src/utils/amulet/get-amulets-for-transfer.ts

View workflow job for this annotation

GitHub Actions / test-cn-quickstart

Unnecessary conditional, value is always falsy
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<string, unknown>;
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) {
return [];
}

// 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),
}));
}
38 changes: 20 additions & 18 deletions src/utils/contracts/findCreatedEvent.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
createdEventBlob: string;
witnessParties: string[];
signatories: string[];
observers: string[];
createdAt: string;
packageName: string;
offset: number;
nodeId: number;
interfaceViews: string[];
implementedInterfaces?: string[];
}

export interface CreatedTreeEventWrapper {
Expand All @@ -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<string, unknown>;
transaction?: {
eventsById?: Record<string, unknown>;
};
}
// 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<string, unknown> };
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;
Expand Down
15 changes: 3 additions & 12 deletions src/utils/mining/mining-rounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,9 @@ async function sleep(ms: number): Promise<void> {
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 */
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading