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
5 changes: 5 additions & 0 deletions .changeset/mina-isolate-staking-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ledgerhq/coin-mina": patch
---

Stop staking upstream failures from breaking the Mina account synchronisation: staking resources now degrade gracefully to the previous sync values, the validator list is fetched once and shared between accounts instead of once per account per sync, and validator requests go through the retrying network helper with a bounded pagination loop
51 changes: 49 additions & 2 deletions libs/coin-modules/coin-mina/src/bridge/synchronisation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { getBlockInfo } from "../logic/history/getBlockInfo";
import { getTransactions } from "../logic/history/getTransactions";
import { fetchValidators, getEpochInfo } from "../network";
import type { RosettaTransaction } from "../network/types";
import type { FetchEpochInfoResponse } from "../network/types";
import type { FetchEpochInfoResponse, ValidatorInfo } from "../network/types";
import {
createMockTxn,
createMockAccountInfo,
Expand Down Expand Up @@ -381,7 +381,7 @@ describe("synchronisation", () => {
});

it("should populate delegateInfo when a validator matches the delegate address", async () => {
(fetchValidators as jest.Mock).mockResolvedValue([
(fetchValidators as unknown as jest.Mock).mockResolvedValue([
{ address: "validator_address", name: "Validator" },
]);
(getDelegateAddress as jest.Mock).mockResolvedValue("validator_address");
Expand Down Expand Up @@ -418,6 +418,53 @@ describe("synchronisation", () => {
// delegateAddress = address (self) β†’ stakingActive = false
expect(result.resources?.stakingActive).toBe(false);
});

describe("when a staking upstream fails", () => {
const previousResources: MinaAccount["resources"] = {
blockProducers: [{ address: "validator_address", name: "Validator" } as ValidatorInfo],
delegateInfo: undefined,
stakingActive: true,
epochInfo: { epoch: "1", slot: "1", globalSlot: "1", startTime: "", endTime: "" },
};

it("should still return balance and operations", async () => {
(fetchValidators as unknown as jest.Mock).mockRejectedValue(new Error("validators down"));
const fakeOp = { type: "IN", id: "op1" } as MinaOperation;
(mergeOps as jest.Mock).mockReturnValue([fakeOp]);

const result = await getAccountShape(createMockAccountInfo(), { paginationConfig: {} });

expect(result.balance).toEqual(mockAccountData.balance);
expect(result.spendableBalance).toEqual(mockAccountData.spendableBalance);
expect(result.blockHeight).toBe(mockAccountData.blockHeight);
expect(result.operations).toEqual([fakeOp]);
});

it("should keep the resources from the previous sync", async () => {
(fetchValidators as unknown as jest.Mock).mockRejectedValue(new Error("validators down"));
const mockInfo = createMockAccountInfo();
mockInfo.initialAccount = {
...mockInfo.initialAccount,
resources: previousResources,
} as MinaAccount;

const result = await getAccountShape(mockInfo, { paginationConfig: {} });

expect(result.resources).toEqual(previousResources);
});

it("should leave resources unset when there is nothing to fall back on", async () => {
(getEpochInfo as jest.Mock).mockRejectedValue(new Error("graphql down"));
const mockInfo = { ...createMockAccountInfo(), initialAccount: undefined };

const result = await getAccountShape(mockInfo as AccountShapeInfo<Account>, {
paginationConfig: {},
});

expect(result.resources).toBeUndefined();
expect(result.balance).toEqual(mockAccountData.balance);
});
});
});

describe("assignToAccountRaw", () => {
Expand Down
66 changes: 42 additions & 24 deletions libs/coin-modules/coin-mina/src/bridge/synchronisation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers";
import { encodeOperationId } from "@ledgerhq/ledger-wallet-framework/operation";
import { log } from "@ledgerhq/logs";
import type { Operation } from "@ledgerhq/types-live";
import BigNumber from "bignumber.js";
import invariant from "invariant";
import { getAccount } from "../logic/account/getAccount";
Expand Down Expand Up @@ -141,6 +142,45 @@ export const mapRosettaTxnToOperation = async (
}
};

// Staking data is not on the critical path: an upstream failure must degrade it, not fail the
// whole account synchronisation (balance and operations).
const getStakingResources = async (
address: string,
operations: Operation[],
previousResources: MinaAccount["resources"],
): Promise<MinaAccount["resources"]> => {
try {
const [delegateKey, epochInfo, validators] = await Promise.all([
getDelegateAddress(address),
getEpochInfo(),
fetchValidators(),
]);

// GraphQL may lag behind Rosetta. Fall back to the most recent delegation-related op
// to determine the current delegate state without waiting for the GraphQL to catch up.
const graphqlDelegateAddress = delegateKey || address;
const lastDelegationOp = operations.find(
op => op.type === "REDELEGATE" || op.type === "DELEGATE" || op.type === "UNDELEGATE",
);
const getDelegateAddressFn = () => {
if (graphqlDelegateAddress !== address) return graphqlDelegateAddress;
if (lastDelegationOp?.type === "UNDELEGATE") return address;
return lastDelegationOp?.recipients[0] ?? address;
};
const delegateAddress = getDelegateAddressFn();

return {
blockProducers: validators,
delegateInfo: validators.find(v => v.address === delegateAddress) ?? undefined,
stakingActive: address !== delegateAddress,
epochInfo: epochInfo.data.daemonStatus.consensusTimeNow,
};
} catch (error) {
log("warn", "mina: failed to fetch staking resources, keeping the previous ones", { error });
return previousResources;
}
};

export const getAccountShape: GetAccountShape<MinaAccount> = async info => {
const { address, initialAccount, currency, derivationMode } = info;
const oldOperations = initialAccount?.operations || [];
Expand All @@ -162,37 +202,15 @@ export const getAccountShape: GetAccountShape<MinaAccount> = async info => {

const operations = mergeOps(oldOperations, newOperations.flat());

const [delegateKey, epochInfo, validators] = await Promise.all([
getDelegateAddress(address),
getEpochInfo(),
fetchValidators(),
]);

// GraphQL may lag behind Rosetta. Fall back to the most recent delegation-related op
// to determine the current delegate state without waiting for the GraphQL to catch up.
const graphqlDelegateAddress = delegateKey || address;
const lastDelegationOp = operations.find(
op => op.type === "REDELEGATE" || op.type === "DELEGATE" || op.type === "UNDELEGATE",
);
const getDelegateAddressFn = () => {
if (graphqlDelegateAddress !== address) return graphqlDelegateAddress;
if (lastDelegationOp?.type === "UNDELEGATE") return address;
return lastDelegationOp?.recipients[0] ?? address;
};
const delegateAddress = getDelegateAddressFn();
const resources = await getStakingResources(address, operations, initialAccount?.resources);

const shape: Partial<MinaAccount> = {
id: accountId,
balance,
spendableBalance,
operationsCount: operations.length,
blockHeight,
resources: {
blockProducers: validators,
delegateInfo: validators.find(v => v.address === delegateAddress) ?? undefined,
stakingActive: address !== delegateAddress,
epochInfo: epochInfo.data.daemonStatus.consensusTimeNow,
},
...(resources ? { resources } : {}),
};

return { ...shape, operations };
Expand Down
8 changes: 8 additions & 0 deletions libs/coin-modules/coin-mina/src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,12 @@ export const MAX_TRANSACTIONS_PER_PAGE = 100;
export const MINA_ROSETTA_TIMEOUT = 120000;
export const MINA_API_RETRY_COUNT = 3;

export const MINA_VALIDATORS_TIMEOUT = 30000;
export const MAX_VALIDATORS_PER_PAGE = 50;
// Bounds the pagination loop: an upstream no longer returning `last` must not spin forever.
export const MAX_VALIDATORS_PAGES = 20;
// The validator list is network-wide and only meaningfully changes once per epoch (~2 weeks),
// so one fetch is shared by every account instead of one per account per sync.
export const MINA_VALIDATORS_CACHE_TTL_MINUTES = 30;

export const MINA_CANCEL_RETURN_CODE = "27013";
82 changes: 82 additions & 0 deletions libs/coin-modules/coin-mina/src/network/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import network from "@ledgerhq/live-network";
import { getCoinConfig } from "../config";
import {
MAINNET_NETWORK_IDENTIFIER,
MAX_VALIDATORS_PAGES,
MAX_VALIDATORS_PER_PAGE,
MINA_API_RETRY_COUNT,
MINA_DECIMALS,
MINA_SYMBOL,
MINA_TOKEN_ID,
MINA_VALIDATORS_TIMEOUT,
} from "../consts";
import { ValidatorInfoFromAPI } from "./types";
import {
Expand Down Expand Up @@ -690,6 +693,7 @@ const makeValidator = (overrides?: Partial<ValidatorInfoFromAPI>): ValidatorInfo
describe("fetchValidators", () => {
beforeEach(() => {
jest.clearAllMocks();
fetchValidators.reset();
mockGetCoinConfig.mockReturnValue({
infra: {
API_VALIDATORS_BASE_URL: "https://validators.example.com",
Expand All @@ -705,6 +709,13 @@ describe("fetchValidators", () => {

const result = await fetchValidators();

expect(mockNetwork).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
url: `https://validators.example.com?page=0&size=${MAX_VALIDATORS_PER_PAGE}&orderBy=DESC&sortBy=DELEGATORS&type=ACTIVE&isVerifiedOnly=true`,
timeout: MINA_VALIDATORS_TIMEOUT,
}),
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
address: "B62qvalidator",
Expand Down Expand Up @@ -759,4 +770,75 @@ describe("fetchValidators", () => {

expect(result).toEqual([]);
});

it("should share a single fetch between callers instead of one per account", async () => {
mockNetwork.mockResolvedValue(mockRes({ content: [makeValidator()], last: true }));

const [first, second] = await Promise.all([fetchValidators(), fetchValidators()]);
const third = await fetchValidators();

expect(mockNetwork).toHaveBeenCalledTimes(1);
expect(first).toEqual(second);
expect(third).toEqual(first);
});

it("should refetch after the cache has been reset", async () => {
mockNetwork.mockResolvedValue(mockRes({ content: [makeValidator()], last: true }));

await fetchValidators();
fetchValidators.reset();
await fetchValidators();

expect(mockNetwork).toHaveBeenCalledTimes(2);
});

it("should stop paginating when a page comes back empty", async () => {
mockNetwork.mockResolvedValue(mockRes({ content: [], last: false }));

const result = await fetchValidators();

expect(result).toEqual([]);
expect(mockNetwork).toHaveBeenCalledTimes(1);
});

it("should stop paginating at MAX_VALIDATORS_PAGES when the API never reports the last page", async () => {
mockNetwork.mockResolvedValue(mockRes({ content: [makeValidator()], last: false }));

const result = await fetchValidators();

expect(mockNetwork).toHaveBeenCalledTimes(MAX_VALIDATORS_PAGES);
expect(result).toHaveLength(MAX_VALIDATORS_PAGES);
});

it("should retry retryable server errors before failing the caller", async () => {
const setTimeoutSpy = jest
.spyOn(global, "setTimeout")
.mockImplementation((fn: TimerHandler) => {
if (typeof fn === "function") fn();
return 0 as unknown as NodeJS.Timeout;
});
const error503 = new LedgerAPI5xx("API HTTP 503", {
status: 503,
url: "/validators",
method: "GET",
});
mockNetwork
.mockRejectedValueOnce(error503)
.mockResolvedValueOnce(mockRes({ content: [makeValidator()], last: true }));

const result = await fetchValidators();

expect(result).toHaveLength(1);
expect(mockNetwork).toHaveBeenCalledTimes(2);
setTimeoutSpy.mockRestore();
});

it("should not cache a failed fetch", async () => {
mockNetwork.mockRejectedValueOnce(new Error("upstream down"));

await expect(fetchValidators()).rejects.toThrow("upstream down");

mockNetwork.mockResolvedValue(mockRes({ content: [makeValidator()], last: true }));
await expect(fetchValidators()).resolves.toHaveLength(1);
});
});
34 changes: 24 additions & 10 deletions libs/coin-modules/coin-mina/src/network/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import network from "@ledgerhq/live-network";
import { makeLRUCache, minutes } from "@ledgerhq/live-network/cache";
import { log } from "@ledgerhq/logs";

import { getCoinConfig } from "../config";
import {
MAINNET_NETWORK_IDENTIFIER,
MAX_TRANSACTIONS_PER_PAGE,
MAX_VALIDATORS_PAGES,
MAX_VALIDATORS_PER_PAGE,
MINA_API_RETRY_COUNT,
MINA_DECIMALS,
MINA_ROSETTA_TIMEOUT,
MINA_SYMBOL,
MINA_TOKEN_ID,
MINA_VALIDATORS_CACHE_TTL_MINUTES,
MINA_VALIDATORS_TIMEOUT,
} from "../consts";
import { isValidAddress } from "../logic/utils";
import {
Expand Down Expand Up @@ -91,7 +96,7 @@ export const makeNetworkRequest = async <T>({
}: {
method: "POST" | "GET";
url: string;
data: any;
data?: any;
timeout?: number;
retryCount?: number;
}): Promise<T> => {
Expand Down Expand Up @@ -483,22 +488,25 @@ export const getDelegateAccount = async (

// ── Validator API functions ──

export const fetchValidators = async (): Promise<ValidatorInfo[]> => {
const fetchAllValidatorPages = async (): Promise<ValidatorInfo[]> => {
const validators: ValidatorInfoFromAPI[] = [];

let currentPage = 0;
let hasMore = true;

while (hasMore) {
for (let currentPage = 0; currentPage < MAX_VALIDATORS_PAGES; currentPage++) {
const baseUrl = `${getBlockberryUrl()}`;
const { data } = await network<GetValidatorsResponse>({
const data = await makeNetworkRequest<GetValidatorsResponse>({
method: "GET",
url: `${baseUrl}?page=${currentPage}&size=50&orderBy=DESC&sortBy=DELEGATORS&type=ACTIVE&isVerifiedOnly=true`,
url: `${baseUrl}?page=${currentPage}&size=${MAX_VALIDATORS_PER_PAGE}&orderBy=DESC&sortBy=DELEGATORS&type=ACTIVE&isVerifiedOnly=true`,
timeout: MINA_VALIDATORS_TIMEOUT,
});

validators.push(...data.content);
hasMore = !data.last;
currentPage++;
if (data.last || data.content.length === 0) break;
if (currentPage === MAX_VALIDATORS_PAGES - 1) {
log(
"warn",
`[MINA] (fetchAllValidatorPages) Hit MAX_VALIDATORS_PAGES (${MAX_VALIDATORS_PAGES}) without reaching the last page, validator list may be truncated`,
);
}
}
Comment thread
cted-ledger marked this conversation as resolved.

return validators.map(validator => ({
Expand All @@ -515,3 +523,9 @@ export const fetchValidators = async (): Promise<ValidatorInfo[]> => {
blocksCreated: validator.canonicalBlocksCount,
}));
};

export const fetchValidators = makeLRUCache(
fetchAllValidatorPages,
() => getBlockberryUrl(),
minutes(MINA_VALIDATORS_CACHE_TTL_MINUTES, 1),
);
Comment thread
Copilot marked this conversation as resolved.
Loading