diff --git a/.changeset/near-coinmodule-api.md b/.changeset/near-coinmodule-api.md new file mode 100644 index 000000000000..3c19841a971b --- /dev/null +++ b/.changeset/near-coinmodule-api.md @@ -0,0 +1,6 @@ +--- +"@ledgerhq/coin-near": minor +"@ledgerhq/live-common": minor +--- + +Expose the CoinModuleApi for NEAR (native asset and staking) diff --git a/libs/coin-modules/coin-near/.unimportedrc.json b/libs/coin-modules/coin-near/.unimportedrc.json index fcf3126fb2de..61a9bd544924 100644 --- a/libs/coin-modules/coin-near/.unimportedrc.json +++ b/libs/coin-modules/coin-near/.unimportedrc.json @@ -10,6 +10,7 @@ ], "entry": [ "src/account.ts", + "src/api/index.ts", "src/bridge/js.ts", "src/deviceTransactionConfig.ts", "src/errors.ts", @@ -18,8 +19,9 @@ "src/transaction.ts" ], "ignoreUnimported": [ - "src/api/node.mock.ts", + "src/network/node.mock.ts", "src/test/bridge.dataset.ts", + "src/test/coinConfig.ts", "src/supportedFeatures.ts" ], "ignoreUnused": [] diff --git a/libs/coin-modules/coin-near/jest.config.js b/libs/coin-modules/coin-near/jest.config.js index e07b0a5e5bc8..6be33b3549d9 100644 --- a/libs/coin-modules/coin-near/jest.config.js +++ b/libs/coin-modules/coin-near/jest.config.js @@ -1,14 +1,4 @@ -module.exports = { - passWithNoTests: true, - collectCoverageFrom: [ - "src/**/*.ts", - "!src/**/*.test.ts", - "!src/**/*.integ.test.ts", - "!src/**/*.spec.ts", - "!src/test/**/*.ts", - ], - coverageReporters: ["json", ["lcov", { file: "lcov.info", projectRoot: "../../../" }], "text"], - coverageDirectory: "coverage", +const sharedConfig = { testEnvironment: "node", transform: { "^.+\\.(ts|tsx)$": [ @@ -21,10 +11,43 @@ module.exports = { ], }, testPathIgnorePatterns: ["lib/", "lib-es/", ".integration.test.ts", "\\.integ\\.test\\.ts$"], +}; + +// Two projects: `unit` runs with @ledgerhq/disable-network-setup (nock blocks all net connect), +// while `msw` runs the suites that exercise the real HTTP path via MSW WITHOUT it — nock and MSW +// both build on @mswjs/interceptors, so they cannot share the same run. Mirrors coin-kaspa/coin-mina. +module.exports = { + passWithNoTests: true, + collectCoverageFrom: [ + "src/**/*.ts", + "!src/**/*.test.ts", + "!src/**/*.integ.test.ts", + "!src/**/*.spec.ts", + "!src/test/**/*.ts", + ], + coverageReporters: ["json", ["lcov", { file: "lcov.info", projectRoot: "../../../" }], "text"], + coverageDirectory: "coverage", reporters: [ "default", ...(process.env.CI ? ["github-actions"] : []), ["jest-sonar", { outputName: "sonar-executionTests-report.xml", reportedFilePath: "absolute" }], ], - setupFilesAfterEnv: ["@ledgerhq/wallet-framework-test-setup"], + projects: [ + { + ...sharedConfig, + displayName: "unit", + testMatch: ["**/*.unit.test.ts"], + setupFilesAfterEnv: [ + "@ledgerhq/wallet-framework-test-setup", + "@ledgerhq/disable-network-setup", + ], + }, + { + ...sharedConfig, + displayName: "msw", + testMatch: ["**/*.test.ts"], + testPathIgnorePatterns: [...sharedConfig.testPathIgnorePatterns, "\\.unit\\.test\\.ts$"], + setupFilesAfterEnv: ["@ledgerhq/wallet-framework-test-setup"], + }, + ], }; diff --git a/libs/coin-modules/coin-near/src/api/index.integ.test.ts b/libs/coin-modules/coin-near/src/api/index.integ.test.ts new file mode 100644 index 000000000000..54e6291b1ab7 --- /dev/null +++ b/libs/coin-modules/coin-near/src/api/index.integ.test.ts @@ -0,0 +1,374 @@ +import type { + StakingTransactionIntent, + TransactionIntent, +} from "@ledgerhq/coin-module-framework/api/index"; +import * as nearAPI from "near-api-js"; +import { combine } from "../logic/combine"; +import { getActionCosts } from "../network/protocolConfig"; +import { createApi } from "./index"; + +/** + * Read-path and crafting checks against NEAR mainnet through the Ledger proxy. + * + * Nothing here broadcasts: `craftTransaction` and `combine` are exercised against real chain state + * (a real access-key nonce and block hash) but the signed payload is thrown away, and the signature + * is a dummy, so it could not be accepted anyway. + */ +const NODE = "https://near.coin.ledger.com/node"; +const INDEXER = "https://near-indexer.coin.ledger.com"; + +const config = () => ({ + status: { type: "active" as const }, + infra: { + API_NEAR_PRIVATE_NODE: NODE, + API_NEAR_PUBLIC_NODE: "https://rpc.mainnet.near.org", + API_NEAR_INDEXER: "https://near.coin.ledger.com/indexer", + API_NEARBLOCKS_INDEXER: INDEXER, + }, +}); + +const ACCOUNT_WITH_HISTORY = "nearkat.near"; +const DELEGATOR = "81afe80a9d91c82f66122c35ef400da709bde01eada5aae8d7a63bbf68f42040"; +const IMPLICIT_RECIPIENT = "4e7de0a21d8a20f970c86b6edf407906d7ba9e205979c3268270eef80a286e2d"; +const NAMED_RECIPIENT = "recipient.near"; +const DUMMY_SIGNATURE = "ab".repeat(64); +const VALID_STAKE_STATES = ["active", "deactivating", "withdrawable"]; + +const rpc = async (method: string, params: unknown): Promise => { + const response = await fetch(NODE, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: "id", method, params }), + }); + + return response.json(); +}; + +/** Resolved from chain so the test does not go stale when keys rotate. */ +const firstFullAccessKey = async ( + accountId: string, +): Promise<{ publicKey: string; nonce: number }> => { + const { result } = await rpc("query", { + request_type: "view_access_key_list", + finality: "final", + account_id: accountId, + }); + + const key = (result?.keys ?? []).find((k: any) => k.access_key?.permission === "FullAccess"); + if (!key) { + throw new Error(`no full-access key on ${accountId}`); + } + + return { publicKey: key.public_key, nonce: key.access_key.nonce }; +}; + +const sendIntent = (overrides: Partial = {}): TransactionIntent => + ({ + intentType: "transaction", + type: "send", + sender: ACCOUNT_WITH_HISTORY, + recipient: NAMED_RECIPIENT, + amount: 1_000_000_000_000_000_000_000n, + asset: { type: "native" }, + ...overrides, + }) as TransactionIntent; + +describe("CoinModuleApi (integration)", () => { + const api = createApi(config, "near"); + + beforeEach(() => getActionCosts.reset()); + + describe("blocks", () => { + it("reads the latest final block", async () => { + const block = await api.lastBlock(); + + expect(block.height).toBeGreaterThan(100_000_000); + expect(block.hash).toMatch(/\S/); + expect(block.time.getTime()).toBeGreaterThan(Date.now() - 10 * 60_000); + expect(block.time.getTime()).toBeLessThan(Date.now() + 60_000); + }); + + it("reads a past block by height and agrees with the head it came from", async () => { + const head = await api.lastBlock(); + + const same = await api.getBlockInfo(head.height); + expect(same).toEqual(head); + + const past = await api.getBlockInfo(head.height - 100); + expect(past.height).toBe(head.height - 100); + expect(past.hash).not.toBe(head.hash); + expect(past.time.getTime()).toBeLessThan(head.time.getTime()); + }); + + it("rejects a height the chain cannot have", async () => { + // The node answers with an UNKNOWN_BLOCK error rather than a header. + await expect(api.getBlockInfo(999_999_999_999)).rejects.toThrow("Server error"); + }); + }); + + describe("balances", () => { + it("reports the native balance first, with the storage deposit locked", async () => { + const balances = await api.getBalance(ACCOUNT_WITH_HISTORY); + + const [native] = balances; + expect(native.asset).toEqual({ type: "native" }); + expect(native.stake).toBeUndefined(); + expect(native.value).toBeGreaterThan(0n); + expect(native.locked ?? 0n).toBeGreaterThan(0n); + expect(native.value - (native.locked ?? 0n)).toBeGreaterThanOrEqual(0n); + }); + + it("surfaces staking positions of a delegating account as extra balances", async () => { + const balances = await api.getBalance(DELEGATOR); + const stakes = balances.filter(balance => balance.stake !== undefined); + + expect(stakes.length).toBeGreaterThan(0); + stakes.forEach(balance => { + expect(balance.value).toBe(balance.stake!.amount); + expect(VALID_STAKE_STATES).toContain(balance.stake!.state); + expect(balance.stake!.delegate).toMatch(/\S/); + }); + }); + + it("reports zero for an account that does not exist on chain", async () => { + // The node answers UNKNOWN_ACCOUNT with a 200 and no result, which must not turn into a + // phantom balance made of the storage reserve. + const balances = await api.getBalance("this-account-does-not-exist-421337.near"); + + expect(balances).toHaveLength(1); + expect(balances[0].value).toBe(0n); + expect(balances[0].locked).toBe(0n); + }); + + it("rejects balance options it does not implement", async () => { + await expect( + api.getBalance(ACCOUNT_WITH_HISTORY, { includeAssets: async () => true }), + ).rejects.toThrow("getBalance does not support the options parameter"); + }); + }); + + describe("operations", () => { + it("maps a real history without inflating outgoing values by the fee", async () => { + const limit = 25; + const page = await api.listOperations(ACCOUNT_WITH_HISTORY, { minHeight: 0, limit }); + + expect(page.items.length).toBeGreaterThan(0); + + // Ground truth straight from the indexer, fetched at the same page size: an operation value + // must be the deposit alone. The bridge adds the fee on top for outgoing transfers, and the + // generic framework adds it again, so a fee folded in here would be charged twice. + const raw = await ( + await fetch(`${INDEXER}/v3/accounts/${ACCOUNT_WITH_HISTORY}/txns?limit=${limit}`) + ).json(); + const deposits = new Map( + (raw.data ?? []).map((tx: any) => [ + tx.transaction_hash, + { deposit: tx.actions_agg?.deposit ?? "0", fee: tx.outcomes_agg?.transaction_fee ?? "0" }, + ]), + ); + + page.items.forEach(operation => { + expect(operation.tx.hash).toMatch(/\S/); + expect(operation.value).toBeGreaterThanOrEqual(0n); + expect(operation.tx.fees).toBeGreaterThanOrEqual(0n); + expect(operation.tx.block.height).toBeGreaterThan(0); + expect(operation.tx.date.getTime()).toBeGreaterThan(new Date("2020-01-01").getTime()); + expect(operation.asset).toEqual({ type: "native" }); + }); + + // Both requests hit a moving history, so compare only the overlap, and require one. + const compared = page.items.filter(operation => deposits.has(operation.tx.hash)); + expect(compared.length).toBeGreaterThan(0); + + compared.forEach(operation => { + const { deposit, fee } = deposits.get(operation.tx.hash)!; + expect(operation.value).toBe(BigInt(deposit)); + expect(operation.tx.fees).toBe(BigInt(fee)); + }); + + // Explicitly: no outgoing operation carries deposit + fee as its value. + const feeInflated = compared.filter(operation => { + const { deposit, fee } = deposits.get(operation.tx.hash)!; + return ( + operation.type === "OUT" && + fee !== "0" && + operation.value === BigInt(deposit) + BigInt(fee) + ); + }); + expect(feeInflated).toEqual([]); + }, 120_000); + + it("pages forward with the indexer cursor without repeating operations", async () => { + const first = await api.listOperations(ACCOUNT_WITH_HISTORY, { minHeight: 0, limit: 10 }); + + expect(first.items).toHaveLength(10); + expect(first.next).toMatch(/\S/); + + const second = await api.listOperations(ACCOUNT_WITH_HISTORY, { + minHeight: 0, + limit: 10, + cursor: first.next, + }); + + const firstIds = new Set(first.items.map(operation => operation.id)); + second.items.forEach(operation => expect(firstIds.has(operation.id)).toBe(false)); + }, 120_000); + + it("filters out everything below minHeight", async () => { + const head = await api.lastBlock(); + + const page = await api.listOperations(ACCOUNT_WITH_HISTORY, { minHeight: head.height }); + + expect(page.items).toEqual([]); + expect(page.next).toBeUndefined(); + }, 120_000); + + it("refuses ascending order", async () => { + await expect( + api.listOperations(ACCOUNT_WITH_HISTORY, { minHeight: 0, order: "asc" }), + ).rejects.toThrow("ascending order is not supported"); + }); + }); + + describe("fees", () => { + it("prices a transfer from the live gas price and protocol config", async () => { + const { value, parameters } = await api.estimateFees(sendIntent()); + + expect(value).toBeGreaterThan(0n); + expect(String(parameters?.gasPrice)).toMatch(/^\d+$/); + }); + + it("charges more for an implicit recipient it has to create", async () => { + const named = await api.estimateFees(sendIntent()); + const implicit = await api.estimateFees(sendIntent({ recipient: IMPLICIT_RECIPIENT })); + + expect(implicit.value).toBeGreaterThan(named.value); + }); + + it("prices a delegation differently from a transfer", async () => { + const staking = await api.estimateFees({ + ...sendIntent(), + intentType: "staking", + type: "delegate", + mode: "delegate", + valAddress: "astro-stakers.poolv1.near", + } as StakingTransactionIntent); + + expect(staking.value).toBeGreaterThan(0n); + expect(staking.value).not.toBe((await api.estimateFees(sendIntent())).value); + }); + }); + + describe("crafting", () => { + it("crafts a transfer on top of the account's live access key and block", async () => { + const { publicKey, nonce } = await firstFullAccessKey(ACCOUNT_WITH_HISTORY); + + const { transaction } = await api.craftTransaction( + sendIntent({ senderPublicKey: publicKey }), + ); + + const decoded = nearAPI.transactions.Transaction.decode(Buffer.from(transaction, "base64")); + expect(decoded.signerId).toBe(ACCOUNT_WITH_HISTORY); + expect(decoded.receiverId).toBe(NAMED_RECIPIENT); + expect(Number(decoded.nonce.toString())).toBeGreaterThan(nonce); + expect(decoded.blockHash.length).toBe(32); + }); + + it("crafts a delegation addressed to the staking pool", async () => { + const { publicKey } = await firstFullAccessKey(DELEGATOR); + const pool = "astro-stakers.poolv1.near"; + + const { transaction, details } = await api.craftTransaction({ + ...sendIntent({ sender: DELEGATOR, senderPublicKey: publicKey }), + intentType: "staking", + type: "delegate", + mode: "delegate", + valAddress: pool, + } as StakingTransactionIntent); + + const decoded = nearAPI.transactions.Transaction.decode(Buffer.from(transaction, "base64")); + expect(decoded.receiverId).toBe(pool); + expect(details).toMatchObject({ mode: "stake", receiverId: pool }); + }); + + it("combines a live-crafted transaction into a signed payload", async () => { + const { publicKey } = await firstFullAccessKey(ACCOUNT_WITH_HISTORY); + const { transaction } = await api.craftTransaction( + sendIntent({ senderPublicKey: publicKey }), + ); + + // Signed with a dummy signature and deliberately never broadcast. + const signed = combine(transaction, DUMMY_SIGNATURE); + const decoded = nearAPI.transactions.SignedTransaction.decode(Buffer.from(signed, "base64")); + + expect(decoded.transaction.signerId).toBe(ACCOUNT_WITH_HISTORY); + expect(Buffer.from(decoded.signature.data).toString("hex")).toBe(DUMMY_SIGNATURE); + }); + + it("fails clearly when the public key has no access key on the account", async () => { + const { publicKey } = await firstFullAccessKey(DELEGATOR); + + await expect( + api.craftTransaction(sendIntent({ senderPublicKey: publicKey })), + ).rejects.toThrow(`no access key found for ${ACCOUNT_WITH_HISTORY}`); + }); + }); + + describe("validation", () => { + it("accepts a funded transfer against live balances", async () => { + const balances = await api.getBalance(ACCOUNT_WITH_HISTORY); + const fees = await api.estimateFees(sendIntent()); + + const result = await api.validateIntent(sendIntent(), balances, fees); + + expect(result.estimatedFees).toBe(fees.value); + expect(result.amount).toBe(1_000_000_000_000_000_000_000n); + expect(result.totalSpent).toBe(result.amount + fees.value); + }); + + it("rejects a transfer to a named account that does not exist", async () => { + const balances = await api.getBalance(ACCOUNT_WITH_HISTORY); + + const result = await api.validateIntent( + sendIntent({ recipient: "this-account-does-not-exist-421337.near" }), + balances, + { value: 0n }, + ); + + expect(result.errors.recipient?.name).toBe("NearNewNamedAccountError"); + }); + + it("validates address formats", async () => { + await expect(api.validateAddress(ACCOUNT_WITH_HISTORY, {})).resolves.toBe(true); + await expect(api.validateAddress("NOT VALID", {})).resolves.toBe(false); + }); + }); + + describe("staking", () => { + it("reads the positions of a delegating account", async () => { + const page = await api.getStakes(DELEGATOR); + + expect(page.items.length).toBeGreaterThan(0); + page.items.forEach(stake => { + expect(stake.address).toBe(DELEGATOR); + expect(stake.delegate).toMatch(/\S/); + expect(VALID_STAKE_STATES).toContain(stake.state); + expect(stake.amount).toBeGreaterThan(0n); + expect(stake.uid).toContain(DELEGATOR); + }); + }, 120_000); + + it("reads the validator set", async () => { + const page = await api.getValidators(); + + expect(page.items).toHaveLength(200); + expect(new Set(page.items.map(validator => validator.address)).size).toBe(200); + page.items.forEach(validator => { + expect(validator.address).toMatch(/\S/); + expect(validator.balance).toBeGreaterThanOrEqual(0n); + expect(Number(validator.commissionRate)).toBeGreaterThanOrEqual(0); + expect(Number(validator.commissionRate)).toBeLessThanOrEqual(100); + }); + }, 120_000); + }); +}); diff --git a/libs/coin-modules/coin-near/src/api/index.test.ts b/libs/coin-modules/coin-near/src/api/index.test.ts new file mode 100644 index 000000000000..aefbd1888e08 --- /dev/null +++ b/libs/coin-modules/coin-near/src/api/index.test.ts @@ -0,0 +1,143 @@ +import { NEAR_BASE_URL_MOCKED } from "../network/node.mock"; +import { getBalance } from "../logic/getBalance"; +import { getBlockInfo } from "../logic/getBlockInfo"; +import { lastBlock } from "../logic/lastBlock"; +import { listOperations } from "../logic/listOperations"; +import { getStakes } from "../logic/getStakes"; +import { getValidators } from "../logic/getValidators"; +import { broadcast } from "../logic/broadcast"; +import { craftTransaction } from "../logic/craftTransaction"; +import { estimateFees } from "../logic/estimateFees"; +import { validateIntent } from "../logic/validateIntent"; +import { createApi } from "./index"; + +jest.mock("../logic/getBalance", () => ({ getBalance: jest.fn() })); +jest.mock("../logic/getBlockInfo", () => ({ getBlockInfo: jest.fn() })); +jest.mock("../logic/lastBlock", () => ({ lastBlock: jest.fn() })); +jest.mock("../logic/listOperations", () => ({ listOperations: jest.fn() })); +jest.mock("../logic/getStakes", () => ({ getStakes: jest.fn() })); +jest.mock("../logic/getValidators", () => ({ getValidators: jest.fn() })); +jest.mock("../logic/broadcast", () => ({ broadcast: jest.fn() })); +jest.mock("../logic/craftTransaction", () => ({ craftTransaction: jest.fn() })); +jest.mock("../logic/estimateFees", () => ({ estimateFees: jest.fn() })); +jest.mock("../logic/validateIntent", () => ({ validateIntent: jest.fn() })); + +const config = () => ({ + status: { type: "active" as const }, + infra: { + API_NEAR_PRIVATE_NODE: NEAR_BASE_URL_MOCKED, + API_NEAR_PUBLIC_NODE: NEAR_BASE_URL_MOCKED, + API_NEAR_INDEXER: NEAR_BASE_URL_MOCKED, + API_NEARBLOCKS_INDEXER: NEAR_BASE_URL_MOCKED, + }, +}); + +describe("createApi", () => { + const api = createApi(config, "near"); + + it("exposes every CoinModuleApi method", () => { + const methods = [ + "lastBlock", + "getBlockInfo", + "getBlock", + "call", + "getValidators", + "getBalance", + "listOperations", + "getStakes", + "getRewards", + "craftTransaction", + "craftRawTransaction", + "estimateFees", + "combine", + "broadcast", + "validateIntent", + "getNextSequence", + "validateAddress", + "craftTransactionData", + ]; + + for (const method of methods) { + expect(typeof api[method as keyof typeof api]).toBe("function"); + } + }); + + describe("delegates each method to its implementation", () => { + const intent = { intentType: "transaction", sender: "sender.near" } as never; + + beforeEach(() => jest.clearAllMocks()); + + it("forwards the read methods", async () => { + await api.lastBlock(); + expect(lastBlock).toHaveBeenCalled(); + + await api.getBlockInfo(42); + expect(getBlockInfo).toHaveBeenCalledWith(42); + + await api.listOperations("sender.near", { minHeight: 7 }); + expect(listOperations).toHaveBeenCalledWith("sender.near", { minHeight: 7 }); + + await api.getStakes("sender.near", "cursor"); + expect(getStakes).toHaveBeenCalledWith("sender.near", "cursor"); + + await api.getValidators("cursor"); + expect(getValidators).toHaveBeenCalledWith("cursor"); + }); + + it("forwards getBalance and rejects unsupported balance options", async () => { + (getBalance as jest.Mock).mockResolvedValue([]); + + await api.getBalance("sender.near"); + expect(getBalance).toHaveBeenCalledWith("sender.near"); + + await expect( + api.getBalance("sender.near", { includeAssets: async () => true }), + ).rejects.toThrow("getBalance does not support the options parameter"); + }); + + it("forwards the transaction lifecycle methods", async () => { + const fees = { value: 1n }; + + await api.craftTransaction(intent, fees); + expect(craftTransaction).toHaveBeenCalledWith(intent, fees); + + await api.estimateFees(intent, { gasPrice: "1" }); + expect(estimateFees).toHaveBeenCalledWith(intent, { gasPrice: "1" }); + + await api.broadcast("signed-tx"); + expect(broadcast).toHaveBeenCalledWith("signed-tx", undefined); + + await api.validateIntent(intent, [], fees); + expect(validateIntent).toHaveBeenCalledWith(intent, [], fees); + }); + }); + + it("validates an address format on the spot", async () => { + await expect(api.validateAddress("recipient.near", {})).resolves.toBe(true); + await expect(api.validateAddress("NOT VALID", {})).resolves.toBe(false); + }); + + it("does not support reading a block's transactions", () => { + expect(() => api.getBlock(1)).toThrow("getBlock is not supported"); + }); + + it("does not support rewards, which a staking pool compounds into the staked balance", () => { + expect(() => api.getRewards("sender.near")).toThrow("getRewards is not supported"); + }); + + it("explains why an account-level nonce is not available", () => { + expect(() => api.getNextSequence("sender.near")).toThrow( + "the nonce belongs to an access key, not to an account", + ); + }); + + it("does not support crafting from a raw transaction", () => { + expect(() => api.craftRawTransaction("", "", "", 0n)).toThrow( + "craftRawTransaction is not supported", + ); + }); + + it("does not support contract calls", async () => { + await expect(api.call({})).rejects.toThrow("call is not supported"); + }); +}); diff --git a/libs/coin-modules/coin-near/src/api/index.ts b/libs/coin-modules/coin-near/src/api/index.ts index 49f70ee2aaa3..5365fead78b1 100644 --- a/libs/coin-modules/coin-near/src/api/index.ts +++ b/libs/coin-modules/coin-near/src/api/index.ts @@ -1,9 +1,106 @@ -export { getOperations } from "./indexer"; -export { - getAccount, - fetchAccountDetails, - getAccessKey, - broadcastTransaction, - getStakingPositions, - getValidators, -} from "./node"; +import { rejectBalanceOptions } from "@ledgerhq/coin-module-framework/api/getBalance/rejectBalanceOptions"; +import type { + AddressValidationCurrencyParameters, + Balance, + BalanceOptions, + Block, + BlockInfo, + BroadcastConfig, + CoinModuleApi, + CraftedTransaction, + Cursor, + FeeEstimation, + ListOperationsOptions, + Operation, + Page, + Reward, + Stake, + TransactionValidation, + Validator, +} from "@ledgerhq/coin-module-framework/api/index"; +import { craftTransactionData } from "@ledgerhq/coin-module-framework/logic/craftTransactionData"; +import { setCoinConfig, type NearCoinConfig } from "../config"; +import { isValidAddress } from "../logic"; +import { getBalance } from "../logic/getBalance"; +import { getBlockInfo } from "../logic/getBlockInfo"; +import { lastBlock } from "../logic/lastBlock"; +import { listOperations } from "../logic/listOperations"; +import { getStakes } from "../logic/getStakes"; +import { getValidators } from "../logic/getValidators"; +import { broadcast } from "../logic/broadcast"; +import { combine } from "../logic/combine"; +import { craftTransaction, type NearIntent } from "../logic/craftTransaction"; +import { estimateFees } from "../logic/estimateFees"; +import { validateIntent } from "../logic/validateIntent"; + +// CoinModuleApi ("Alpaca") entry point for NEAR. Staking reads are implemented (real pool-contract +// delegation); getRewards/getBlock/getNextSequence/call/craftRawTransaction and tokens are not — see the inline throws below for why. +export function createApi(config: NearCoinConfig, _currencyId: string): CoinModuleApi { + setCoinConfig(config); + + return { + // --- Blocks / chain state --- + lastBlock: (): Promise => lastBlock(), + getBlockInfo: (height: number): Promise => getBlockInfo(height), + + // --- Account state --- + getBalance: (address: string, options?: BalanceOptions): Promise => + rejectBalanceOptions(() => getBalance(address), options), + listOperations: (address: string, options: ListOperationsOptions): Promise> => + listOperations(address, options), + + // --- Transaction lifecycle --- + craftTransaction: ( + transactionIntent: NearIntent, + customFees?: FeeEstimation, + ): Promise => craftTransaction(transactionIntent, customFees), + estimateFees: ( + transactionIntent: NearIntent, + customFeesParameters?: FeeEstimation["parameters"], + ): Promise => estimateFees(transactionIntent, customFeesParameters), + combine, + broadcast: (tx: string, broadcastConfig?: BroadcastConfig): Promise => + broadcast(tx, broadcastConfig), + validateIntent: ( + transactionIntent: NearIntent, + balances: Balance[], + customFees?: FeeEstimation, + ): Promise => validateIntent(transactionIntent, balances, customFees), + craftTransactionData, + validateAddress: async ( + address: string, + _parameters: Partial, + ): Promise => isValidAddress(address), + + // --- Staking --- + getStakes: (address: string, cursor?: Cursor): Promise> => + getStakes(address, cursor), + getValidators: (cursor?: Cursor): Promise> => getValidators(cursor), + + // --- Not supported --- + getBlock: (_height: number): Promise => { + throw new Error("getBlock is not supported"); + }, + getNextSequence: (_address: string): Promise => { + throw new Error( + "getNextSequence is not applicable for Near: the nonce belongs to an access key, not to an account", + ); + }, + getRewards: (_address: string, _cursor?: Cursor): Promise> => { + throw new Error("getRewards is not supported"); + }, + craftRawTransaction: ( + _transaction: string, + _sender: string, + _publicKey: string, + _sequence: bigint, + ): Promise => { + throw new Error("craftRawTransaction is not supported"); + }, + call: async () => { + throw new Error("call is not supported"); + }, + }; +} + +export default createApi; diff --git a/libs/coin-modules/coin-near/src/api/parity.integ.test.ts b/libs/coin-modules/coin-near/src/api/parity.integ.test.ts new file mode 100644 index 000000000000..85ea712800bb --- /dev/null +++ b/libs/coin-modules/coin-near/src/api/parity.integ.test.ts @@ -0,0 +1,173 @@ +import type { TransactionIntent } from "@ledgerhq/coin-module-framework/api/index"; +import { BigNumber } from "bignumber.js"; +import { setCoinConfig } from "../config"; +import getEstimatedFees from "../getFeesForTransaction"; +import { getAccount } from "../network"; +import { getActionCosts } from "../network/protocolConfig"; +import { preload } from "../preload"; +import { setNearPreloadData } from "../preload-data"; +import type { Transaction } from "../types"; +import { createApi } from "./index"; + +/** + * The CoinModuleApi and the account bridge must agree on mainnet. + * + * They read the same chain through different plumbing: the bridge takes its costs from preloaded + * data, the api from the protocol config. This suite pins the two together so a change to either + * side cannot silently drift, which is the main risk of running both paths side by side. + */ +const NODE = "https://near.coin.ledger.com/node"; + +const infra = { + API_NEAR_PRIVATE_NODE: NODE, + API_NEAR_PUBLIC_NODE: "https://rpc.mainnet.near.org", + API_NEAR_INDEXER: "https://near.coin.ledger.com/indexer", + API_NEARBLOCKS_INDEXER: "https://near-indexer.coin.ledger.com", +}; + +const config = () => ({ status: { type: "active" as const }, infra }); + +const ACCOUNT = "nearkat.near"; +const NAMED_RECIPIENT = "recipient.near"; +const IMPLICIT_RECIPIENT = "4e7de0a21d8a20f970c86b6edf407906d7ba9e205979c3268270eef80a286e2d"; +const POOL = "astro-stakers.poolv1.near"; +const AMOUNT = 1_000_000_000_000_000_000_000n; + +const sendIntent = (recipient: string): TransactionIntent => + ({ + intentType: "transaction", + type: "send", + sender: ACCOUNT, + recipient, + amount: AMOUNT, + asset: { type: "native" }, + }) as TransactionIntent; + +const bridgeTransaction = (mode: string, recipient: string): Transaction => + ({ + family: "near", + mode, + recipient, + amount: new BigNumber(AMOUNT.toString()), + useAllAmount: false, + }) as Transaction; + +describe("CoinModuleApi vs account bridge (integration)", () => { + const api = createApi(config, "near"); + + beforeAll(async () => { + setCoinConfig(config); + // The bridge path only has costs once preloaded; the api path must not need this. + setNearPreloadData(await preload()); + }, 120_000); + + beforeEach(() => getActionCosts.reset()); + + it("reports the same spendable balance", async () => { + const [native] = await api.getBalance(ACCOUNT); + const { spendableBalance } = await getAccount(ACCOUNT); + + expect(native.value - (native.locked ?? 0n)).toBe(BigInt(spendableBalance.toFixed(0))); + }, 120_000); + + it("reports the same total as the native balance, staking buckets included", async () => { + const [native] = await api.getBalance(ACCOUNT); + const { balance, nearResources } = await getAccount(ACCOUNT); + + const frozen = nearResources.stakedBalance + .plus(nearResources.availableBalance) + .plus(nearResources.pendingBalance) + .plus(nearResources.storageUsageBalance); + + expect(native.value).toBe(BigInt(balance.toFixed(0))); + expect(native.locked).toBe(BigInt(BigNumber.min(frozen, balance).toFixed(0))); + }, 120_000); + + it.each([ + ["a named recipient", NAMED_RECIPIENT], + ["an implicit recipient", IMPLICIT_RECIPIENT], + ])( + "prices a transfer to %s identically", + async (_label, recipient) => { + const fromApi = await api.estimateFees(sendIntent(recipient)); + const fromBridge = await getEstimatedFees(bridgeTransaction("send", recipient)); + + expect(fromApi.value).toBe(BigInt(fromBridge.toFixed(0))); + expect(fromApi.value).toBeGreaterThan(0n); + }, + 120_000, + ); + + it.each(["stake", "unstake", "withdraw"])( + "prices a %s identically", + async mode => { + const stakingIntent = { + ...sendIntent(POOL), + intentType: "staking" as const, + type: mode === "stake" ? "delegate" : mode === "unstake" ? "undelegate" : "withdraw", + mode: mode === "stake" ? "delegate" : mode === "unstake" ? "undelegate" : "withdraw", + valAddress: POOL, + }; + + const fromApi = await api.estimateFees(stakingIntent as never); + const fromBridge = await getEstimatedFees(bridgeTransaction(mode, POOL)); + + expect(fromApi.value).toBe(BigInt(fromBridge.toFixed(0))); + expect(fromApi.value).toBeGreaterThan(0n); + }, + 120_000, + ); + + it("charges the higher gas for a withdraw-all on both paths", async () => { + const intent = { + ...sendIntent(POOL), + intentType: "staking" as const, + type: "withdraw", + mode: "withdraw", + valAddress: POOL, + useAllAmount: true, + }; + + const fromApi = await api.estimateFees(intent as never); + const fromBridge = await getEstimatedFees({ + ...bridgeTransaction("withdraw", POOL), + useAllAmount: true, + } as Transaction); + const partial = await api.estimateFees({ ...intent, useAllAmount: false } as never); + + expect(fromApi.value).toBe(BigInt(fromBridge.toFixed(0))); + expect(fromApi.value).toBeGreaterThan(partial.value); + }, 120_000); + + it("prices without preloaded data, which the bridge path cannot do", async () => { + // Wipe what preload() filled in: the bridge would now price at zero, the api must not. + setNearPreloadData({ + storageCost: new BigNumber(0), + gasPrice: new BigNumber(0), + createAccountCostSend: new BigNumber(0), + createAccountCostExecution: new BigNumber(0), + transferCostSend: new BigNumber(0), + transferCostExecution: new BigNumber(0), + addKeyCostSend: new BigNumber(0), + addKeyCostExecution: new BigNumber(0), + receiptCreationSend: new BigNumber(0), + receiptCreationExecution: new BigNumber(0), + minGasPurchasePrice: new BigNumber(0), + accountCreationCharge: new BigNumber(0), + validators: [], + }); + + const fromApi = await api.estimateFees(sendIntent(NAMED_RECIPIENT)); + const fromBridge = await getEstimatedFees(bridgeTransaction("send", NAMED_RECIPIENT)); + + expect(fromApi.value).toBeGreaterThan(0n); + expect(fromBridge.isZero()).toBe(true); + + // storageUsageBalance (part of `locked`) must also come from getActionCosts(), not the + // zeroed preload cache — otherwise it would silently diverge from the bridge's reserve. + const [native] = await api.getBalance(ACCOUNT); + expect(native.locked).toBeGreaterThan(0n); + + setNearPreloadData(await preload()); + }, 120_000); +}); diff --git a/libs/coin-modules/coin-near/src/broadcast.ts b/libs/coin-modules/coin-near/src/broadcast.ts index e9c1e281f212..73823b721696 100644 --- a/libs/coin-modules/coin-near/src/broadcast.ts +++ b/libs/coin-modules/coin-near/src/broadcast.ts @@ -1,6 +1,6 @@ import { patchOperationWithHash } from "@ledgerhq/ledger-wallet-framework/operation"; import type { AccountBridge } from "@ledgerhq/types-live"; -import { broadcastTransaction } from "./api"; +import { broadcastTransaction } from "./network"; import { Transaction } from "./types"; export const broadcast: AccountBridge["broadcast"] = async ({ diff --git a/libs/coin-modules/coin-near/src/buildTransaction.ts b/libs/coin-modules/coin-near/src/buildTransaction.ts index eaf476d497c3..fe8548079248 100644 --- a/libs/coin-modules/coin-near/src/buildTransaction.ts +++ b/libs/coin-modules/coin-near/src/buildTransaction.ts @@ -1,10 +1,9 @@ import { log } from "@ledgerhq/logs"; import type { Account } from "@ledgerhq/types-live"; import * as nearAPI from "near-api-js"; -import { Action } from "near-api-js/lib/transaction"; import { Transaction as NearApiTransaction } from "near-api-js/lib/transaction"; -import { getAccessKey } from "./api"; -import { getStakingGas } from "./logic"; +import { buildActions } from "./logic/actions"; +import { getAccessKey } from "./network"; import type { Transaction } from "./types"; export const buildTransaction = async ( @@ -17,56 +16,11 @@ export const buildTransaction = async ( publicKey, }); - const parsedNearAmount = t.amount.toFixed(); - - const actions: Action[] = []; - - switch (t.mode) { - case "stake": - actions.push( - nearAPI.transactions.functionCall( - "deposit_and_stake", - {}, - getStakingGas().toFixed(), - parsedNearAmount, - ), - ); - break; - case "unstake": - if (t.useAllAmount) { - actions.push( - nearAPI.transactions.functionCall("unstake_all", {}, getStakingGas().toFixed(), "0"), - ); - } else { - actions.push( - nearAPI.transactions.functionCall( - "unstake", - { amount: parsedNearAmount }, - getStakingGas().toFixed(), - "0", - ), - ); - } - break; - case "withdraw": - if (t.useAllAmount) { - actions.push( - nearAPI.transactions.functionCall("withdraw_all", {}, getStakingGas(t).toNumber(), "0"), - ); - } else { - actions.push( - nearAPI.transactions.functionCall( - "withdraw", - { amount: parsedNearAmount }, - getStakingGas().toFixed(), - "0", - ), - ); - } - break; - default: - actions.push(nearAPI.transactions.transfer(parsedNearAmount)); - } + const actions = buildActions({ + mode: t.mode, + amount: t.amount.toFixed(), + useAllAmount: t.useAllAmount ?? false, + }); try { const transaction = nearAPI.transactions.createTransaction( diff --git a/libs/coin-modules/coin-near/src/constants.ts b/libs/coin-modules/coin-near/src/constants.ts index d672cf4c4976..60e2186a9b27 100644 --- a/libs/coin-modules/coin-near/src/constants.ts +++ b/libs/coin-modules/coin-near/src/constants.ts @@ -4,6 +4,8 @@ export const MIN_ACCOUNT_BALANCE_BUFFER = "50000000000000000000000"; export const STAKING_GAS_BASE = "25000000000000"; export const FIGMENT_NEAR_VALIDATOR_ADDRESS = "ledgerbyfigment.poolv1.near"; export const FRACTIONAL_DIGITS = 5; +/** How many staking pools to surface; the indexer orders them by stake, so this is the top N. */ +export const VALIDATORS_COUNT = 200; export const YOCTO_THRESHOLD_VARIATION = "10"; export const NEAR_DUMMY_ADDRESS = "4e7de0a21d8a20f970c86b6edf407906d7ba9e205979c3268270eef80a286e2d"; diff --git a/libs/coin-modules/coin-near/src/getFeesForTransaction.ts b/libs/coin-modules/coin-near/src/getFeesForTransaction.ts index 5839789c9198..923e10b776a7 100644 --- a/libs/coin-modules/coin-near/src/getFeesForTransaction.ts +++ b/libs/coin-modules/coin-near/src/getFeesForTransaction.ts @@ -1,6 +1,6 @@ import { BigNumber } from "bignumber.js"; -import { getGasPrice } from "./api/node"; -import { isImplicitAccount, getStakingFees } from "./logic"; +import { computeFees } from "./logic/fees"; +import { getGasPrice } from "./network/node"; import { getCurrentNearPreloadData } from "./preload-data"; import { Transaction } from "./types"; @@ -8,32 +8,13 @@ const getEstimatedFees = async (transaction: Transaction): Promise => const rawGasPrice = await getGasPrice(); const gasPrice = new BigNumber(rawGasPrice); - if (["stake", "unstake", "withdraw"].includes(transaction.mode)) { - return getStakingFees(transaction, gasPrice); - } - - const { - createAccountCostSend, - createAccountCostExecution, - transferCostSend, - transferCostExecution, - addKeyCostSend, - addKeyCostExecution, - receiptCreationSend, - receiptCreationExecution, - } = getCurrentNearPreloadData(); - - let sendFee = transferCostSend.plus(receiptCreationSend); - let executionFee = transferCostExecution.plus(receiptCreationExecution); - - if (isImplicitAccount(transaction.recipient)) { - sendFee = sendFee.plus(createAccountCostSend).plus(addKeyCostSend); - executionFee = executionFee.plus(createAccountCostExecution).plus(addKeyCostExecution); - } - - const fees = sendFee.multipliedBy(gasPrice).plus(executionFee.multipliedBy(gasPrice)); - - return fees; + return computeFees({ + mode: transaction.mode, + recipient: transaction.recipient, + useAllAmount: transaction.useAllAmount ?? false, + gasPrice, + costs: getCurrentNearPreloadData(), + }); }; export default getEstimatedFees; diff --git a/libs/coin-modules/coin-near/src/getTransactionStatus.test.ts b/libs/coin-modules/coin-near/src/getTransactionStatus.test.ts index 005316c2244a..b784a1de1fae 100644 --- a/libs/coin-modules/coin-near/src/getTransactionStatus.test.ts +++ b/libs/coin-modules/coin-near/src/getTransactionStatus.test.ts @@ -1,5 +1,5 @@ import BigNumber from "bignumber.js"; -import { mockServer, NEAR_BASE_URL_MOCKED } from "./api/node.mock"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "./network/node.mock"; import { setCoinConfig } from "./config"; import getTransactionStatus from "./getTransactionStatus"; import { NearAccount, Transaction } from "./types"; diff --git a/libs/coin-modules/coin-near/src/getTransactionStatus.ts b/libs/coin-modules/coin-near/src/getTransactionStatus.ts index 6d37b4ecff75..eedfa836c4f7 100644 --- a/libs/coin-modules/coin-near/src/getTransactionStatus.ts +++ b/libs/coin-modules/coin-near/src/getTransactionStatus.ts @@ -10,7 +10,7 @@ import { } from "@ledgerhq/ledger-wallet-framework/errors"; import { AccountBridge } from "@ledgerhq/types-live"; import { BigNumber } from "bignumber.js"; -import { fetchAccountDetails } from "./api"; +import { fetchAccountDetails } from "./network"; import { NEW_ACCOUNT_SIZE, YOCTO_THRESHOLD_VARIATION } from "./constants"; import { NearNewAccountWarning, diff --git a/libs/coin-modules/coin-near/src/logic.ts b/libs/coin-modules/coin-near/src/logic.ts index ad0899c91b37..96bba1a4a951 100644 --- a/libs/coin-modules/coin-near/src/logic.ts +++ b/libs/coin-modules/coin-near/src/logic.ts @@ -29,7 +29,11 @@ export const isImplicitAccount = (address: string): boolean => { return !address.includes("."); }; -export const getStakingGas = (t?: Transaction, multiplier = 5): BigNumber => { +/** Only the mode and useAllAmount flag drive staking gas, so callers outside the account bridge + * (which has no `Transaction`) can pass just those two fields. */ +export type StakingGasInput = { mode?: string; useAllAmount?: boolean }; + +export const getStakingGas = (t?: StakingGasInput, multiplier = 5): BigNumber => { const stakingGasBase = new BigNumber(STAKING_GAS_BASE); if (t?.mode === "withdraw" && t?.useAllAmount) { @@ -167,12 +171,13 @@ export const getYoctoThreshold = (): BigNumber => { /* * An estimation for the fee by using the staking gas and scaling accordingly. * Buffer added so that the transaction never fails - we'll always overestimate. + * + * The runtime locks the whole prepaid gas at conversion time and refunds the unburnt part + * afterwards, so the fee charges the full prepaid amount rather than a fraction of it — the + * refund shows up on-chain the same way it does for the account bridge. */ -export const getStakingFees = (t: Transaction, gasPrice: BigNumber): BigNumber => { +export const getStakingFees = (t: StakingGasInput, gasPrice: BigNumber): BigNumber => { const stakingGas = getStakingGas(t); - return stakingGas - .plus(STAKING_GAS_BASE) // Buffer - .multipliedBy(gasPrice) - .dividedBy(10); + return stakingGas.plus(STAKING_GAS_BASE).multipliedBy(gasPrice); // Buffer }; diff --git a/libs/coin-modules/coin-near/src/logic/actions.ts b/libs/coin-modules/coin-near/src/logic/actions.ts new file mode 100644 index 000000000000..2f8a8a426e7c --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/actions.ts @@ -0,0 +1,58 @@ +import * as nearAPI from "near-api-js"; +import type { Action } from "near-api-js/lib/transaction"; +import { getStakingGas } from "../logic"; + +export type ActionsInput = { + mode: string; + /** Amount in yoctoNEAR. */ + amount: string; + useAllAmount?: boolean; +}; + +// One action per supported mode. Staking goes through the pool contract (attached deposit when +// staking, call argument otherwise); `_all` variants take no amount, so "use all" can't leave dust +// behind if the balance moves between crafting and execution. +export const buildActions = ({ mode, amount, useAllAmount }: ActionsInput): Action[] => { + switch (mode) { + case "stake": + return [ + nearAPI.transactions.functionCall( + "deposit_and_stake", + {}, + getStakingGas().toFixed(), + amount, + ), + ]; + case "unstake": + return useAllAmount + ? [nearAPI.transactions.functionCall("unstake_all", {}, getStakingGas().toFixed(), "0")] + : [ + nearAPI.transactions.functionCall( + "unstake", + { amount }, + getStakingGas().toFixed(), + "0", + ), + ]; + case "withdraw": + return useAllAmount + ? [ + nearAPI.transactions.functionCall( + "withdraw_all", + {}, + getStakingGas({ mode, useAllAmount }).toNumber(), + "0", + ), + ] + : [ + nearAPI.transactions.functionCall( + "withdraw", + { amount }, + getStakingGas().toFixed(), + "0", + ), + ]; + default: + return [nearAPI.transactions.transfer(amount)]; + } +}; diff --git a/libs/coin-modules/coin-near/src/logic/broadcast.ts b/libs/coin-modules/coin-near/src/logic/broadcast.ts new file mode 100644 index 000000000000..15ced0c7ffd1 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/broadcast.ts @@ -0,0 +1,10 @@ +import type { BroadcastConfig } from "@ledgerhq/coin-module-framework/api/types"; +import { broadcastTransaction } from "../network"; + +/** + * Submit a signed transaction (base64, from {@link combine}) and return its hash. The retry on the + * node's 10-second timeout lives in the network layer. + */ +export async function broadcast(tx: string, _broadcastConfig?: BroadcastConfig): Promise { + return broadcastTransaction(tx); +} diff --git a/libs/coin-modules/coin-near/src/logic/combine.ts b/libs/coin-modules/coin-near/src/logic/combine.ts new file mode 100644 index 000000000000..1cd9a0c0bd6a --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/combine.ts @@ -0,0 +1,32 @@ +import * as nearAPI from "near-api-js"; + +// Refuses what `Buffer.from(value, "hex")` would quietly truncate: it stops at the first invalid +// character and drops a trailing half-byte, which would attach a wrong-length signature that only fails at broadcast. +function decodeSignature(signature: string): Buffer { + const decoded = Buffer.from(signature, "hex"); + + if (signature.length % 2 !== 0 || decoded.length !== signature.length / 2) { + throw new Error("Near: signature is not valid hex"); + } + if (decoded.length === 0) { + throw new Error("Near: signature is empty"); + } + + return decoded; +} + +// Attaches a device signature and returns the signed, base64'd transaction ready to broadcast. The +// key type is read back from the transaction's own public key, so it always matches the crafting key. +export function combine(tx: string, signature: string, _pubkey?: string): string { + const transaction = nearAPI.transactions.Transaction.decode(Buffer.from(tx, "base64")); + + const signedTransaction = new nearAPI.transactions.SignedTransaction({ + transaction, + signature: new nearAPI.transactions.Signature({ + keyType: transaction.publicKey.keyType, + data: decodeSignature(signature), + }), + }); + + return Buffer.from(signedTransaction.encode()).toString("base64"); +} diff --git a/libs/coin-modules/coin-near/src/logic/craftTransaction.ts b/libs/coin-modules/coin-near/src/logic/craftTransaction.ts new file mode 100644 index 000000000000..339ff58b40c7 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/craftTransaction.ts @@ -0,0 +1,103 @@ +import type { + CraftedTransaction, + FeeEstimation, + StakingTransactionIntent, + TransactionIntent, +} from "@ledgerhq/coin-module-framework/api/index"; +import * as nearAPI from "near-api-js"; +import { getAccessKey } from "../network"; +import { buildActions } from "./actions"; +import { isValidAddress } from "../logic"; + +export type NearIntent = TransactionIntent | StakingTransactionIntent; + +// The framework names a staking op via `mode` (delegation set, only filled when a validator is +// set) or `type` (always set); both map onto the module's internal modes. +const STAKING_MODES: Record = { + delegate: "stake", + undelegate: "unstake", + withdraw: "withdraw", + stake: "stake", + unstake: "unstake", + finalize_unstake: "withdraw", +}; + +const isStakingIntent = (intent: NearIntent): intent is StakingTransactionIntent => + intent.intentType === "staking"; + +// The module's internal mode plus the transaction's target (the pool contract for staking, carried +// as `valAddress` or else the recipient). An absent target returns "" rather than throwing: pricing +// and validation run while the form is still being filled, only crafting genuinely needs it. +export function resolveTarget(intent: NearIntent): { mode: string; receiverId: string } { + if (!isStakingIntent(intent)) { + return { mode: "send", receiverId: intent.recipient }; + } + + const operation = intent.mode ?? (intent as { type?: string }).type; + const mode = operation ? STAKING_MODES[operation] : undefined; + if (!mode) { + throw new Error(`Near: staking operation ${operation} is not supported`); + } + + return { mode, receiverId: intent.valAddress || intent.recipient || "" }; +} + +// Crafts an unsigned, Borsh-encoded + base64 transaction for {@link combine} and device signing. +// senderPublicKey is required: the nonce is scoped to that access key, not the account, so it's +// fetched here rather than via `getNextSequence` (which isn't implemented for that reason). +export async function craftTransaction( + intent: NearIntent, + _customFees?: FeeEstimation, +): Promise { + if (!intent.senderPublicKey) { + throw new Error("Near: senderPublicKey is required to craft a transaction"); + } + + const { mode, receiverId } = resolveTarget(intent); + + if (!receiverId) { + throw new Error( + isStakingIntent(intent) + ? "Near: a validator address is required for a staking transaction" + : "Near: recipient is required", + ); + } + + // Validated up front because both feed the access-key lookup below: a malformed value should + // fail here rather than after a wasted network round trip. + if (!isValidAddress(intent.sender)) { + throw new Error(`Near: invalid sender address ${intent.sender}`); + } + if (!isValidAddress(receiverId)) { + throw new Error(`Near: invalid recipient address ${receiverId}`); + } + + const { nonce, block_hash } = await getAccessKey({ + address: intent.sender, + publicKey: intent.senderPublicKey, + }); + + if (block_hash === undefined || nonce === undefined) { + throw new Error(`Near: no access key found for ${intent.sender}`); + } + + const actions = buildActions({ + mode, + amount: intent.amount.toString(), + useAllAmount: intent.useAllAmount ?? false, + }); + + const transaction = nearAPI.transactions.createTransaction( + intent.sender, + nearAPI.utils.PublicKey.fromString(intent.senderPublicKey), + receiverId, + nonce + 1, + actions, + nearAPI.utils.serialize.base_decode(block_hash), + ); + + return { + transaction: Buffer.from(transaction.encode()).toString("base64"), + details: { mode, nonce: nonce + 1, receiverId }, + }; +} diff --git a/libs/coin-modules/coin-near/src/logic/estimateFees.ts b/libs/coin-modules/coin-near/src/logic/estimateFees.ts new file mode 100644 index 000000000000..5787e963d089 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/estimateFees.ts @@ -0,0 +1,59 @@ +import type { FeeEstimation } from "@ledgerhq/coin-module-framework/api/index"; +import { BigNumber } from "bignumber.js"; +import { getActionCosts, getGasPrice } from "../network"; +import { computeFees } from "./fees"; +import { pooledAmount } from "./pooledAmount"; +import { resolveTarget, type NearIntent } from "./craftTransaction"; + +/** A caller-supplied gas price, if it is a usable positive integer. */ +function overriddenGasPrice(parameters: FeeEstimation["parameters"]): string | undefined { + const value = parameters?.gasPrice; + + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") { + return undefined; + } + + const gasPrice = new BigNumber(value.toString()); + + return gasPrice.isInteger() && gasPrice.gt(0) ? gasPrice.toFixed() : undefined; +} + +// Fee for an intent, in yoctoNEAR, sourced from the protocol config since nothing preloads on this +// path (preload defaults are zeros, which would silently yield a zero fee). A missing recipient +// prices as zero rather than erroring, so a form can price while still being filled in. +export async function estimateFees( + intent: NearIntent, + customFeesParameters?: FeeEstimation["parameters"], +): Promise { + const { mode, receiverId } = resolveTarget(intent); + + if (!receiverId) { + return { value: 0n, parameters: {} }; + } + + const override = overriddenGasPrice(customFeesParameters); + const [liveGasPrice, costs] = await Promise.all([ + override === undefined ? getGasPrice() : Promise.resolve(override), + getActionCosts(), + ]); + + const fees = computeFees({ + mode, + recipient: receiverId, + useAllAmount: intent.useAllAmount ?? false, + gasPrice: new BigNumber(liveGasPrice), + costs, + }); + + const value = BigInt(fees.toFixed(0)); + + // Unstaking and withdrawing move funds the pool holds, not the liquid balance, so "use all" + // cannot be derived from the native balance. `computeUseAllAmount` prefers `parameters.amount` + // when the module supplies it, which is the only way to tell the framework the real ceiling. + if (mode === "unstake" || mode === "withdraw") { + const pooled = await pooledAmount(mode, intent.sender, receiverId); + return { value, parameters: { gasPrice: liveGasPrice, amount: pooled } }; + } + + return { value, parameters: { gasPrice: liveGasPrice } }; +} diff --git a/libs/coin-modules/coin-near/src/logic/fees.ts b/libs/coin-modules/coin-near/src/logic/fees.ts new file mode 100644 index 000000000000..390cb8cef186 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/fees.ts @@ -0,0 +1,47 @@ +import { BigNumber } from "bignumber.js"; +import type { NearActionCosts } from "../network/protocolConfig"; +import { getStakingFees, isImplicitAccount } from "../logic"; + +/** Gas costs the fee formula needs; a subset of {@link NearActionCosts}. */ +export type NearFeeCosts = Omit; + +export type FeeInput = { + mode: string; + recipient: string; + useAllAmount?: boolean; + gasPrice: BigNumber; + costs: NearFeeCosts; +}; + +const STAKING_MODES = new Set(["stake", "unstake", "withdraw"]); + +// Fee for a transaction, in yoctoNEAR — single formula shared by the account bridge (`costs` from +// preload) and CoinModuleApi (from the protocol config). Sending to a not-yet-existing implicit +// account also pays for creating it and adding its access key. +export const computeFees = ({ + mode, + recipient, + useAllAmount, + gasPrice, + costs, +}: FeeInput): BigNumber => { + if (STAKING_MODES.has(mode)) { + return getStakingFees({ mode, useAllAmount: useAllAmount ?? false }, gasPrice); + } + + let sendFee = costs.transferCostSend.plus(costs.receiptCreationSend); + let executionFee = costs.transferCostExecution.plus(costs.receiptCreationExecution); + + if (isImplicitAccount(recipient)) { + sendFee = sendFee.plus(costs.createAccountCostSend).plus(costs.addKeyCostSend); + executionFee = executionFee + .plus(costs.createAccountCostExecution) + .plus(costs.addKeyCostExecution); + } + + // nearcore floors the gas price on the execution half only (runtime/runtime/src/config.rs) — + // the send half is always priced at the raw gas price. + const executionGasPrice = BigNumber.max(gasPrice, costs.minGasPurchasePrice); + + return sendFee.multipliedBy(gasPrice).plus(executionFee.multipliedBy(executionGasPrice)); +}; diff --git a/libs/coin-modules/coin-near/src/logic/getBalance.ts b/libs/coin-modules/coin-near/src/logic/getBalance.ts new file mode 100644 index 000000000000..d1358da7c052 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/getBalance.ts @@ -0,0 +1,40 @@ +import type { AssetInfo, Balance } from "@ledgerhq/coin-module-framework/api/index"; +import { getAccount } from "../network"; +import { toStakes } from "./getStakes"; + +export const NATIVE_ASSET: AssetInfo = { type: "native" }; + +/** + * Native balance plus one entry per staking position. + * + * The native entry comes first on purpose: the generic coin-framework picks the account balance + * from the *first* native entry, and the staking entries are also native. + * + * Its value is the account total, delegated buckets included, which is what the account bridge + * reports as the account balance. `locked` is the non-spendable part of it: the staked, pending + * and withdrawable buckets plus the storage deposit and minimum-balance buffer, capped at the + * value so it can never claim more is locked than the account holds. `value - locked` then + * reproduces the account bridge's spendable balance, which floors at zero. This mirrors + * coin-tezos, which likewise reports the total as `value` and the frozen part as `locked`. + */ +export async function getBalance(address: string): Promise { + const { balance, nearResources } = await getAccount(address); + + const nonSpendable = nearResources.stakedBalance + .plus(nearResources.availableBalance) + .plus(nearResources.pendingBalance) + .plus(nearResources.storageUsageBalance); + + const value = BigInt(balance.toFixed(0)); + const frozen = BigInt(nonSpendable.toFixed(0)); + const locked = frozen > value ? value : frozen; + + return [ + { value, asset: NATIVE_ASSET, locked }, + ...toStakes(address, nearResources.stakingPositions).map(stake => ({ + value: stake.amount, + asset: NATIVE_ASSET, + stake, + })), + ]; +} diff --git a/libs/coin-modules/coin-near/src/logic/getBlockInfo.ts b/libs/coin-modules/coin-near/src/logic/getBlockInfo.ts new file mode 100644 index 000000000000..c0429247bb72 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/getBlockInfo.ts @@ -0,0 +1,15 @@ +import type { BlockInfo } from "@ledgerhq/coin-module-framework/api/index"; +import { getBlockHeaderAtHeight } from "../network"; +import type { NearBlockHeader } from "../network/sdk.types"; + +/** NEAR block timestamps are nanoseconds since the epoch. */ +export const toBlockInfo = (header: NearBlockHeader): BlockInfo => ({ + height: header.height, + hash: header.hash, + time: new Date(header.timestamp / 1e6), +}); + +/** Block metadata at a given height, via the JSON-RPC `block` method. */ +export async function getBlockInfo(height: number): Promise { + return toBlockInfo(await getBlockHeaderAtHeight(height)); +} diff --git a/libs/coin-modules/coin-near/src/logic/getStakes.ts b/libs/coin-modules/coin-near/src/logic/getStakes.ts new file mode 100644 index 000000000000..faa5c1ad47ed --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/getStakes.ts @@ -0,0 +1,61 @@ +import type { Cursor, Page, Stake, StakeAction } from "@ledgerhq/coin-module-framework/api/index"; +import { getStakingPositions } from "../network"; +import type { NearStakingPosition } from "../network/sdk.types"; +import { canUnstake, canWithdraw } from "../logic"; + +// Maps a delegation to up to three stakes (staked, unbonding, withdrawable) per pool. Rewards +// compound into the staked balance so aren't a separate position, and sub-threshold dust (the node +// under-reports staked by 1 yoctoNEAR) is dropped. +export function toStakes(address: string, positions: NearStakingPosition[]): Stake[] { + const items: Stake[] = []; + + for (const position of positions) { + const { validatorId, staked, available, pending } = position; + + if (canUnstake(position)) { + const actions: StakeAction[] = ["undelegate"]; + items.push({ + uid: `${address}:${validatorId}:staked`, + address, + delegate: validatorId, + state: "active", + actions, + asset: { type: "native" }, + amount: BigInt(staked.toFixed(0)), + }); + } + + if (pending.gt(0)) { + items.push({ + uid: `${address}:${validatorId}:pending`, + address, + delegate: validatorId, + state: "deactivating", + actions: [], + asset: { type: "native" }, + amount: BigInt(pending.toFixed(0)), + }); + } + + if (canWithdraw(position)) { + items.push({ + uid: `${address}:${validatorId}:available`, + address, + delegate: validatorId, + state: "withdrawable", + actions: ["withdraw"], + asset: { type: "native" }, + amount: BigInt(available.toFixed(0)), + }); + } + } + + return items; +} + +/** Staking positions across every pool the account has delegated to. Single page. */ +export async function getStakes(address: string, _cursor?: Cursor): Promise> { + const { stakingPositions } = await getStakingPositions(address); + + return { items: toStakes(address, stakingPositions), next: undefined }; +} diff --git a/libs/coin-modules/coin-near/src/logic/getValidators.ts b/libs/coin-modules/coin-near/src/logic/getValidators.ts new file mode 100644 index 000000000000..3dfe00178896 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/getValidators.ts @@ -0,0 +1,20 @@ +import type { Cursor, Page, Validator } from "@ledgerhq/coin-module-framework/api/index"; +import { VALIDATORS_COUNT } from "../constants"; +import { getValidators as fetchValidators } from "../network"; + +/** + * Staking pools available for delegation. The indexer call is LRU-cached upstream and returns a + * single capped page, so `next` is always undefined. + */ +export async function getValidators(_cursor?: Cursor): Promise> { + const validators = await fetchValidators({ total: VALIDATORS_COUNT }); + + const items: Validator[] = validators.map(({ account_id, stake, commission }) => ({ + address: account_id, + name: account_id, + balance: BigInt(stake || "0"), + commissionRate: String(commission), + })); + + return { items, next: undefined }; +} diff --git a/libs/coin-modules/coin-near/src/logic/lastBlock.ts b/libs/coin-modules/coin-near/src/logic/lastBlock.ts new file mode 100644 index 000000000000..acb67eeea8a4 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/lastBlock.ts @@ -0,0 +1,8 @@ +import type { BlockInfo } from "@ledgerhq/coin-module-framework/api/index"; +import { getLastBlockHeader } from "../network"; +import { toBlockInfo } from "./getBlockInfo"; + +/** Metadata of the latest final block. */ +export async function lastBlock(): Promise { + return toBlockInfo(await getLastBlockHeader()); +} diff --git a/libs/coin-modules/coin-near/src/logic/listOperations.ts b/libs/coin-modules/coin-near/src/logic/listOperations.ts new file mode 100644 index 000000000000..a32c0c411316 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/listOperations.ts @@ -0,0 +1,77 @@ +import type { + ListOperationsOptions, + MemoNotSupported, + Operation, + Page, +} from "@ledgerhq/coin-module-framework/api/index"; +import { fetchTransactionsPage } from "../network"; +import { getOperationType } from "../network/indexer"; +import type { NearTransaction } from "../network/sdk.types"; + +/** The indexer defaults to 25 and 422s on anything outside this range. */ +const MIN_PAGE_SIZE = 1; +const MAX_PAGE_SIZE = 100; + +const blockHeight = (transaction: NearTransaction): number => + transaction.block?.block_height !== undefined ? Number(transaction.block.block_height) : 0; + +// Unlike the account bridge, an OUT value here is the transferred amount only — the generic +// framework adds `tx.fees` on top for outgoing ops, so folding the fee in here would double-charge it. +export function toOperation(transaction: NearTransaction, address: string): Operation { + const type = getOperationType(transaction, address); + const date = new Date(Number.parseFloat(transaction.block_timestamp) / 1e6); + + return { + id: transaction.transaction_hash, + type, + senders: transaction.signer_account_id ? [transaction.signer_account_id] : [], + recipients: transaction.receiver_account_id ? [transaction.receiver_account_id] : [], + value: BigInt(transaction.actions_agg?.deposit || 0), + asset: { type: "native" }, + tx: { + hash: transaction.transaction_hash, + block: { + height: blockHeight(transaction), + hash: transaction.block?.block_hash ?? "", + time: date, + }, + fees: BigInt(transaction.outcomes_agg?.transaction_fee || 0), + date, + failed: transaction.outcomes?.status === false, + }, + }; +} + +// Paginated, newest-first history. The cursor is the indexer's own opaque token, forwarded +// unchanged; paging is forward-only newest-to-oldest, so ascending order can't be honoured across pages. +export async function listOperations( + address: string, + options: ListOperationsOptions, +): Promise>> { + if (options.order === "asc") { + throw new Error("ascending order is not supported"); + } + + // `limit` is a soft limit by contract, and the indexer 422s on anything outside its range, so an + // out-of-range request is clamped rather than forwarded and turned into a network error. + const limit = Math.min(Math.max(options.limit ?? MAX_PAGE_SIZE, MIN_PAGE_SIZE), MAX_PAGE_SIZE); + + const { transactions, next } = await fetchTransactionsPage(address, { + ...(options.cursor !== undefined && { cursor: options.cursor }), + limit, + }); + + const minHeight = options.minHeight || 0; + // Pages run newest-first, so a transaction below the floor means everything after it is older: + // drop it and stop advertising a next cursor. + const reachedFloor = transactions.some(transaction => blockHeight(transaction) < minHeight); + + const items = transactions + .filter(transaction => blockHeight(transaction) >= minHeight) + .map(transaction => toOperation(transaction, address)); + + return { + items, + next: reachedFloor ? undefined : next, + }; +} diff --git a/libs/coin-modules/coin-near/src/logic/pooledAmount.ts b/libs/coin-modules/coin-near/src/logic/pooledAmount.ts new file mode 100644 index 000000000000..74ff80183e60 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/pooledAmount.ts @@ -0,0 +1,41 @@ +import type { Balance } from "@ledgerhq/coin-module-framework/api/index"; +import { getStakingPositions } from "../network"; + +/** Sum of the staking positions in a given state, per pool. */ +function stakedAt(balances: Balance[], delegate: string, states: string[]): bigint { + return balances + .filter(b => b.stake !== undefined && b.stake.delegate === delegate) + .filter(b => states.includes(b.stake!.state)) + .reduce((acc, b) => acc + b.stake!.amount, 0n); +} + +/** + * What the pool holds for the sender, in the bucket the mode moves: the delegated amount for + * `unstake`, the released one for `withdraw`. + * + * Balances are the source when the caller has them and they carry staking entries. The generic + * framework rebuilds balances from the account and drops those entries, and prices fees without any + * balances at all, so the pool is queried directly in both cases. coin-tezos reads the chain inside + * its own validation for the same reason. + */ +export async function pooledAmount( + mode: string, + sender: string, + delegate: string, + balances?: Balance[], +): Promise { + const states = mode === "unstake" ? ["active", "activating"] : ["withdrawable"]; + + if (balances?.some(b => b.stake !== undefined)) { + return stakedAt(balances, delegate, states); + } + + const { stakingPositions } = await getStakingPositions(sender); + const position = stakingPositions.find(p => p.validatorId === delegate); + + if (!position) { + return 0n; + } + + return BigInt((mode === "unstake" ? position.staked : position.available).toFixed(0)); +} diff --git a/libs/coin-modules/coin-near/src/logic/tests/actions.test.ts b/libs/coin-modules/coin-near/src/logic/tests/actions.test.ts new file mode 100644 index 000000000000..bee92b066b09 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/actions.test.ts @@ -0,0 +1,67 @@ +import { buildActions } from "../actions"; + +const AMOUNT = "1000000000000000000000000"; + +type FunctionCall = { + methodName: string; + deposit: { toString(): string }; + gas: { toString(): string }; + args: Uint8Array; +}; + +/** functionCall wraps the call in an enum; unwrap to the payload the test asserts on. */ +const functionCallOf = (action: ReturnType[number]) => + (action as unknown as { functionCall: FunctionCall }).functionCall; + +const argsOf = (action: ReturnType[number]) => + JSON.parse(Buffer.from(functionCallOf(action).args).toString()); + +describe("buildActions", () => { + it("builds a transfer for the default mode", () => { + const [action] = buildActions({ mode: "send", amount: AMOUNT }); + + expect( + ( + action as unknown as { transfer: { deposit: { toString(): string } } } + ).transfer.deposit.toString(), + ).toBe(AMOUNT); + }); + + it("stakes by attaching the amount as the deposit of deposit_and_stake", () => { + const [action] = buildActions({ mode: "stake", amount: AMOUNT }); + + expect(functionCallOf(action).methodName).toBe("deposit_and_stake"); + expect(functionCallOf(action).deposit.toString()).toBe(AMOUNT); + }); + + it("passes the amount as a call argument when unstaking a partial amount", () => { + const [action] = buildActions({ mode: "unstake", amount: AMOUNT }); + + expect(functionCallOf(action).methodName).toBe("unstake"); + expect(argsOf(action)).toEqual({ amount: AMOUNT }); + expect(functionCallOf(action).deposit.toString()).toBe("0"); + }); + + it("calls unstake_all with no amount when unstaking everything", () => { + const [action] = buildActions({ mode: "unstake", amount: AMOUNT, useAllAmount: true }); + + expect(functionCallOf(action).methodName).toBe("unstake_all"); + expect(argsOf(action)).toEqual({}); + }); + + it("passes the amount as a call argument when withdrawing a partial amount", () => { + const [action] = buildActions({ mode: "withdraw", amount: AMOUNT }); + + expect(functionCallOf(action).methodName).toBe("withdraw"); + expect(argsOf(action)).toEqual({ amount: AMOUNT }); + }); + + it("calls withdraw_all with extra gas when withdrawing everything", () => { + const [all] = buildActions({ mode: "withdraw", amount: AMOUNT, useAllAmount: true }); + const [partial] = buildActions({ mode: "withdraw", amount: AMOUNT }); + + expect(functionCallOf(all).methodName).toBe("withdraw_all"); + expect(argsOf(all)).toEqual({}); + expect(Number(functionCallOf(all).gas)).toBeGreaterThan(Number(functionCallOf(partial).gas)); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/broadcast.test.ts b/libs/coin-modules/coin-near/src/logic/tests/broadcast.test.ts new file mode 100644 index 000000000000..305bcb21f36c --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/broadcast.test.ts @@ -0,0 +1,50 @@ +import { http, HttpResponse } from "msw"; +import { setMockCoinConfig } from "../../test/coinConfig"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "../../network/node.mock"; +import { broadcast } from "../broadcast"; + +describe("broadcast (MSW)", () => { + beforeAll(() => { + setMockCoinConfig(); + mockServer.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => mockServer.resetHandlers()); + afterAll(() => mockServer.close()); + + it("submits the signed transaction and returns the hash the node assigns", async () => { + let sentPayload: string | undefined; + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, async ({ request }) => { + const body = (await request.json()) as { params: { signed_tx_base64: string } }; + sentPayload = body.params.signed_tx_base64; + return HttpResponse.json({ + jsonrpc: "2.0", + id: "id", + result: { transaction: { hash: "GkQ7Uh8oPPGtVfyPz1yLKmqPqZ8ZyxvGtN5MmYq8mF1w" } }, + }); + }), + ); + + await expect(broadcast("ZmFrZS1zaWduZWQtdHg=")).resolves.toBe( + "GkQ7Uh8oPPGtVfyPz1yLKmqPqZ8ZyxvGtN5MmYq8mF1w", + ); + expect(sentPayload).toBe("ZmFrZS1zaWduZWQtdHg="); + }); + + it("surfaces the node's error instead of a hash on an HTTP-200 failure body", async () => { + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, () => + HttpResponse.json({ + jsonrpc: "2.0", + id: "id", + error: { cause: { name: "INVALID_TRANSACTION" }, message: "nonce too small" }, + }), + ), + ); + + await expect(broadcast("ZmFrZS1zaWduZWQtdHg=")).rejects.toThrow( + "INVALID_TRANSACTION: nonce too small", + ); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/broadcast.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/broadcast.unit.test.ts new file mode 100644 index 000000000000..106e78a71e1b --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/broadcast.unit.test.ts @@ -0,0 +1,26 @@ +import { broadcastTransaction } from "../../network"; +import { broadcast } from "../broadcast"; + +jest.mock("../../network", () => ({ broadcastTransaction: jest.fn() })); + +const SIGNED_TX = "ZmFrZS1zaWduZWQtdHg="; +const HASH = "GkQ7Uh8oPPGtVfyPz1yLKmqPqZ8ZyxvGtN5MmYq8mF1w"; + +describe("broadcast", () => { + beforeEach(() => jest.clearAllMocks()); + + it("submits the signed transaction and returns its hash", async () => { + (broadcastTransaction as jest.Mock).mockResolvedValue(HASH); + + await expect(broadcast(SIGNED_TX)).resolves.toBe(HASH); + expect(broadcastTransaction).toHaveBeenCalledWith(SIGNED_TX); + }); + + it("propagates a node error", async () => { + (broadcastTransaction as jest.Mock).mockRejectedValue( + new Error("INVALID_TRANSACTION: nonce too small"), + ); + + await expect(broadcast(SIGNED_TX)).rejects.toThrow("nonce too small"); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/combine.test.ts b/libs/coin-modules/coin-near/src/logic/tests/combine.test.ts new file mode 100644 index 000000000000..850f77d9d555 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/combine.test.ts @@ -0,0 +1,89 @@ +import * as nearAPI from "near-api-js"; +import { combine } from "../combine"; + +const SENDER = "sender.near"; +const RECIPIENT = "recipient.near"; +const PUBLIC_KEY = "ed25519:HYgHRZBqhvhV4RLsBTz2CoM3JMVYFHDs1QLLZfDdWfPn"; +const BLOCK_HASH = "6ykMPuAsmyPvVMSLKvfg7DBUZP9tYcgKNzVLrLxSnLpj"; +const SIGNATURE = "ab".repeat(64); + +const unsigned = (): string => { + const transaction = nearAPI.transactions.createTransaction( + SENDER, + nearAPI.utils.PublicKey.fromString(PUBLIC_KEY), + RECIPIENT, + 42, + [nearAPI.transactions.transfer("1000000000000000000000000")], + nearAPI.utils.serialize.base_decode(BLOCK_HASH), + ); + + return Buffer.from(transaction.encode()).toString("base64"); +}; + +describe("combine", () => { + it("returns a signed transaction wrapping the crafted one unchanged", () => { + const tx = unsigned(); + const crafted = nearAPI.transactions.Transaction.decode(Buffer.from(tx, "base64")); + + const signed = nearAPI.transactions.SignedTransaction.decode( + Buffer.from(combine(tx, SIGNATURE), "base64"), + ); + + expect(signed.transaction.signerId).toBe(SENDER); + expect(signed.transaction.receiverId).toBe(RECIPIENT); + expect(signed.transaction.nonce.toString()).toBe(crafted.nonce.toString()); + expect(Buffer.from(signed.transaction.blockHash).toString("hex")).toBe( + Buffer.from(crafted.blockHash).toString("hex"), + ); + }); + + it("carries the signature bytes through", () => { + const signed = nearAPI.transactions.SignedTransaction.decode( + Buffer.from(combine(unsigned(), SIGNATURE), "base64"), + ); + + expect(Buffer.from(signed.signature.data).toString("hex")).toBe(SIGNATURE); + }); + + it("takes the key type from the transaction's own public key", () => { + const tx = unsigned(); + const expected = nearAPI.transactions.Transaction.decode(Buffer.from(tx, "base64")).publicKey + .keyType; + + const signed = nearAPI.transactions.SignedTransaction.decode( + Buffer.from(combine(tx, SIGNATURE), "base64"), + ); + + expect(signed.signature.keyType).toBe(expected); + }); + + it("throws on a payload that is not a crafted transaction", () => { + expect(() => combine("bm90LWEtdHJhbnNhY3Rpb24=", SIGNATURE)).toThrow( + "the buffer is smaller than expected", + ); + }); + + describe("malformed signatures", () => { + // Buffer.from(value, "hex") stops at the first invalid character and drops a trailing + // half-byte, so without a guard these would be attached silently at the wrong length. + it.each([ + ["a non-hex character", `zz${"ab".repeat(63)}`], + ["an odd number of characters", "abc"], + ])("rejects %s", (_label, signature) => { + expect(() => combine(unsigned(), signature)).toThrow("signature is not valid hex"); + }); + + it("rejects an empty signature", () => { + expect(() => combine(unsigned(), "")).toThrow("signature is empty"); + }); + + it("accepts a well-formed 64-byte signature", () => { + expect(() => combine(unsigned(), "ab".repeat(64))).not.toThrow(); + }); + + it("lets Borsh reject a well-formed signature of the wrong length", () => { + // The NEAR schema pins the signature at 64 bytes, so a longer one cannot be encoded. + expect(() => combine(unsigned(), "ab".repeat(65))).toThrow("does not match schema length 64"); + }); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/craftTransaction.parity.test.ts b/libs/coin-modules/coin-near/src/logic/tests/craftTransaction.parity.test.ts new file mode 100644 index 000000000000..fad0986df138 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/craftTransaction.parity.test.ts @@ -0,0 +1,110 @@ +import type { + StakingTransactionIntent, + TransactionIntent, +} from "@ledgerhq/coin-module-framework/api/index"; +import { BigNumber } from "bignumber.js"; +import { buildTransaction } from "../../buildTransaction"; +import { getAccessKey } from "../../network"; +import type { Transaction } from "../../types"; +import { craftTransaction } from "../craftTransaction"; + +jest.mock("../../network", () => ({ getAccessKey: jest.fn() })); + +/** + * The crafted transaction must be byte-identical to what the account bridge builds. + * + * `buildTransaction` is the path that has been signing NEAR transactions in production, so pinning + * the new `craftTransaction` to its exact Borsh output is the strongest available evidence that the + * CoinModuleApi signs the same thing the bridge does. The access key is stubbed so both sides see + * the same nonce and block hash. + */ +const SENDER = "sender.near"; +const RECIPIENT = "recipient.near"; +const POOL = "astro-stakers.poolv1.near"; +const PUBLIC_KEY = "ed25519:HYgHRZBqhvhV4RLsBTz2CoM3JMVYFHDs1QLLZfDdWfPn"; +const BLOCK_HASH = "6ykMPuAsmyPvVMSLKvfg7DBUZP9tYcgKNzVLrLxSnLpj"; +const NONCE = 41; +const AMOUNT = "5000000000000000000000000"; + +// Derived from the function rather than imported: `src/logic/**` may not depend on +// @ledgerhq/types-live, and only `freshAddress` is read. +const account = { freshAddress: SENDER } as Parameters[0]; + +const bridgeTx = (mode: string, recipient: string, useAllAmount = false): Transaction => + ({ + family: "near", + mode, + recipient, + amount: new BigNumber(AMOUNT), + useAllAmount, + }) as Transaction; + +const sendIntent = (recipient: string, useAllAmount = false): TransactionIntent => + ({ + intentType: "transaction", + type: "send", + sender: SENDER, + recipient, + amount: BigInt(AMOUNT), + asset: { type: "native" }, + senderPublicKey: PUBLIC_KEY, + useAllAmount, + }) as TransactionIntent; + +const stakingIntent = ( + mode: StakingTransactionIntent["mode"], + useAllAmount = false, +): StakingTransactionIntent => + ({ + ...sendIntent(POOL, useAllAmount), + intentType: "staking", + type: mode, + mode, + valAddress: POOL, + }) as StakingTransactionIntent; + +const bridgeBytes = async (tx: Transaction): Promise => + Buffer.from((await buildTransaction(account, tx, PUBLIC_KEY)).encode()).toString("base64"); + +const apiBytes = async (intent: TransactionIntent | StakingTransactionIntent): Promise => + (await craftTransaction(intent)).transaction; + +describe("craftTransaction vs buildTransaction (byte parity)", () => { + beforeEach(() => { + jest.clearAllMocks(); + (getAccessKey as jest.Mock).mockResolvedValue({ nonce: NONCE, block_hash: BLOCK_HASH }); + }); + + it("encodes a transfer identically", async () => { + expect(await apiBytes(sendIntent(RECIPIENT))).toBe( + await bridgeBytes(bridgeTx("send", RECIPIENT)), + ); + }); + + it.each([ + ["delegate", "stake"], + ["undelegate", "unstake"], + ["withdraw", "withdraw"], + ] as const)("encodes a %s identically to the bridge's %s", async (intentMode, bridgeMode) => { + expect(await apiBytes(stakingIntent(intentMode))).toBe( + await bridgeBytes(bridgeTx(bridgeMode, POOL)), + ); + }); + + it.each([ + ["undelegate", "unstake"], + ["withdraw", "withdraw"], + ] as const)("encodes a %s with useAllAmount identically", async (intentMode, bridgeMode) => { + expect(await apiBytes(stakingIntent(intentMode, true))).toBe( + await bridgeBytes(bridgeTx(bridgeMode, POOL, true)), + ); + }); + + it("encodes a transfer to an implicit account identically", async () => { + const implicit = "4e7de0a21d8a20f970c86b6edf407906d7ba9e205979c3268270eef80a286e2d"; + + expect(await apiBytes(sendIntent(implicit))).toBe( + await bridgeBytes(bridgeTx("send", implicit)), + ); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/craftTransaction.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/craftTransaction.unit.test.ts new file mode 100644 index 000000000000..21fa64306933 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/craftTransaction.unit.test.ts @@ -0,0 +1,166 @@ +import type { + StakingTransactionIntent, + TransactionIntent, +} from "@ledgerhq/coin-module-framework/api/index"; +import * as nearAPI from "near-api-js"; +import { getAccessKey } from "../../network"; +import { craftTransaction } from "../craftTransaction"; + +jest.mock("../../network", () => ({ getAccessKey: jest.fn() })); + +const SENDER = "sender.near"; +const RECIPIENT = "recipient.near"; +const VALIDATOR = "astro-stakers.poolv1.near"; +const PUBLIC_KEY = "ed25519:HYgHRZBqhvhV4RLsBTz2CoM3JMVYFHDs1QLLZfDdWfPn"; +const BLOCK_HASH = "6ykMPuAsmyPvVMSLKvfg7DBUZP9tYcgKNzVLrLxSnLpj"; +const NONCE = 41; + +/** exactOptionalPropertyTypes is on, so an explicit `undefined` override needs the union. */ +type Overrides = { [K in keyof T]?: T[K] | undefined }; + +const intent = (overrides: Overrides = {}): TransactionIntent => + ({ + intentType: "transaction", + type: "send", + sender: SENDER, + recipient: RECIPIENT, + amount: 5_000_000_000_000_000_000_000_000n, + asset: { type: "native" }, + senderPublicKey: PUBLIC_KEY, + ...overrides, + }) as TransactionIntent; + +const stakingIntent = ( + mode: StakingTransactionIntent["mode"], + overrides: Overrides = {}, +): StakingTransactionIntent => + ({ + ...intent(), + intentType: "staking", + type: mode, + mode, + valAddress: VALIDATOR, + ...overrides, + }) as StakingTransactionIntent; + +/** What the framework actually hands us for NEAR: the operation in `type`, the pool as recipient. */ +const typedStakingIntent = (type: string): StakingTransactionIntent => + ({ + ...intent(), + intentType: "staking", + type, + recipient: VALIDATOR, + }) as StakingTransactionIntent; + +const decode = (base64: string) => + nearAPI.transactions.Transaction.decode(Buffer.from(base64, "base64")); + +const methodOf = (base64: string) => + (decode(base64).actions[0] as unknown as { functionCall: { methodName: string } }).functionCall + .methodName; + +describe("craftTransaction", () => { + beforeEach(() => { + jest.clearAllMocks(); + (getAccessKey as jest.Mock).mockResolvedValue({ nonce: NONCE, block_hash: BLOCK_HASH }); + }); + + it("crafts a transfer addressed to the recipient", async () => { + const { transaction, details } = await craftTransaction(intent()); + const decoded = decode(transaction); + + expect(decoded.signerId).toBe(SENDER); + expect(decoded.receiverId).toBe(RECIPIENT); + // Borsh decoding yields a plain { keyType, data }, so compare the raw key bytes. + expect(Buffer.from(decoded.publicKey.data).toString("hex")).toBe( + Buffer.from(nearAPI.utils.PublicKey.fromString(PUBLIC_KEY).data).toString("hex"), + ); + expect(details).toMatchObject({ mode: "send", receiverId: RECIPIENT }); + }); + + it("takes the next nonce from the sender's access key", async () => { + const { transaction, details } = await craftTransaction(intent()); + + expect(getAccessKey).toHaveBeenCalledWith({ address: SENDER, publicKey: PUBLIC_KEY }); + expect(decode(transaction).nonce.toString()).toBe(String(NONCE + 1)); + expect(details).toMatchObject({ nonce: NONCE + 1 }); + }); + + it.each([ + ["delegate", "stake"], + ["undelegate", "unstake"], + ["withdraw", "withdraw"], + ] as const)("addresses a %s intent to the staking pool as mode %s", async (mode, expected) => { + const { transaction, details } = await craftTransaction(stakingIntent(mode)); + + expect(decode(transaction).receiverId).toBe(VALIDATOR); + expect(details).toMatchObject({ mode: expected, receiverId: VALIDATOR }); + }); + + it("uses the _all pool method when a staking intent spends everything", async () => { + const { transaction } = await craftTransaction( + stakingIntent("undelegate", { useAllAmount: true }), + ); + + expect(methodOf(transaction)).toBe("unstake_all"); + }); + + it.each([ + ["stake", "deposit_and_stake"], + ["unstake", "unstake"], + ["finalize_unstake", "withdraw"], + ] as const)( + "calls %s on the pool when the operation only comes as an intent type", + async (type, method) => { + const { transaction } = await craftTransaction(typedStakingIntent(type)); + + expect(decode(transaction).receiverId).toBe(VALIDATOR); + expect(methodOf(transaction)).toBe(method); + }, + ); + + it("rejects a staking operation the module does not implement", async () => { + await expect(craftTransaction(stakingIntent("redelegate"))).rejects.toThrow( + "staking operation redelegate is not supported", + ); + }); + + it("requires a validator address for a staking intent", async () => { + await expect( + craftTransaction(stakingIntent("delegate", { valAddress: "", recipient: "" })), + ).rejects.toThrow("validator address is required"); + }); + + it("requires the sender public key, which carries the nonce's access key", async () => { + await expect(craftTransaction(intent({ senderPublicKey: undefined }))).rejects.toThrow( + "senderPublicKey is required", + ); + expect(getAccessKey).not.toHaveBeenCalled(); + }); + + it("requires a recipient", async () => { + await expect(craftTransaction(intent({ recipient: "" }))).rejects.toThrow( + "recipient is required", + ); + }); + + it("rejects a malformed sender before hitting the network", async () => { + await expect(craftTransaction(intent({ sender: "NOT VALID" }))).rejects.toThrow( + "invalid sender address NOT VALID", + ); + expect(getAccessKey).not.toHaveBeenCalled(); + }); + + it("rejects a malformed recipient before hitting the network", async () => { + await expect(craftTransaction(intent({ recipient: "NOT VALID" }))).rejects.toThrow( + "invalid recipient address NOT VALID", + ); + expect(getAccessKey).not.toHaveBeenCalled(); + }); + + it("fails clearly when the account has no access key for that public key", async () => { + (getAccessKey as jest.Mock).mockResolvedValue({}); + + await expect(craftTransaction(intent())).rejects.toThrow(`no access key found for ${SENDER}`); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/estimateFees.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/estimateFees.unit.test.ts new file mode 100644 index 000000000000..7932da0ac299 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/estimateFees.unit.test.ts @@ -0,0 +1,165 @@ +import type { + StakingTransactionIntent, + TransactionIntent, +} from "@ledgerhq/coin-module-framework/api/index"; +import { BigNumber } from "bignumber.js"; +import { getActionCosts, getGasPrice, getStakingPositions } from "../../network"; +import { getCurrentNearPreloadData } from "../../preload-data"; +import { estimateFees } from "../estimateFees"; + +jest.mock("../../network", () => ({ + getActionCosts: jest.fn(), + getGasPrice: jest.fn(), + getStakingPositions: jest.fn(), +})); + +const GAS_PRICE = "100000000"; + +const COSTS = { + storageCost: new BigNumber("10000000000000000000"), + transferCostSend: new BigNumber(115123062500), + transferCostExecution: new BigNumber(115123062500), + receiptCreationSend: new BigNumber(108059500000), + receiptCreationExecution: new BigNumber(108059500000), + createAccountCostSend: new BigNumber(99607375000), + createAccountCostExecution: new BigNumber(99607375000), + addKeyCostSend: new BigNumber(101765125000), + addKeyCostExecution: new BigNumber(101765125000), + minGasPurchasePrice: new BigNumber(0), + accountCreationCharge: new BigNumber(0), +}; + +/** exactOptionalPropertyTypes is on, so an explicit `undefined` override needs the union. */ +type Overrides = { [K in keyof T]?: T[K] | undefined }; + +const intent = (overrides: Overrides = {}): TransactionIntent => + ({ + intentType: "transaction", + type: "send", + sender: "sender.near", + recipient: "recipient.near", + amount: 1_000_000_000_000_000_000_000_000n, + asset: { type: "native" }, + ...overrides, + }) as TransactionIntent; + +describe("estimateFees", () => { + beforeEach(() => { + jest.clearAllMocks(); + (getGasPrice as jest.Mock).mockResolvedValue(GAS_PRICE); + (getActionCosts as unknown as jest.Mock).mockResolvedValue(COSTS); + }); + + it("returns a non-zero fee without any preload having run", async () => { + // The account bridge fills these in via preload(); nothing does on this path, so the defaults + // are zeros. A fee sourced from them would be zero and every transaction would fail on chain. + expect(getCurrentNearPreloadData().gasPrice.isZero()).toBe(true); + expect(getCurrentNearPreloadData().transferCostSend.isZero()).toBe(true); + + const { value } = await estimateFees(intent()); + + expect(value).toBeGreaterThan(0n); + expect(getActionCosts).toHaveBeenCalled(); + }); + + it("charges more for an implicit recipient, which the transfer has to create", async () => { + const named = await estimateFees(intent()); + const implicit = await estimateFees( + intent({ recipient: "4e7de0a21d8a20f970c86b6edf407906d7ba9e205979c3268270eef80a286e2d" }), + ); + + expect(implicit.value).toBeGreaterThan(named.value); + }); + + it("reports the gas price it priced with", async () => { + const { parameters } = await estimateFees(intent()); + + expect(parameters).toEqual({ gasPrice: GAS_PRICE }); + }); + + it("prices a staking intent from staking gas, not from transfer costs", async () => { + const staking = (await estimateFees({ + ...intent(), + intentType: "staking", + type: "delegate", + mode: "delegate", + valAddress: "astro-stakers.poolv1.near", + } as StakingTransactionIntent)) as { value: bigint }; + + const send = await estimateFees(intent()); + + expect(staking.value).toBeGreaterThan(0n); + expect(staking.value).not.toBe(send.value); + }); + + it("returns a zero estimate for an incomplete form instead of throwing", async () => { + const { value } = await estimateFees(intent({ recipient: "" })); + + expect(value).toBe(0n); + expect(getGasPrice).not.toHaveBeenCalled(); + }); + + it("returns a zero estimate for a staking intent with no validator picked yet", async () => { + const { value } = await estimateFees({ + ...intent({ recipient: "" }), + intentType: "staking", + type: "stake", + } as StakingTransactionIntent); + + expect(value).toBe(0n); + expect(getGasPrice).not.toHaveBeenCalled(); + }); + + it("reports the pool's released amount as the ceiling for a withdrawal", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ + stakingPositions: [ + { + validatorId: "astro-stakers.poolv1.near", + staked: new BigNumber(0), + available: new BigNumber("200000000000000000000000"), + pending: new BigNumber(0), + }, + ], + }); + + const { parameters } = await estimateFees({ + ...intent({ recipient: "astro-stakers.poolv1.near" }), + intentType: "staking", + type: "finalize_unstake", + } as unknown as StakingTransactionIntent); + + expect(parameters?.amount).toBe(200000000000000000000000n); + }); + + describe("with a caller-supplied gas price", () => { + it("prices from the override instead of querying the network", async () => { + const { value, parameters } = await estimateFees(intent(), { gasPrice: "200000000" }); + + expect(getGasPrice).not.toHaveBeenCalled(); + expect(parameters).toEqual({ gasPrice: "200000000" }); + + // Twice the live gas price, so twice the fee. + const live = await estimateFees(intent()); + expect(value).toBe(live.value * 2n); + }); + + it("accepts the bigint the framework hands fee parameters back as", async () => { + const { parameters } = await estimateFees(intent(), { gasPrice: 200_000_000n }); + + expect(parameters).toEqual({ gasPrice: "200000000" }); + expect(getGasPrice).not.toHaveBeenCalled(); + }); + + it.each([ + ["zero", 0], + ["negative", -1], + ["fractional", 1.5], + ["not a number", "abc"], + ["absent", undefined], + ])("falls back to the live gas price when the override is %s", async (_label, gasPrice) => { + await estimateFees(intent(), { gasPrice }); + + expect(getGasPrice).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/fees.test.ts b/libs/coin-modules/coin-near/src/logic/tests/fees.test.ts new file mode 100644 index 000000000000..138febe8bfac --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/fees.test.ts @@ -0,0 +1,110 @@ +import { BigNumber } from "bignumber.js"; +import { STAKING_GAS_BASE } from "../../constants"; +import { computeFees, type NearFeeCosts } from "../fees"; + +const GAS_PRICE = new BigNumber(100_000_000); + +const costs: NearFeeCosts = { + transferCostSend: new BigNumber(100), + transferCostExecution: new BigNumber(200), + receiptCreationSend: new BigNumber(10), + receiptCreationExecution: new BigNumber(20), + createAccountCostSend: new BigNumber(1_000), + createAccountCostExecution: new BigNumber(2_000), + addKeyCostSend: new BigNumber(500), + addKeyCostExecution: new BigNumber(700), + minGasPurchasePrice: new BigNumber(0), + accountCreationCharge: new BigNumber(0), +}; + +const NAMED_RECIPIENT = "recipient.near"; +const IMPLICIT_RECIPIENT = "4e7de0a21d8a20f970c86b6edf407906d7ba9e205979c3268270eef80a286e2d"; + +describe("computeFees", () => { + it("charges send + execution gas for a transfer to an existing named account", () => { + const fees = computeFees({ + mode: "send", + recipient: NAMED_RECIPIENT, + gasPrice: GAS_PRICE, + costs, + }); + + // (100 + 10 + 200 + 20) * gasPrice + expect(fees.toFixed()).toBe(new BigNumber(330).multipliedBy(GAS_PRICE).toFixed()); + }); + + it("adds account-creation and access-key gas for an implicit recipient", () => { + const fees = computeFees({ + mode: "send", + recipient: IMPLICIT_RECIPIENT, + gasPrice: GAS_PRICE, + costs, + }); + + // 330 + createAccount (1000 + 2000) + addKey (500 + 700) + expect(fees.toFixed()).toBe(new BigNumber(4530).multipliedBy(GAS_PRICE).toFixed()); + }); + + it.each(["stake", "unstake", "withdraw"])("prices %s from staking gas", mode => { + const fees = computeFees({ + mode, + recipient: "pool.poolv1.near", + gasPrice: GAS_PRICE, + costs, + }); + + // (5 * base + base buffer) * gasPrice — the full prepaid gas, not a fraction of it: the + // runtime locks the whole amount at conversion time and refunds the unburnt part on-chain. + const expected = new BigNumber(STAKING_GAS_BASE).multipliedBy(6).multipliedBy(GAS_PRICE); + expect(fees.toFixed()).toBe(expected.toFixed()); + }); + + it("uses the higher gas multiplier for a withdraw-all", () => { + const fees = computeFees({ + mode: "withdraw", + recipient: "pool.poolv1.near", + useAllAmount: true, + gasPrice: GAS_PRICE, + costs, + }); + + // (7 * base + base buffer) * gasPrice + const expected = new BigNumber(STAKING_GAS_BASE).multipliedBy(8).multipliedBy(GAS_PRICE); + expect(fees.toFixed()).toBe(expected.toFixed()); + }); + + it("floors the execution-half gas price at minGasPurchasePrice", () => { + const floorPrice = GAS_PRICE.multipliedBy(5); + const highFloor: NearFeeCosts = { ...costs, minGasPurchasePrice: floorPrice }; + + const fees = computeFees({ + mode: "send", + recipient: NAMED_RECIPIENT, + gasPrice: GAS_PRICE, + costs: highFloor, + }); + + // send half (110) at the raw gasPrice, execution half (220) at the floored price + const expected = new BigNumber(110) + .multipliedBy(GAS_PRICE) + .plus(new BigNumber(220).multipliedBy(floorPrice)); + expect(fees.toFixed()).toBe(expected.toFixed()); + }); + + it("does not treat a staking recipient as an implicit account", () => { + const staking = computeFees({ + mode: "stake", + recipient: IMPLICIT_RECIPIENT, + gasPrice: GAS_PRICE, + costs, + }); + const send = computeFees({ + mode: "send", + recipient: IMPLICIT_RECIPIENT, + gasPrice: GAS_PRICE, + costs, + }); + + expect(staking.eq(send)).toBe(false); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/getBalance.test.ts b/libs/coin-modules/coin-near/src/logic/tests/getBalance.test.ts new file mode 100644 index 000000000000..4add7365145c --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/getBalance.test.ts @@ -0,0 +1,70 @@ +import { http, HttpResponse } from "msw"; +import { setMockCoinConfig } from "../../test/coinConfig"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "../../network/node.mock"; +import { getActionCosts } from "../../network/protocolConfig"; +import { getBalance } from "../getBalance"; + +const ADDRESS = "delegator.near"; + +const runtimeConfig = { + storage_amount_per_byte: "10000000000000000000", + transaction_costs: { + action_creation_config: { + add_key_cost: { full_access_cost: { execution: 0, send_not_sir: 0 } }, + create_account_cost: { execution: 0, send_not_sir: 0 }, + transfer_cost: { execution: 0, send_not_sir: 0 }, + }, + action_receipt_creation_config: { execution: 0, send_not_sir: 0 }, + }, +}; + +const mockAccount = (amount: string): void => { + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/kitwallet/staking-deposits/:address`, () => + HttpResponse.json([]), + ), + http.post(NEAR_BASE_URL_MOCKED, async ({ request }) => { + const body = (await request.json()) as { method: string; params: { request_type?: string } }; + + if (body.method === "EXPERIMENTAL_protocol_config") { + return HttpResponse.json({ result: { runtime_config: runtimeConfig } }); + } + if (body.params?.request_type === "view_account") { + return HttpResponse.json({ result: { amount, storage_usage: 182, block_height: 1 } }); + } + return HttpResponse.json({ jsonrpc: "2.0", id: "id", result: {} }); + }), + ); +}; + +describe("getBalance (MSW)", () => { + beforeAll(() => { + setMockCoinConfig(); + mockServer.listen({ onUnhandledRequest: "error" }); + }); + + beforeEach(() => getActionCosts.reset()); + afterEach(() => mockServer.resetHandlers()); + afterAll(() => mockServer.close()); + + it("reports the account total as value, and the storage reserve as locked", async () => { + mockAccount("1000000000000000000000000"); + + const [native] = await getBalance(ADDRESS); + + expect(native.asset).toEqual({ type: "native" }); + expect(native.stake).toBeUndefined(); + expect(native.value).toBe(1_000_000_000_000_000_000_000_000n); + // storageCost(1e19) * storage_usage(182) + MIN_ACCOUNT_BALANCE_BUFFER(5e22) + expect(native.locked).toBe(51_820_000_000_000_000_000_000n); + }); + + it("never locks more than the account holds", async () => { + mockAccount("1000000000000000000000"); // below the reserve + + const [native] = await getBalance(ADDRESS); + + expect(native.value).toBe(1_000_000_000_000_000_000_000n); + expect(native.locked).toBe(native.value); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/getBalance.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/getBalance.unit.test.ts new file mode 100644 index 000000000000..106216f188d7 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/getBalance.unit.test.ts @@ -0,0 +1,138 @@ +import { BigNumber } from "bignumber.js"; +import { getAccount } from "../../network"; +import { getYoctoThreshold } from "../../logic"; +import { getBalance } from "../getBalance"; + +jest.mock("../../network", () => ({ getAccount: jest.fn() })); + +const ADDRESS = "delegator.near"; +const VALIDATOR = "astro-stakers.poolv1.near"; +const STAKED = getYoctoThreshold().multipliedBy(4); + +/** The storage deposit plus the minimum-balance buffer, as `getAccount` reports it. */ +const RESERVE = new BigNumber("51820000000000000000000"); +const ONE_NEAR = new BigNumber("1000000000000000000000000"); + +type Position = { + validatorId: string; + staked: BigNumber; + available: BigNumber; + pending: BigNumber; +}; + +/** Mirrors what `getAccount` derives: `balance` folds the staking buckets into the raw amount. */ +const accountShape = ({ + amount, + reserve = RESERVE, + positions = [], +}: { + amount: BigNumber; + reserve?: BigNumber; + positions?: Position[]; +}) => { + const staked = positions.reduce((acc, p) => acc.plus(p.staked), new BigNumber(0)); + const available = positions.reduce((acc, p) => acc.plus(p.available), new BigNumber(0)); + const pending = positions.reduce((acc, p) => acc.plus(p.pending), new BigNumber(0)); + + return { + blockHeight: 140_000_000, + balance: amount.plus(staked).plus(available).plus(pending), + spendableBalance: BigNumber.max(0, amount.minus(reserve)), + nearResources: { + stakedBalance: staked, + availableBalance: available, + pendingBalance: pending, + storageUsageBalance: reserve, + stakingPositions: positions, + }, + }; +}; + +const position = (overrides: Partial = {}): Position => ({ + validatorId: VALIDATOR, + staked: new BigNumber(0), + available: new BigNumber(0), + pending: new BigNumber(0), + ...overrides, +}); + +describe("getBalance", () => { + beforeEach(() => jest.clearAllMocks()); + + it("puts the native total first, so the framework reads it as the account balance", async () => { + (getAccount as jest.Mock).mockResolvedValue(accountShape({ amount: ONE_NEAR })); + + const balances = await getBalance(ADDRESS); + + expect(balances[0].asset).toEqual({ type: "native" }); + expect(balances[0].stake).toBeUndefined(); + expect(balances[0].value).toBe(BigInt(ONE_NEAR.toFixed(0))); + }); + + it("reproduces the account bridge's spendable balance as value - locked", async () => { + const shape = accountShape({ amount: ONE_NEAR }); + (getAccount as jest.Mock).mockResolvedValue(shape); + + const [native] = await getBalance(ADDRESS); + + expect(native.locked).toBe(BigInt(RESERVE.toFixed(0))); + expect(native.value - native.locked!).toBe(BigInt(shape.spendableBalance.toFixed(0))); + }); + + it("reports nothing for an account that does not exist", async () => { + (getAccount as jest.Mock).mockResolvedValue( + accountShape({ amount: new BigNumber(0), reserve: RESERVE }), + ); + + const [native] = await getBalance(ADDRESS); + + expect(native.value).toBe(0n); + expect(native.locked).toBe(0n); + }); + + it("never locks more than the account holds, when the balance is under the reserve", async () => { + const amount = new BigNumber("10000000000000000000000"); // 0.01 NEAR, below the reserve + const shape = accountShape({ amount }); + (getAccount as jest.Mock).mockResolvedValue(shape); + + const [native] = await getBalance(ADDRESS); + + expect(native.value).toBe(BigInt(amount.toFixed(0))); + expect(native.locked).toBe(BigInt(amount.toFixed(0))); + expect(native.value - native.locked!).toBe(0n); + expect(shape.spendableBalance.isZero()).toBe(true); + }); + + it("adds one entry per staking position and keeps the account total as the native value", async () => { + const shape = accountShape({ + amount: ONE_NEAR, + positions: [position({ staked: STAKED })], + }); + (getAccount as jest.Mock).mockResolvedValue(shape); + + const balances = await getBalance(ADDRESS); + + expect(balances).toHaveLength(2); + expect(balances[0].value).toBe(BigInt(shape.balance.toFixed(0))); + expect(balances[0].value - balances[0].locked!).toBe(BigInt(shape.spendableBalance.toFixed(0))); + expect(balances[1]).toMatchObject({ + value: BigInt(STAKED.toFixed(0)), + asset: { type: "native" }, + stake: { delegate: VALIDATOR, state: "active" }, + }); + }); + + it("locks every staking bucket so the total stays spendable-accurate", async () => { + const shape = accountShape({ + amount: ONE_NEAR, + positions: [position({ staked: STAKED, available: STAKED, pending: STAKED })], + }); + (getAccount as jest.Mock).mockResolvedValue(shape); + + const balances = await getBalance(ADDRESS); + + expect(balances[0].value).toBe(BigInt(shape.balance.toFixed(0))); + expect(balances[0].value - balances[0].locked!).toBe(BigInt(shape.spendableBalance.toFixed(0))); + expect(balances.filter(b => b.stake !== undefined)).toHaveLength(3); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/getBlockInfo.test.ts b/libs/coin-modules/coin-near/src/logic/tests/getBlockInfo.test.ts new file mode 100644 index 000000000000..bbef46d8bd63 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/getBlockInfo.test.ts @@ -0,0 +1,40 @@ +import { http, HttpResponse } from "msw"; +import { setMockCoinConfig } from "../../test/coinConfig"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "../../network/node.mock"; +import { getBlockInfo } from "../getBlockInfo"; + +const HEADER = { height: 140_000_000, hash: "BlockHash1", timestamp: 1_750_000_000_000_000_000 }; + +describe("getBlockInfo (MSW)", () => { + beforeAll(() => { + setMockCoinConfig(); + mockServer.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => mockServer.resetHandlers()); + afterAll(() => mockServer.close()); + + it("maps the node's header into BlockInfo, converting the nanosecond timestamp", async () => { + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, () => HttpResponse.json({ result: { header: HEADER } })), + ); + + const block = await getBlockInfo(140_000_000); + + expect(block).toEqual({ + height: 140_000_000, + hash: "BlockHash1", + time: new Date(HEADER.timestamp / 1e6), + }); + }); + + it("propagates the node's error message for an invalid height", async () => { + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, () => + HttpResponse.json({ error: { message: "DB Not Found Error" } }), + ), + ); + + await expect(getBlockInfo(999_999_999)).rejects.toThrow("DB Not Found Error"); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/getBlockInfo.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/getBlockInfo.unit.test.ts new file mode 100644 index 000000000000..e7216f7c4c63 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/getBlockInfo.unit.test.ts @@ -0,0 +1,42 @@ +import { getBlockHeaderAtHeight, getLastBlockHeader } from "../../network"; +import { getBlockInfo, toBlockInfo } from "../getBlockInfo"; +import { lastBlock } from "../lastBlock"; + +jest.mock("../../network", () => ({ + getBlockHeaderAtHeight: jest.fn(), + getLastBlockHeader: jest.fn(), +})); + +const HEADER = { height: 140_000_000, hash: "BlockHash1", timestamp: 1_750_000_000_000_000_000 }; + +describe("toBlockInfo", () => { + it("converts the nanosecond block timestamp to a date", () => { + expect(toBlockInfo(HEADER)).toEqual({ + height: 140_000_000, + hash: "BlockHash1", + time: new Date(1_750_000_000_000), + }); + }); +}); + +describe("getBlockInfo", () => { + beforeEach(() => jest.clearAllMocks()); + + it("reads the header at the requested height", async () => { + (getBlockHeaderAtHeight as jest.Mock).mockResolvedValue(HEADER); + + await expect(getBlockInfo(140_000_000)).resolves.toEqual(toBlockInfo(HEADER)); + expect(getBlockHeaderAtHeight).toHaveBeenCalledWith(140_000_000); + }); +}); + +describe("lastBlock", () => { + beforeEach(() => jest.clearAllMocks()); + + it("reads the latest final header", async () => { + (getLastBlockHeader as jest.Mock).mockResolvedValue(HEADER); + + await expect(lastBlock()).resolves.toEqual(toBlockInfo(HEADER)); + expect(getLastBlockHeader).toHaveBeenCalled(); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/getStakes.test.ts b/libs/coin-modules/coin-near/src/logic/tests/getStakes.test.ts new file mode 100644 index 000000000000..717fd02c64e7 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/getStakes.test.ts @@ -0,0 +1,76 @@ +import { http, HttpResponse } from "msw"; +import { setMockCoinConfig } from "../../test/coinConfig"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "../../network/node.mock"; +import { getStakes } from "../getStakes"; + +const ADDRESS = "delegator.near"; +const VALIDATOR_ID = "astro-stakers.poolv1.near"; + +const viewResult = (value: unknown) => ({ + jsonrpc: "2.0", + id: "id", + result: { + result: Array.from(Buffer.from(JSON.stringify(value))), + logs: [], + block_height: 1, + block_hash: "DsWyc6swGSDezgvweB561FLbL1nsBsU3QJtZFLHq79Ru", + }, +}); + +const mockPool = (views: Record): void => { + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/kitwallet/staking-deposits/:address`, () => + HttpResponse.json([{ deposit: "1000000000000000000000000", validator_id: VALIDATOR_ID }]), + ), + http.post(NEAR_BASE_URL_MOCKED, async ({ request }) => { + const body = (await request.json()) as { params: { method_name?: string } }; + const methodName = body.params.method_name as string; + return HttpResponse.json(viewResult(views[methodName])); + }), + ); +}; + +describe("getStakes (MSW)", () => { + beforeAll(() => { + setMockCoinConfig(); + mockServer.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => mockServer.resetHandlers()); + afterAll(() => mockServer.close()); + + it("reports an active delegation as a single unpaginated page", async () => { + mockPool({ + get_account_staked_balance: "1000000000000000000000000", + get_account_unstaked_balance: "0", + is_account_unstaked_balance_available: false, + }); + + const page = await getStakes(ADDRESS); + + expect(page.next).toBeUndefined(); + expect(page.items).toEqual([ + { + uid: `${ADDRESS}:${VALIDATOR_ID}:staked`, + address: ADDRESS, + delegate: VALIDATOR_ID, + state: "active", + actions: ["undelegate"], + asset: { type: "native" }, + amount: 1_000_000_000_000_000_000_000_000n, + }, + ]); + }); + + it("returns an empty page rather than undefined items when the account has no deposits", async () => { + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/kitwallet/staking-deposits/:address`, () => + HttpResponse.json([]), + ), + ); + + const page = await getStakes(ADDRESS); + + expect(page.items).toEqual([]); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/getStakes.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/getStakes.unit.test.ts new file mode 100644 index 000000000000..d1c23b96b9f8 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/getStakes.unit.test.ts @@ -0,0 +1,112 @@ +import { BigNumber } from "bignumber.js"; +import { getStakingPositions } from "../../network"; +import type { NearStakingPosition } from "../../network/sdk.types"; +import { getYoctoThreshold } from "../../logic"; +import { getStakes, toStakes } from "../getStakes"; + +jest.mock("../../network", () => ({ getStakingPositions: jest.fn() })); + +const ADDRESS = "delegator.near"; +const VALIDATOR = "astro-stakers.poolv1.near"; + +const ABOVE_THRESHOLD = getYoctoThreshold().multipliedBy(3); +const BELOW_THRESHOLD = getYoctoThreshold().minus(1); + +/** exactOptionalPropertyTypes is on, so an explicit `undefined` override needs the union. */ +type Overrides = { [K in keyof T]?: T[K] | undefined }; + +const position = (overrides: Overrides = {}): NearStakingPosition => + ({ + validatorId: VALIDATOR, + staked: new BigNumber(0), + available: new BigNumber(0), + pending: new BigNumber(0), + ...overrides, + }) as NearStakingPosition; + +describe("toStakes", () => { + it("maps a staked balance to an active, undelegatable position", () => { + const [stake] = toStakes(ADDRESS, [position({ staked: ABOVE_THRESHOLD })]); + + expect(stake).toMatchObject({ + uid: `${ADDRESS}:${VALIDATOR}:staked`, + address: ADDRESS, + delegate: VALIDATOR, + state: "active", + actions: ["undelegate"], + asset: { type: "native" }, + amount: BigInt(ABOVE_THRESHOLD.toFixed(0)), + }); + }); + + it("maps an unstaked balance still in the unbonding window to deactivating with no actions", () => { + const [stake] = toStakes(ADDRESS, [position({ pending: ABOVE_THRESHOLD })]); + + expect(stake).toMatchObject({ + uid: `${ADDRESS}:${VALIDATOR}:pending`, + state: "deactivating", + actions: [], + amount: BigInt(ABOVE_THRESHOLD.toFixed(0)), + }); + }); + + it("maps a released balance to a withdrawable position", () => { + const [stake] = toStakes(ADDRESS, [position({ available: ABOVE_THRESHOLD })]); + + expect(stake).toMatchObject({ + uid: `${ADDRESS}:${VALIDATOR}:available`, + state: "withdrawable", + actions: ["withdraw"], + amount: BigInt(ABOVE_THRESHOLD.toFixed(0)), + }); + }); + + it("emits one position per bucket for a pool holding all three", () => { + const stakes = toStakes(ADDRESS, [ + position({ + staked: ABOVE_THRESHOLD, + pending: ABOVE_THRESHOLD, + available: ABOVE_THRESHOLD, + }), + ]); + + expect(stakes.map(s => s.state)).toEqual(["active", "deactivating", "withdrawable"]); + }); + + it("drops staked and withdrawable dust below the staking threshold", () => { + const stakes = toStakes(ADDRESS, [ + position({ staked: BELOW_THRESHOLD, available: BELOW_THRESHOLD }), + ]); + + expect(stakes).toEqual([]); + }); + + it("reports neither rewards nor a deposited split, since a pool compounds rewards into the staked shares and the principal isn't recoverable", () => { + const [stake] = toStakes(ADDRESS, [position({ staked: ABOVE_THRESHOLD })]); + + expect(stake.amountRewarded).toBeUndefined(); + expect(stake.amountDeposited).toBeUndefined(); + }); +}); + +describe("getStakes", () => { + beforeEach(() => jest.clearAllMocks()); + + it("returns a single page of the account's positions", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ + stakingPositions: [position({ staked: ABOVE_THRESHOLD })], + }); + + const page = await getStakes(ADDRESS); + + expect(getStakingPositions).toHaveBeenCalledWith(ADDRESS); + expect(page.items).toHaveLength(1); + expect(page.next).toBeUndefined(); + }); + + it("returns an empty page for an account with no delegations", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ stakingPositions: [] }); + + await expect(getStakes(ADDRESS)).resolves.toEqual({ items: [], next: undefined }); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/getValidators.test.ts b/libs/coin-modules/coin-near/src/logic/tests/getValidators.test.ts new file mode 100644 index 000000000000..e310250da177 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/getValidators.test.ts @@ -0,0 +1,55 @@ +import { http, HttpResponse } from "msw"; +import { setMockCoinConfig } from "../../test/coinConfig"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "../../network/node.mock"; +import { getValidators as fetchValidators } from "../../network"; +import { getValidators } from "../getValidators"; + +describe("getValidators (MSW)", () => { + beforeAll(() => { + setMockCoinConfig(); + mockServer.listen({ onUnhandledRequest: "error" }); + }); + + beforeEach(() => fetchValidators.reset()); + afterEach(() => mockServer.resetHandlers()); + afterAll(() => mockServer.close()); + + it("maps the indexer's validators into a single, unpaginated page", async () => { + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/validators`, () => + HttpResponse.json({ + data: [ + { + account_id: "astro-stakers.poolv1.near", + current_epoch_stake: "31516203410952749364980772561846", + fee_numerator: 1, + fee_denominator: 100, + }, + ], + }), + ), + ); + + const page = await getValidators(); + + expect(page.next).toBeUndefined(); + expect(page.items).toEqual([ + { + address: "astro-stakers.poolv1.near", + name: "astro-stakers.poolv1.near", + balance: 31516203410952749364980772561846n, + commissionRate: "1", + }, + ]); + }); + + it("returns an empty page rather than undefined items when the indexer has no data", async () => { + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/validators`, () => HttpResponse.json({ data: null })), + ); + + const page = await getValidators(); + + expect(page.items).toEqual([]); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/getValidators.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/getValidators.unit.test.ts new file mode 100644 index 000000000000..37904e57c50f --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/getValidators.unit.test.ts @@ -0,0 +1,48 @@ +import { getValidators as fetchValidators } from "../../network"; +import { getValidators } from "../getValidators"; + +jest.mock("../../network", () => ({ getValidators: jest.fn() })); + +describe("getValidators", () => { + beforeEach(() => jest.clearAllMocks()); + + it("maps indexer validators to the framework shape", async () => { + (fetchValidators as unknown as jest.Mock).mockResolvedValue([ + { + account_id: "astro-stakers.poolv1.near", + stake: "31516203410952749364980772561846", + commission: 1, + }, + ]); + + const page = await getValidators(); + + expect(page.items).toEqual([ + { + address: "astro-stakers.poolv1.near", + name: "astro-stakers.poolv1.near", + balance: 31516203410952749364980772561846n, + commissionRate: "1", + }, + ]); + expect(page.next).toBeUndefined(); + }); + + it("requests the top 200 validators", async () => { + (fetchValidators as unknown as jest.Mock).mockResolvedValue([]); + + await getValidators(); + + expect(fetchValidators).toHaveBeenCalledWith({ total: 200 }); + }); + + it("defaults a missing stake to zero", async () => { + (fetchValidators as unknown as jest.Mock).mockResolvedValue([ + { account_id: "pool.poolv1.near", stake: "", commission: 0 }, + ]); + + const page = await getValidators(); + + expect(page.items[0].balance).toBe(0n); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/lastBlock.test.ts b/libs/coin-modules/coin-near/src/logic/tests/lastBlock.test.ts new file mode 100644 index 000000000000..46d002a41117 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/lastBlock.test.ts @@ -0,0 +1,36 @@ +import { http, HttpResponse } from "msw"; +import { setMockCoinConfig } from "../../test/coinConfig"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "../../network/node.mock"; +import { lastBlock } from "../lastBlock"; + +const HEADER = { height: 140_000_001, hash: "BlockHash2", timestamp: 1_750_000_100_000_000_000 }; + +describe("lastBlock (MSW)", () => { + beforeAll(() => { + setMockCoinConfig(); + mockServer.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => mockServer.resetHandlers()); + afterAll(() => mockServer.close()); + + it("asks the node for the latest final block and maps its header", async () => { + let requestedParams: unknown; + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, async ({ request }) => { + const body = (await request.json()) as { params: unknown }; + requestedParams = body.params; + return HttpResponse.json({ result: { header: HEADER } }); + }), + ); + + const block = await lastBlock(); + + expect(requestedParams).toEqual({ finality: "final" }); + expect(block).toEqual({ + height: 140_000_001, + hash: "BlockHash2", + time: new Date(HEADER.timestamp / 1e6), + }); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/listOperations.test.ts b/libs/coin-modules/coin-near/src/logic/tests/listOperations.test.ts new file mode 100644 index 000000000000..8fded5a47654 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/listOperations.test.ts @@ -0,0 +1,116 @@ +import { http, HttpResponse } from "msw"; +import { setMockCoinConfig } from "../../test/coinConfig"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "../../network/node.mock"; +import type { NearTransaction } from "../../network/sdk.types"; +import { listOperations } from "../listOperations"; + +const ADDRESS = "sender.near"; + +const transaction = (overrides: Partial = {}): NearTransaction => ({ + signer_account_id: ADDRESS, + receiver_account_id: "recipient.near", + transaction_hash: "hash-1", + block_timestamp: "1750000000000000000", + block: { block_height: "140000000" }, + actions_agg: { deposit: "1000000000000000000000000" }, + outcomes_agg: { transaction_fee: "100000000000000000000" }, + outcomes: { status: true }, + ...overrides, +}); + +describe("listOperations (MSW)", () => { + beforeAll(() => { + setMockCoinConfig(); + mockServer.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => mockServer.resetHandlers()); + afterAll(() => mockServer.close()); + + it("maps a transaction into an Operation", async () => { + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/accounts/:address/txns`, () => + HttpResponse.json({ data: [transaction()] }), + ), + ); + + const page = await listOperations(ADDRESS, { minHeight: 0 }); + + expect(page.items).toHaveLength(1); + expect(page.items[0]).toMatchObject({ + id: "hash-1", + type: "OUT", + value: 1_000_000_000_000_000_000_000_000n, + }); + }); + + it("forwards the indexer's opaque cursor unchanged on the next page", async () => { + const cursor = "eyJhY2NvdW50X2lkIjoid2Fja2F6b25nLnBvb2x2MS5uZWFyIn0="; + let requestedNext: string | null = null; + + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/accounts/:address/txns`, ({ request }) => { + requestedNext = new URL(request.url).searchParams.get("next"); + return HttpResponse.json({ data: [transaction()], meta: { next_page: cursor } }); + }), + ); + + const page = await listOperations(ADDRESS, { cursor, minHeight: 0 }); + + expect(requestedNext).toBe(cursor); + expect(page.next).toBe(cursor); + }); + + it("returns an empty page rather than undefined items when the indexer has no data", async () => { + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/accounts/:address/txns`, () => + HttpResponse.json({ data: null }), + ), + ); + + const page = await listOperations(ADDRESS, { minHeight: 0 }); + + expect(page.items).toEqual([]); + expect(page.next).toBeUndefined(); + }); + + it("drops transactions below minHeight and stops advertising a next cursor once the floor is reached", async () => { + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/accounts/:address/txns`, () => + HttpResponse.json({ + data: [ + transaction({ transaction_hash: "above", block: { block_height: "200" } }), + transaction({ transaction_hash: "below", block: { block_height: "50" } }), + ], + meta: { next_page: "would-be-next" }, + }), + ), + ); + + const page = await listOperations(ADDRESS, { minHeight: 100 }); + + expect(page.items.map(o => o.id)).toEqual(["above"]); + expect(page.next).toBeUndefined(); + }); + + it("clamps an out-of-range limit instead of forwarding it to the indexer", async () => { + let requestedLimit: string | null = null; + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/accounts/:address/txns`, ({ request }) => { + requestedLimit = new URL(request.url).searchParams.get("limit"); + return HttpResponse.json({ data: [] }); + }), + ); + + await listOperations(ADDRESS, { limit: 500, minHeight: 0 }); + + expect(requestedLimit).toBe("100"); + }); + + it("rejects ascending order without issuing a request", async () => { + // No handler registered: a request would fail the suite via onUnhandledRequest. + await expect(listOperations(ADDRESS, { order: "asc", minHeight: 0 })).rejects.toThrow( + "ascending order is not supported", + ); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/listOperations.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/listOperations.unit.test.ts new file mode 100644 index 000000000000..f1d1316f6f65 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/listOperations.unit.test.ts @@ -0,0 +1,156 @@ +import { fetchTransactionsPage } from "../../network"; +import type { NearTransaction } from "../../network/sdk.types"; +import { listOperations, toOperation } from "../listOperations"; + +jest.mock("../../network", () => ({ fetchTransactionsPage: jest.fn() })); + +const ADDRESS = "delegator.near"; +const PEER = "recipient.near"; +const FEE = "23000000000000000000"; +const DEPOSIT = "5000000000000000000000000"; + +/** exactOptionalPropertyTypes is on, so an explicit `undefined` override needs the union. */ +type Overrides = { [K in keyof T]?: T[K] | undefined }; + +const transaction = (overrides: Overrides = {}): NearTransaction => + ({ + signer_account_id: ADDRESS, + receiver_account_id: PEER, + transaction_hash: "GkQ7Uh8oPPGtVfyPz1yLKmqPqZ8ZyxvGtN5MmYq8mF1w", + block_timestamp: "1750000000000000000", + outcomes_agg: { transaction_fee: FEE }, + outcomes: { status: true }, + block: { block_hash: "BlockHash1", block_height: "140000000" }, + actions_agg: { deposit: DEPOSIT }, + actions: [{ action: "TRANSFER", method: null }], + ...overrides, + }) as NearTransaction; + +const mockPage = (transactions: NearTransaction[], next?: string) => + (fetchTransactionsPage as jest.Mock).mockResolvedValue({ transactions, next }); + +describe("toOperation", () => { + it("excludes the fee from an outgoing value, because the framework adds it on top", () => { + const operation = toOperation(transaction(), ADDRESS); + + expect(operation.type).toBe("OUT"); + expect(operation.value).toBe(BigInt(DEPOSIT)); + expect(operation.tx.fees).toBe(BigInt(FEE)); + }); + + it("reports the raw deposit for an incoming operation", () => { + const operation = toOperation( + transaction({ signer_account_id: PEER, receiver_account_id: ADDRESS }), + ADDRESS, + ); + + expect(operation.type).toBe("IN"); + expect(operation.value).toBe(BigInt(DEPOSIT)); + }); + + it.each([ + ["deposit_and_stake", "STAKE"], + ["unstake", "UNSTAKE"], + ["unstake_all", "UNSTAKE"], + ["withdraw", "WITHDRAW_UNSTAKED"], + ["withdraw_all", "WITHDRAW_UNSTAKED"], + ])("classifies the %s call as %s", (method, expected) => { + const operation = toOperation( + transaction({ actions: [{ action: "FUNCTION_CALL", method }] }), + ADDRESS, + ); + + expect(operation.type).toBe(expected); + }); + + it("maps the block reference and converts the nanosecond timestamp", () => { + const operation = toOperation(transaction(), ADDRESS); + + expect(operation.tx.block).toEqual({ + height: 140_000_000, + hash: "BlockHash1", + time: new Date(1_750_000_000_000), + }); + expect(operation.tx.date).toEqual(new Date(1_750_000_000_000)); + }); + + it("tolerates a transaction with no block, fee or deposit", () => { + const operation = toOperation( + transaction({ block: undefined, outcomes_agg: undefined, actions_agg: undefined }), + ADDRESS, + ); + + expect(operation.value).toBe(0n); + expect(operation.tx.fees).toBe(0n); + expect(operation.tx.block).toMatchObject({ height: 0, hash: "" }); + }); + + it("marks a reverted transaction as failed", () => { + expect(toOperation(transaction({ outcomes: { status: false } }), ADDRESS).tx.failed).toBe(true); + expect(toOperation(transaction({ outcomes: undefined }), ADDRESS).tx.failed).toBe(false); + }); +}); + +describe("listOperations", () => { + beforeEach(() => jest.clearAllMocks()); + + it("rejects ascending order, since paging runs newest first", async () => { + await expect(listOperations(ADDRESS, { minHeight: 0, order: "asc" })).rejects.toThrow( + "ascending order is not supported", + ); + }); + + it("forwards the cursor and limit to the indexer and returns its next cursor", async () => { + mockPage([transaction()], "cursor-2"); + + const page = await listOperations(ADDRESS, { minHeight: 0, cursor: "cursor-1", limit: 10 }); + + expect(fetchTransactionsPage).toHaveBeenCalledWith(ADDRESS, { + cursor: "cursor-1", + limit: 10, + }); + expect(page.next).toBe("cursor-2"); + expect(page.items).toHaveLength(1); + }); + + it("drops operations below minHeight and stops paging once the floor is crossed", async () => { + mockPage( + [ + transaction({ block: { block_hash: "b2", block_height: "200" } }), + transaction({ block: { block_hash: "b1", block_height: "100" } }), + ], + "cursor-2", + ); + + const page = await listOperations(ADDRESS, { minHeight: 150 }); + + expect(page.items).toHaveLength(1); + expect(page.items[0].tx.block.height).toBe(200); + expect(page.next).toBeUndefined(); + }); + + // The indexer 422s outside 1..100, so an out-of-range limit must never reach it. + it.each([ + ["above the cap", 500, 100], + ["zero", 0, 1], + ["negative", -5, 1], + ])("clamps a limit %s", async (_label, limit, expected) => { + mockPage([transaction()]); + + await listOperations(ADDRESS, { minHeight: 0, limit }); + + expect(fetchTransactionsPage).toHaveBeenCalledWith(ADDRESS, { + cursor: undefined, + limit: expected, + }); + }); + + it("returns an empty page when the indexer has no data", async () => { + mockPage([]); + + const page = await listOperations(ADDRESS, { minHeight: 0 }); + + expect(page.items).toEqual([]); + expect(page.next).toBeUndefined(); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/pooledAmount.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/pooledAmount.unit.test.ts new file mode 100644 index 000000000000..f13f5a319f57 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/pooledAmount.unit.test.ts @@ -0,0 +1,94 @@ +import type { Balance, Stake } from "@ledgerhq/coin-module-framework/api/index"; +import { BigNumber } from "bignumber.js"; +import { getStakingPositions } from "../../network"; +import type { NearStakingPosition } from "../../network/sdk.types"; +import { pooledAmount } from "../pooledAmount"; + +jest.mock("../../network", () => ({ getStakingPositions: jest.fn() })); + +const ADDRESS = "delegator.near"; +const VALIDATOR = "astro-stakers.poolv1.near"; +const OTHER_VALIDATOR = "other-pool.poolv1.near"; + +const stakeBalance = (overrides: Partial & Pick): Balance => ({ + value: overrides.amount, + asset: { type: "native" }, + stake: { + uid: `${ADDRESS}:${overrides.delegate ?? VALIDATOR}:${overrides.state}`, + address: ADDRESS, + delegate: VALIDATOR, + actions: [], + asset: { type: "native" }, + ...overrides, + }, +}); + +const position = (overrides: Partial = {}): NearStakingPosition => + ({ + validatorId: VALIDATOR, + staked: new BigNumber(0), + available: new BigNumber(0), + pending: new BigNumber(0), + ...overrides, + }) as NearStakingPosition; + +describe("pooledAmount", () => { + beforeEach(() => jest.clearAllMocks()); + + it("sums active and activating stake entries for unstake, from balances alone", async () => { + const balances: Balance[] = [ + stakeBalance({ state: "active", amount: 10n }), + stakeBalance({ state: "activating", amount: 5n }), + stakeBalance({ state: "withdrawable", amount: 3n }), + ]; + + await expect(pooledAmount("unstake", ADDRESS, VALIDATOR, balances)).resolves.toBe(15n); + expect(getStakingPositions).not.toHaveBeenCalled(); + }); + + it("sums only withdrawable stake entries for withdraw, from balances alone", async () => { + const balances: Balance[] = [ + stakeBalance({ state: "active", amount: 10n }), + stakeBalance({ state: "withdrawable", amount: 3n }), + ]; + + await expect(pooledAmount("withdraw", ADDRESS, VALIDATOR, balances)).resolves.toBe(3n); + expect(getStakingPositions).not.toHaveBeenCalled(); + }); + + it("ignores stake entries delegated to a different pool", async () => { + const balances: Balance[] = [ + stakeBalance({ state: "active", amount: 10n, delegate: OTHER_VALIDATOR }), + ]; + + await expect(pooledAmount("unstake", ADDRESS, VALIDATOR, balances)).resolves.toBe(0n); + expect(getStakingPositions).not.toHaveBeenCalled(); + }); + + it("falls back to the network when balances carry no stake entries", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ + stakingPositions: [position({ staked: new BigNumber(20) })], + }); + const balances: Balance[] = [{ value: 0n, asset: { type: "native" } }]; + + await expect(pooledAmount("unstake", ADDRESS, VALIDATOR, balances)).resolves.toBe(20n); + expect(getStakingPositions).toHaveBeenCalledWith(ADDRESS); + }); + + it("falls back to the network when no balances are passed at all", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ + stakingPositions: [position({ available: new BigNumber(7) })], + }); + + await expect(pooledAmount("withdraw", ADDRESS, VALIDATOR, undefined)).resolves.toBe(7n); + expect(getStakingPositions).toHaveBeenCalledWith(ADDRESS); + }); + + it("returns 0 when the network has no position for the given pool", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ + stakingPositions: [position({ validatorId: OTHER_VALIDATOR, staked: new BigNumber(20) })], + }); + + await expect(pooledAmount("unstake", ADDRESS, VALIDATOR, undefined)).resolves.toBe(0n); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/tests/validateIntent.unit.test.ts b/libs/coin-modules/coin-near/src/logic/tests/validateIntent.unit.test.ts new file mode 100644 index 000000000000..44938f4d84a3 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/tests/validateIntent.unit.test.ts @@ -0,0 +1,371 @@ +import type { + Balance, + StakingTransactionIntent, + TransactionIntent, +} from "@ledgerhq/coin-module-framework/api/index"; +import { BigNumber } from "bignumber.js"; +import { fetchAccountDetails, getActionCosts, getStakingPositions } from "../../network"; +import { getYoctoThreshold } from "../../logic"; +import { validateIntent } from "../validateIntent"; + +jest.mock("../../network", () => ({ + fetchAccountDetails: jest.fn(), + getActionCosts: jest.fn(), + getStakingPositions: jest.fn(), +})); + +const SENDER = "sender.near"; +const NAMED_RECIPIENT = "recipient.near"; +const IMPLICIT_RECIPIENT = "4e7de0a21d8a20f970c86b6edf407906d7ba9e205979c3268270eef80a286e2d"; +const VALIDATOR = "astro-stakers.poolv1.near"; + +const ONE_NEAR = 1_000_000_000_000_000_000_000_000n; +const FEES = { value: 100_000_000_000_000_000_000n }; +const ABOVE_THRESHOLD = BigInt(getYoctoThreshold().multipliedBy(3).toFixed(0)); + +const nativeBalance = (value: bigint, locked = 0n): Balance => ({ + value, + locked, + asset: { type: "native" }, +}); + +const stakeBalance = (amount: bigint, state: string): Balance => + ({ + value: amount, + asset: { type: "native" }, + stake: { + uid: `${SENDER}:${VALIDATOR}:${state}`, + address: SENDER, + delegate: VALIDATOR, + state, + actions: [], + asset: { type: "native" }, + amount, + }, + }) as Balance; + +/** exactOptionalPropertyTypes is on, so an explicit `undefined` override needs the union. */ +type Overrides = { [K in keyof T]?: T[K] | undefined }; + +const sendIntent = (overrides: Overrides = {}): TransactionIntent => + ({ + intentType: "transaction", + type: "send", + sender: SENDER, + recipient: NAMED_RECIPIENT, + amount: ONE_NEAR, + asset: { type: "native" }, + ...overrides, + }) as TransactionIntent; + +const stakingIntent = ( + mode: StakingTransactionIntent["mode"], + overrides: Overrides = {}, +): StakingTransactionIntent => + ({ + ...sendIntent(), + intentType: "staking", + type: mode, + mode, + valAddress: VALIDATOR, + amount: ABOVE_THRESHOLD, + ...overrides, + }) as StakingTransactionIntent; + +describe("validateIntent", () => { + beforeEach(() => { + jest.clearAllMocks(); + (getActionCosts as unknown as jest.Mock).mockResolvedValue({ + storageCost: new BigNumber("10000000000000000000"), + accountCreationCharge: new BigNumber(0), + }); + (fetchAccountDetails as jest.Mock).mockResolvedValue({ amount: "1", storage_usage: 182 }); + (getStakingPositions as jest.Mock).mockResolvedValue({ stakingPositions: [] }); + }); + + describe("transfers", () => { + it("accepts a funded transfer to an existing account", async () => { + const result = await validateIntent(sendIntent(), [nativeBalance(ONE_NEAR * 10n)], FEES); + + expect(result.errors).toEqual({}); + expect(result.amount).toBe(ONE_NEAR); + expect(result.totalSpent).toBe(ONE_NEAR + FEES.value); + }); + + it("subtracts the locked storage deposit from what can be spent", async () => { + const result = await validateIntent( + sendIntent({ amount: ONE_NEAR * 2n }), + [nativeBalance(ONE_NEAR * 3n, ONE_NEAR * 2n)], + FEES, + ); + + expect(result.errors.amount?.name).toBe("NotEnoughBalance"); + }); + + it("requires a recipient", async () => { + const result = await validateIntent( + sendIntent({ recipient: "" }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.errors.recipient?.name).toBe("RecipientRequired"); + }); + + it("rejects a malformed recipient", async () => { + const result = await validateIntent( + sendIntent({ recipient: "NOT VALID" }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.errors.recipient?.name).toBe("InvalidAddress"); + }); + + it("requires a positive amount", async () => { + const result = await validateIntent( + sendIntent({ amount: 0n }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.errors.amount?.name).toBe("AmountRequired"); + }); + + it("warns when sending to yourself", async () => { + const result = await validateIntent( + sendIntent({ recipient: SENDER }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.warnings.recipient?.name).toBe("InvalidAddressBecauseDestinationIsAlsoSource"); + }); + + it("warns that an implicit recipient will be created by the transfer", async () => { + (fetchAccountDetails as jest.Mock).mockResolvedValue(undefined); + + const result = await validateIntent( + sendIntent({ recipient: IMPLICIT_RECIPIENT }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.warnings.recipient?.name).toBe("NearNewAccountWarning"); + expect(result.errors).toEqual({}); + expect( + (result.warnings.recipient as unknown as { formattedNewAccountStorageCost: string }) + .formattedNewAccountStorageCost, + ).toContain("0.00182"); + }); + + it("rejects an amount too small to cover a new account's storage", async () => { + (fetchAccountDetails as jest.Mock).mockResolvedValue(undefined); + + const result = await validateIntent( + sendIntent({ recipient: IMPLICIT_RECIPIENT, amount: 1n }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.errors.amount?.name).toBe("NearActivationFeeNotCovered"); + expect( + (result.errors.amount as unknown as { formattedNewAccountStorageCost: string }) + .formattedNewAccountStorageCost, + ).toContain("0.00182"); + }); + + it("rejects a named recipient that does not exist yet", async () => { + (fetchAccountDetails as jest.Mock).mockResolvedValue(undefined); + + const result = await validateIntent(sendIntent(), [nativeBalance(ONE_NEAR * 10n)], FEES); + + expect(result.errors.recipient?.name).toBe("NearNewNamedAccountError"); + }); + + it("spends the balance minus fees when sending everything", async () => { + const result = await validateIntent( + sendIntent({ useAllAmount: true }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.amount).toBe(ONE_NEAR * 10n - FEES.value); + expect(result.errors).toEqual({}); + }); + + it("recommends unstaking first when sending everything with an open position", async () => { + const result = await validateIntent( + sendIntent({ useAllAmount: true }), + [nativeBalance(ONE_NEAR * 10n), stakeBalance(ABOVE_THRESHOLD, "active")], + FEES, + ); + + expect(result.warnings.amount?.name).toBe("NearRecommendUnstake"); + }); + + it("recommends unstaking first even when the caller's balances carry no staking entries", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ + stakingPositions: [ + { validatorId: VALIDATOR, staked: new BigNumber(ABOVE_THRESHOLD.toString()) }, + ], + }); + + const result = await validateIntent( + sendIntent({ useAllAmount: true }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(getStakingPositions).toHaveBeenCalledWith(SENDER); + expect(result.warnings.amount?.name).toBe("NearRecommendUnstake"); + }); + + it("does not warn to unstake when the sender has no delegation at all", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ stakingPositions: [] }); + + const result = await validateIntent( + sendIntent({ useAllAmount: true }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.warnings.amount).toBeUndefined(); + }); + }); + + describe("staking", () => { + it("accepts a funded delegation", async () => { + const result = await validateIntent( + stakingIntent("delegate"), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.errors).toEqual({}); + expect(result.totalSpent).toBe(ABOVE_THRESHOLD + FEES.value); + }); + + it("reads the pool when the caller's balances carry no staking entries", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ + stakingPositions: [ + { + validatorId: VALIDATOR, + staked: new BigNumber(0), + available: new BigNumber(ONE_NEAR.toString()), + pending: new BigNumber(0), + }, + ], + }); + + const result = await validateIntent( + stakingIntent("withdraw", { amount: ONE_NEAR / 2n }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(getStakingPositions).toHaveBeenCalledWith(SENDER); + expect(result.errors).toEqual({}); + expect(result.amount).toBe(ONE_NEAR / 2n); + }); + + it("rejects a withdrawal the pool cannot cover", async () => { + (getStakingPositions as jest.Mock).mockResolvedValue({ + stakingPositions: [ + { + validatorId: VALIDATOR, + staked: new BigNumber(0), + available: new BigNumber(ABOVE_THRESHOLD.toString()), + pending: new BigNumber(0), + }, + ], + }); + + const result = await validateIntent( + stakingIntent("withdraw", { amount: ONE_NEAR }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.errors.amount?.name).toBe("NearNotEnoughAvailable"); + }); + + it("rejects an amount below the staking threshold", async () => { + const result = await validateIntent( + stakingIntent("delegate", { amount: 1n }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.errors.amount?.name).toBe("NearStakingThresholdNotMet"); + expect((result.errors.amount as unknown as { threshold: string }).threshold).toMatch(/NEAR$/); + }); + + it("rejects a delegation the liquid balance cannot cover", async () => { + const result = await validateIntent( + stakingIntent("delegate", { amount: ONE_NEAR * 100n }), + [nativeBalance(ONE_NEAR)], + FEES, + ); + + expect(result.errors.amount?.name).toBe("NotEnoughBalance"); + }); + + it("only spends the fee when unstaking, since the funds are already delegated", async () => { + const result = await validateIntent( + stakingIntent("undelegate"), + [nativeBalance(ONE_NEAR), stakeBalance(ABOVE_THRESHOLD * 2n, "active")], + FEES, + ); + + expect(result.errors).toEqual({}); + expect(result.totalSpent).toBe(FEES.value); + }); + + it("rejects unstaking more than is delegated to that pool", async () => { + const result = await validateIntent( + stakingIntent("undelegate", { amount: ABOVE_THRESHOLD * 5n }), + [nativeBalance(ONE_NEAR), stakeBalance(ABOVE_THRESHOLD, "active")], + FEES, + ); + + expect(result.errors.amount?.name).toBe("NearNotEnoughStaked"); + }); + + it("rejects withdrawing more than has been released", async () => { + const result = await validateIntent( + stakingIntent("withdraw", { amount: ABOVE_THRESHOLD * 5n }), + [nativeBalance(ONE_NEAR), stakeBalance(ABOVE_THRESHOLD, "withdrawable")], + FEES, + ); + + expect(result.errors.amount?.name).toBe("NearNotEnoughAvailable"); + }); + + it("does not count an unbonding position as withdrawable", async () => { + const result = await validateIntent( + stakingIntent("withdraw"), + [nativeBalance(ONE_NEAR), stakeBalance(ABOVE_THRESHOLD, "deactivating")], + FEES, + ); + + expect(result.errors.amount?.name).toBe("NearNotEnoughAvailable"); + }); + + it("rejects staking when the liquid balance cannot even cover the fee", async () => { + const result = await validateIntent(stakingIntent("delegate"), [nativeBalance(1n)], FEES); + + expect(result.errors.amount?.name).toBe("NotEnoughBalance"); + }); + + it("warns when delegating the entire spendable balance", async () => { + const result = await validateIntent( + stakingIntent("delegate", { useAllAmount: true }), + [nativeBalance(ONE_NEAR * 10n)], + FEES, + ); + + expect(result.warnings.amount?.name).toBe("NearUseAllAmountStakeWarning"); + }); + }); +}); diff --git a/libs/coin-modules/coin-near/src/logic/validateIntent.ts b/libs/coin-modules/coin-near/src/logic/validateIntent.ts new file mode 100644 index 000000000000..b9ed8c7db0fd --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/validateIntent.ts @@ -0,0 +1,233 @@ +import type { + Balance, + FeeEstimation, + TransactionValidation, + Unit, +} from "@ledgerhq/coin-module-framework/api/index"; +import { formatCurrencyUnit } from "@ledgerhq/coin-module-framework/currencies/index"; +import { + AmountRequired, + InvalidAddress, + InvalidAddressBecauseDestinationIsAlsoSource, + NotEnoughBalance, + RecipientRequired, +} from "@ledgerhq/coin-module-framework/errors"; +import { BigNumber } from "bignumber.js"; +import { NEW_ACCOUNT_SIZE, YOCTO_THRESHOLD_VARIATION } from "../constants"; +import { + NearActivationFeeNotCovered, + NearNewAccountWarning, + NearNewNamedAccountError, + NearNotEnoughAvailable, + NearNotEnoughStaked, + NearRecommendUnstake, + NearStakingThresholdNotMet, + NearUseAllAmountStakeWarning, +} from "../errors"; +import { fetchAccountDetails, getActionCosts, getStakingPositions } from "../network"; +import { pooledAmount } from "./pooledAmount"; +import { getYoctoThreshold, isImplicitAccount, isValidAddress } from "../logic"; +import { resolveTarget, type NearIntent } from "./craftTransaction"; + +const STAKING_MODES = new Set(["stake", "unstake", "withdraw"]); + +const NEAR_NAME = "NEAR"; +const NEAR_UNIT: Unit = { name: NEAR_NAME, code: NEAR_NAME, magnitude: 24 }; + +/** Errors carry a human-readable amount, which their i18n message interpolates. */ +function formatNear(value: BigNumber): string { + return formatCurrencyUnit(NEAR_UNIT, value, { showCode: true }); +} + +/** Spendable native balance: the account total minus its non-spendable part (staking and reserve). */ +function spendable(balances: Balance[]): bigint { + const native = balances.find(b => b.asset.type === "native" && b.stake === undefined); + + return (native?.value ?? 0n) - (native?.locked ?? 0n); +} + +/** + * Whether the sender has an open delegation. `balances` carries staking entries only when built + * from this API's own {@link getBalance} — a caller that rebuilds it from the wallet's generic + * `Account` model (`extractBalances`) never sets `stake`, so the pool is queried directly as a + * fallback, mirroring {@link pooledAmount}. + */ +async function hasOpenDelegation(balances: Balance[], sender: string): Promise { + if (balances.some(b => b.stake !== undefined)) { + return true; + } + + const { stakingPositions } = await getStakingPositions(sender); + return stakingPositions.length > 0; +} + +type RecipientCheck = { + error?: Error; + warning?: Error; + isNewAccount: boolean; + /** Yocto cost of creating the recipient account, zero when the recipient is unusable. */ + storageCost: BigNumber; + formattedStorageCost: string; +}; + +/** Whether the recipient is usable, and what creating it would cost if it does not exist yet. */ +async function checkRecipient(intent: NearIntent): Promise { + const unusable = { isNewAccount: false, storageCost: new BigNumber(0), formattedStorageCost: "" }; + + if (!intent.recipient) { + return { ...unusable, error: new RecipientRequired() }; + } + + if (!isValidAddress(intent.recipient)) { + return { ...unusable, error: new InvalidAddress("", { currencyName: NEAR_NAME }) }; + } + + const { storageCost: costPerByte, accountCreationCharge } = await getActionCosts(); + // The activation minimum is the higher of the storage deposit and nearcore's explicit + // account-creation charge (protocol 85+) — the latter can exceed the former. + const storageCost = BigNumber.max( + costPerByte.multipliedBy(NEW_ACCOUNT_SIZE), + accountCreationCharge, + ); + const formattedStorageCost = formatNear(storageCost); + + if (await fetchAccountDetails(intent.recipient)) { + return { isNewAccount: false, storageCost, formattedStorageCost }; + } + + const found = { isNewAccount: true, storageCost, formattedStorageCost }; + + // An implicit (hex) account is created by the transfer itself; a named account cannot be, + // it has to exist first. + if (isImplicitAccount(intent.recipient)) { + const warning = new NearNewAccountWarning(undefined, { + formattedNewAccountStorageCost: formattedStorageCost, + }); + return { ...found, warning }; + } + + return { ...found, error: new NearNewNamedAccountError() }; +} + +async function validateSend( + intent: NearIntent, + balances: Balance[], + estimatedFees: bigint, +): Promise { + const errors: Record = {}; + const warnings: Record = {}; + + const available = spendable(balances); + const recipient = await checkRecipient(intent); + + if (recipient.error) { + errors.recipient = recipient.error; + } + if (recipient.warning) { + warnings.recipient = recipient.warning; + } + if (intent.sender === intent.recipient) { + warnings.recipient = new InvalidAddressBecauseDestinationIsAlsoSource(); + } + + const maxAmount = available - estimatedFees > 0n ? available - estimatedFees : 0n; + const amount = intent.useAllAmount ? maxAmount : intent.amount; + const totalSpent = amount + estimatedFees; + + if (totalSpent > available) { + errors.amount = new NotEnoughBalance(); + } else if (amount <= 0n && !intent.useAllAmount) { + errors.amount = new AmountRequired(); + } else if (recipient.isNewAccount && amount < BigInt(recipient.storageCost.toFixed(0))) { + errors.amount = new NearActivationFeeNotCovered(undefined, { + formattedNewAccountStorageCost: recipient.formattedStorageCost, + }); + } + + if (intent.useAllAmount && (await hasOpenDelegation(balances, intent.sender))) { + warnings.amount = new NearRecommendUnstake(); + } + + return { errors, warnings, estimatedFees, amount, totalSpent }; +} + +// The ceiling a staking op can move: unstake/withdraw move already-delegated funds (only the fee +// comes out of the liquid balance), staking spends the liquid balance itself. +async function maxStakingAmount( + mode: string, + balances: Balance[], + sender: string, + delegate: string, + available: bigint, + estimatedFees: bigint, +): Promise { + if (mode === "unstake" || mode === "withdraw") { + return pooledAmount(mode, sender, delegate, balances); + } + + const afterFees = available - estimatedFees; + return afterFees > 0n ? afterFees : 0n; +} + +async function validateStaking( + intent: NearIntent, + balances: Balance[], + estimatedFees: bigint, + mode: string, + delegate: string, +): Promise { + const errors: Record = {}; + const warnings: Record = {}; + + const available = spendable(balances); + const yoctoThreshold = getYoctoThreshold(); + const threshold = BigInt(yoctoThreshold.toFixed(0)); + + const maxAmount = await maxStakingAmount( + mode, + balances, + intent.sender, + delegate, + available, + estimatedFees, + ); + const amount = intent.useAllAmount ? maxAmount : intent.amount; + const totalSpent = mode === "stake" ? amount + estimatedFees : estimatedFees; + + if (estimatedFees > available || (mode === "stake" && totalSpent > available)) { + errors.amount = new NotEnoughBalance(); + } else if (amount < threshold) { + // The node reports a staked amount a yoctoNEAR short of what was staked, so the displayed + // threshold carries the same variation the account bridge adds. + errors.amount = new NearStakingThresholdNotMet(undefined, { + threshold: formatNear(yoctoThreshold.plus(YOCTO_THRESHOLD_VARIATION)), + }); + } else if (mode === "unstake" && amount > maxAmount) { + errors.amount = new NearNotEnoughStaked(); + } else if (mode === "withdraw" && amount > maxAmount) { + errors.amount = new NearNotEnoughAvailable(); + } + + if (mode === "stake" && !errors.amount && intent.useAllAmount) { + warnings.amount = new NearUseAllAmountStakeWarning(); + } + + return { errors, warnings, estimatedFees, amount, totalSpent }; +} + +// Validates an intent against the account's balances, mirroring the account bridge's rules for +// both transfers and staking. +export async function validateIntent( + intent: NearIntent, + balances: Balance[], + customFees?: FeeEstimation, +): Promise { + const estimatedFees = customFees?.value ?? 0n; + const { mode, receiverId } = resolveTarget(intent); + + if (STAKING_MODES.has(mode)) { + return validateStaking(intent, balances, estimatedFees, mode, receiverId); + } + + return validateSend(intent, balances, estimatedFees); +} diff --git a/libs/coin-modules/coin-near/src/network/getBlock.test.ts b/libs/coin-modules/coin-near/src/network/getBlock.test.ts new file mode 100644 index 000000000000..66e12b16ff40 --- /dev/null +++ b/libs/coin-modules/coin-near/src/network/getBlock.test.ts @@ -0,0 +1,64 @@ +import { http, HttpResponse } from "msw"; +import { setMockCoinConfig } from "../test/coinConfig"; +import { getBlockHeaderAtHeight, getLastBlockHeader } from "./getBlock"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "./node.mock"; + +const HEADER = { height: 140_000_000, hash: "BlockHash1", timestamp: 1_750_000_000_000_000_000 }; + +const mockBlock = (capture?: (params: Record) => void) => + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, async ({ request }) => { + const body = (await request.json()) as { method: string; params: Record }; + if (body.method !== "block") { + return HttpResponse.json({ error: { message: `unexpected method ${body.method}` } }); + } + capture?.(body.params); + return HttpResponse.json({ result: { header: HEADER, chunks: [] } }); + }), + ); + +describe("getBlock", () => { + beforeAll(() => { + setMockCoinConfig(); + mockServer.listen({ onUnhandledRequest: "error" }); + }); + + afterEach(() => mockServer.resetHandlers()); + afterAll(() => mockServer.close()); + + it("asks for the latest final block", async () => { + let params: Record | undefined; + mockBlock(p => (params = p)); + + await expect(getLastBlockHeader()).resolves.toEqual(HEADER); + expect(params).toEqual({ finality: "final" }); + }); + + it("asks for a specific height", async () => { + let params: Record | undefined; + mockBlock(p => (params = p)); + + await expect(getBlockHeaderAtHeight(140_000_000)).resolves.toEqual(HEADER); + expect(params).toEqual({ block_id: 140_000_000 }); + }); + + it.each([-1, 1.5, Number.NaN])( + "rejects the invalid height %p without a request", + async height => { + // No handler is registered, so a request would fail the suite on an unhandled call. + await expect(getBlockHeaderAtHeight(height)).rejects.toThrow( + `invalid block height ${height}`, + ); + }, + ); + + it("throws when the node returns no header", async () => { + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, () => + HttpResponse.json({ error: { message: "DB Not Found Error" } }), + ), + ); + + await expect(getLastBlockHeader()).rejects.toThrow("DB Not Found Error"); + }); +}); diff --git a/libs/coin-modules/coin-near/src/network/getBlock.ts b/libs/coin-modules/coin-near/src/network/getBlock.ts new file mode 100644 index 000000000000..4d35b27247e6 --- /dev/null +++ b/libs/coin-modules/coin-near/src/network/getBlock.ts @@ -0,0 +1,42 @@ +import network from "@ledgerhq/live-network/network"; +import { getCoinConfig } from "../config"; +import type { NearBlockHeader } from "./sdk.types"; + +type BlockQuery = { finality: "final" } | { block_id: number }; + +// Block header via the JSON-RPC `block` method. Only the header is requested; reading a block's +// transactions would cost one extra call per chunk. +const getBlockHeader = async (params: BlockQuery): Promise => { + const currencyConfig = getCoinConfig(); + const { data } = await network<{ + result?: { header: NearBlockHeader }; + error?: { message?: string }; + }>({ + method: "POST", + url: currencyConfig.infra.API_NEAR_PRIVATE_NODE, + data: { + jsonrpc: "2.0", + id: "id", + method: "block", + params, + }, + }); + + if (!data.result?.header) { + throw new Error(data.error?.message || "Near: the node returned no block header"); + } + + return data.result.header; +}; + +export const getLastBlockHeader = (): Promise => + getBlockHeader({ finality: "final" }); + +/** Rejects rather than throwing synchronously, so every failure on this path is awaitable. */ +export const getBlockHeaderAtHeight = async (height: number): Promise => { + if (!Number.isInteger(height) || height < 0) { + throw new Error(`Near: invalid block height ${height}`); + } + + return getBlockHeader({ block_id: height }); +}; diff --git a/libs/coin-modules/coin-near/src/network/index.ts b/libs/coin-modules/coin-near/src/network/index.ts new file mode 100644 index 000000000000..879c0bfbe9c7 --- /dev/null +++ b/libs/coin-modules/coin-near/src/network/index.ts @@ -0,0 +1,12 @@ +export { getOperations, fetchTransactionsPage } from "./indexer"; +export { getBlockHeaderAtHeight, getLastBlockHeader } from "./getBlock"; +export { getActionCosts, getProtocolConfig, type NearActionCosts } from "./protocolConfig"; +export { + getAccount, + fetchAccountDetails, + getAccessKey, + getGasPrice, + broadcastTransaction, + getStakingPositions, + getValidators, +} from "./node"; diff --git a/libs/coin-modules/coin-near/src/api/indexer.integ.test.ts b/libs/coin-modules/coin-near/src/network/indexer.integ.test.ts similarity index 100% rename from libs/coin-modules/coin-near/src/api/indexer.integ.test.ts rename to libs/coin-modules/coin-near/src/network/indexer.integ.test.ts diff --git a/libs/coin-modules/coin-near/src/api/indexer.test.ts b/libs/coin-modules/coin-near/src/network/indexer.test.ts similarity index 100% rename from libs/coin-modules/coin-near/src/api/indexer.test.ts rename to libs/coin-modules/coin-near/src/network/indexer.test.ts diff --git a/libs/coin-modules/coin-near/src/api/indexer.ts b/libs/coin-modules/coin-near/src/network/indexer.ts similarity index 72% rename from libs/coin-modules/coin-near/src/api/indexer.ts rename to libs/coin-modules/coin-near/src/network/indexer.ts index 55e2d894c047..d187d42671a4 100644 --- a/libs/coin-modules/coin-near/src/api/indexer.ts +++ b/libs/coin-modules/coin-near/src/network/indexer.ts @@ -5,21 +5,50 @@ import { BigNumber } from "bignumber.js"; import { getCoinConfig } from "../config"; import { NearTransaction, NearV3Response } from "./sdk.types"; -const fetchTransactions = async (address: string): Promise => { +/** + * One page of account transactions. `next` is the indexer's own opaque cursor, forwarded as-is so + * callers can resume without knowing its encoding. + */ +export const fetchTransactionsPage = async ( + address: string, + options: { cursor?: string; limit?: number } = {}, +): Promise<{ transactions: NearTransaction[]; next?: string }> => { const currencyConfig = getCoinConfig(); + const params = new URLSearchParams(); + if (options.limit !== undefined) { + params.set("limit", String(options.limit)); + } + if (options.cursor) { + params.set("next", options.cursor); + } + const query = params.toString(); + const response = await liveNetwork>({ - url: `${currencyConfig.infra.API_NEARBLOCKS_INDEXER}/v3/accounts/${address}/txns`, + url: `${currencyConfig.infra.API_NEARBLOCKS_INDEXER}/v3/accounts/${address}/txns${ + query ? `?${query}` : "" + }`, }); - return response.data.data ?? []; + const next = response.data.meta?.next_page; + + return { + transactions: response.data.data ?? [], + ...(next !== undefined && { next }), + }; +}; + +const fetchTransactions = async (address: string): Promise => { + const { transactions } = await fetchTransactionsPage(address); + + return transactions; }; function isSender(transaction: NearTransaction, address: string): boolean { return transaction.signer_account_id === address; } -function getOperationType(transaction: NearTransaction, address: string): OperationType { +export function getOperationType(transaction: NearTransaction, address: string): OperationType { switch (transaction.actions?.at(0)?.method) { case "deposit_and_stake": return "STAKE"; diff --git a/libs/coin-modules/coin-near/src/api/node.mock.ts b/libs/coin-modules/coin-near/src/network/node.mock.ts similarity index 100% rename from libs/coin-modules/coin-near/src/api/node.mock.ts rename to libs/coin-modules/coin-near/src/network/node.mock.ts diff --git a/libs/coin-modules/coin-near/src/api/node.test.ts b/libs/coin-modules/coin-near/src/network/node.test.ts similarity index 78% rename from libs/coin-modules/coin-near/src/api/node.test.ts rename to libs/coin-modules/coin-near/src/network/node.test.ts index 380b76b6a176..de82f0b35a3f 100644 --- a/libs/coin-modules/coin-near/src/api/node.test.ts +++ b/libs/coin-modules/coin-near/src/network/node.test.ts @@ -1,12 +1,31 @@ import { http, HttpResponse } from "msw"; import { setCoinConfig } from "../config"; -import { getGasPrice, getStakingPositions, getValidators } from "./node"; +import { + broadcastTransaction, + getAccount, + getGasPrice, + getStakingPositions, + getValidators, +} from "./node"; import { mockServer, NEAR_BASE_URL_MOCKED } from "./node.mock"; +import { getActionCosts } from "./protocolConfig"; const RPC_GAS_PRICE = "123000000"; const ADDRESS = "delegator.near"; const VALIDATOR_ID = "astro-stakers.poolv1.near"; +const runtimeConfig = (storageAmountPerByte: string) => ({ + storage_amount_per_byte: storageAmountPerByte, + transaction_costs: { + action_creation_config: { + add_key_cost: { full_access_cost: { execution: 101765125000, send_not_sir: 101765125000 } }, + create_account_cost: { execution: 99607375000, send_not_sir: 99607375000 }, + transfer_cost: { execution: 115123062500, send_not_sir: 115123062500 }, + }, + action_receipt_creation_config: { execution: 108059500000, send_not_sir: 108059500000 }, + }, +}); + type IndexerValidator = { account_id: string; current_epoch_stake: string | null; @@ -83,6 +102,7 @@ describe("node api (indexer-backed calls)", () => { beforeEach(() => { getValidators.reset(); + getActionCosts.reset(); }); afterEach(() => { @@ -152,6 +172,47 @@ describe("node api (indexer-backed calls)", () => { }); }); + describe("getAccount", () => { + const mockAccount = (storageAmountPerByte: string): void => { + mockServer.use( + http.get(`${NEAR_BASE_URL_MOCKED}/v3/kitwallet/staking-deposits/:address`, () => + HttpResponse.json([]), + ), + http.post(NEAR_BASE_URL_MOCKED, async ({ request }) => { + const body = (await request.json()) as { + method: string; + params: { request_type?: string }; + }; + + if (body.method === "EXPERIMENTAL_protocol_config") { + return HttpResponse.json({ + result: { runtime_config: runtimeConfig(storageAmountPerByte) }, + }); + } + + if (body.params?.request_type === "view_account") { + return HttpResponse.json({ + result: { amount: "1000000000000000000000000", storage_usage: 182, block_height: 1 }, + }); + } + + return HttpResponse.json({ jsonrpc: "2.0", id: "id", result: {} }); + }), + ); + }; + + it("derives storageUsageBalance from the protocol config, not the preload cache", async () => { + // Distinct from FALLBACK_STORAGE_AMOUNT_PER_BYTE (constants.ts) — proves the value comes + // from the live protocol config rather than the preload cache's zeroed/fallback default. + mockAccount("20000000000000000000"); + + const account = await getAccount(ADDRESS); + + // storageUsageBalance = storageCost * storage_usage (182) + MIN_ACCOUNT_BALANCE_BUFFER + expect(account.nearResources.storageUsageBalance.toFixed()).toBe("53640000000000000000000"); + }); + }); + describe("getStakingPositions", () => { it("discovers delegated validators through the v3 kitwallet endpoint", async () => { let requestedPath: string | undefined; @@ -232,6 +293,64 @@ describe("node api (indexer-backed calls)", () => { }); }); + describe("broadcastTransaction", () => { + const mockSendTx = (result: unknown, error?: unknown): void => { + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, () => + HttpResponse.json({ jsonrpc: "2.0", id: "id", result, error }), + ), + ); + }; + + it("returns the transaction hash on success", async () => { + mockSendTx({ transaction: { hash: "GkQ7Uh8oPPGtVfyPz1yLKmqPqZ8ZyxvGtN5MmYq8mF1w" } }); + + await expect(broadcastTransaction("signed-tx")).resolves.toBe( + "GkQ7Uh8oPPGtVfyPz1yLKmqPqZ8ZyxvGtN5MmYq8mF1w", + ); + }); + + it("throws when the node responds with no transaction hash", async () => { + mockSendTx({ transaction: {} }); + + await expect(broadcastTransaction("signed-tx")).rejects.toThrow( + "Near: send_tx returned no transaction hash", + ); + }); + + it("propagates a non-timeout node error immediately", async () => { + mockSendTx(undefined, { cause: { name: "INVALID_TRANSACTION" }, message: "nonce too small" }); + + await expect(broadcastTransaction("signed-tx")).rejects.toThrow( + "INVALID_TRANSACTION: nonce too small", + ); + }); + + it("retries once on a timeout then returns the hash", async () => { + let attempts = 0; + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, () => { + attempts += 1; + if (attempts === 1) { + return HttpResponse.json({ + jsonrpc: "2.0", + id: "id", + error: { cause: { name: "TIMEOUT_ERROR" }, message: "timed out" }, + }); + } + return HttpResponse.json({ + jsonrpc: "2.0", + id: "id", + result: { transaction: { hash: "retried-hash" } }, + }); + }), + ); + + await expect(broadcastTransaction("signed-tx")).resolves.toBe("retried-hash"); + expect(attempts).toBe(2); + }); + }); + describe("getValidators", () => { it("maps the flat snake_case v3 shape", async () => { let requestedUrl: URL | undefined; diff --git a/libs/coin-modules/coin-near/src/api/node.ts b/libs/coin-modules/coin-near/src/network/node.ts similarity index 94% rename from libs/coin-modules/coin-near/src/api/node.ts rename to libs/coin-modules/coin-near/src/network/node.ts index b376b0ff1fde..e6a93874f8c9 100644 --- a/libs/coin-modules/coin-near/src/api/node.ts +++ b/libs/coin-modules/coin-near/src/network/node.ts @@ -7,13 +7,12 @@ import * as nearAPI from "near-api-js"; import { getCoinConfig } from "../config"; import { MIN_ACCOUNT_BALANCE_BUFFER } from "../constants"; import { canUnstake, canWithdraw, getYoctoThreshold } from "../logic"; -import { getCurrentNearPreloadData } from "../preload-data"; import { NearAccount } from "../types"; +import { getActionCosts } from "./protocolConfig"; import { NearAccessKey, NearAccountDetails, NearContract, - NearProtocolConfig, NearRawValidator, NearStakingPosition, NearV3Response, @@ -57,7 +56,9 @@ export const getAccount = async ( const { stakingPositions, totalStaked, totalAvailable, totalPending } = await getStakingPositions(address); - const { storageCost } = getCurrentNearPreloadData(); + // Read directly from the protocol config rather than the preload cache: a CoinModuleApi caller + // never runs the account bridge's preload step, so the cache would still hold its zeroed default. + const { storageCost } = await getActionCosts(); const balance = new BigNumber(accountDetails.amount); const storageUsage = storageCost.multipliedBy(accountDetails.storage_usage); @@ -83,24 +84,6 @@ export const getAccount = async ( }; }; -export const getProtocolConfig = async (): Promise => { - const currencyConfig = getCoinConfig(); - const { data } = await network<{ result: NearProtocolConfig }>({ - method: "POST", - url: currencyConfig.infra.API_NEAR_PRIVATE_NODE, - data: { - jsonrpc: "2.0", - id: "id", - method: "EXPERIMENTAL_protocol_config", - params: { - finality: "final", - }, - }, - }); - - return data.result; -}; - type NearStats = { gas_price: string | null; }; @@ -213,7 +196,13 @@ export const broadcastTransaction = async (transaction: string, retries = 6): Pr throw new Error((data.error?.cause?.name || "UNKOWWN CAUSE") + ": " + data.error.message); } - return data.result.transaction.hash; + const hash = data.result?.transaction?.hash; + + if (!hash) { + throw new Error("Near: send_tx returned no transaction hash"); + } + + return hash; }; export const getStakingPositions = async ( diff --git a/libs/coin-modules/coin-near/src/network/protocolConfig.test.ts b/libs/coin-modules/coin-near/src/network/protocolConfig.test.ts new file mode 100644 index 000000000000..42ce4b3f054d --- /dev/null +++ b/libs/coin-modules/coin-near/src/network/protocolConfig.test.ts @@ -0,0 +1,95 @@ +import { http, HttpResponse } from "msw"; +import { setMockCoinConfig } from "../test/coinConfig"; +import { mockServer, NEAR_BASE_URL_MOCKED } from "./node.mock"; +import { getActionCosts } from "./protocolConfig"; + +const runtimeConfig = { + storage_amount_per_byte: "10000000000000000000", + transaction_costs: { + action_creation_config: { + add_key_cost: { full_access_cost: { execution: 101765125000, send_not_sir: 101765125000 } }, + create_account_cost: { execution: 99607375000, send_not_sir: 99607375000 }, + transfer_cost: { execution: 115123062500, send_not_sir: 115123062500 }, + }, + action_receipt_creation_config: { execution: 108059500000, send_not_sir: 108059500000 }, + }, +}; + +const mockProtocolConfig = (result: unknown, onCall?: () => void) => + mockServer.use( + http.post(NEAR_BASE_URL_MOCKED, async ({ request }) => { + const body = (await request.json()) as { method: string }; + if (body.method !== "EXPERIMENTAL_protocol_config") { + return HttpResponse.json({ error: { message: `unexpected method ${body.method}` } }); + } + onCall?.(); + return HttpResponse.json({ result }); + }), + ); + +describe("getActionCosts", () => { + beforeAll(() => { + setMockCoinConfig(); + mockServer.listen({ onUnhandledRequest: "error" }); + }); + + beforeEach(() => getActionCosts.reset()); + afterEach(() => mockServer.resetHandlers()); + afterAll(() => mockServer.close()); + + it("derives the storage price and per-action gas costs from the protocol config", async () => { + mockProtocolConfig({ runtime_config: runtimeConfig }); + + const costs = await getActionCosts(); + + expect(costs.storageCost.toFixed()).toBe("10000000000000000000"); + expect(costs.transferCostSend.toFixed()).toBe("115123062500"); + expect(costs.transferCostExecution.toFixed()).toBe("115123062500"); + expect(costs.receiptCreationSend.toFixed()).toBe("108059500000"); + expect(costs.receiptCreationExecution.toFixed()).toBe("108059500000"); + expect(costs.createAccountCostSend.toFixed()).toBe("99607375000"); + expect(costs.createAccountCostExecution.toFixed()).toBe("99607375000"); + expect(costs.addKeyCostSend.toFixed()).toBe("101765125000"); + expect(costs.addKeyCostExecution.toFixed()).toBe("101765125000"); + }); + + it("defaults minGasPurchasePrice and accountCreationCharge to 0 on a pre-protocol-85 config", async () => { + mockProtocolConfig({ runtime_config: runtimeConfig }); + + const costs = await getActionCosts(); + + expect(costs.minGasPurchasePrice.toFixed()).toBe("0"); + expect(costs.accountCreationCharge.toFixed()).toBe("0"); + }); + + it("reads minGasPurchasePrice and accountCreationCharge once the protocol exposes them", async () => { + mockProtocolConfig({ + runtime_config: { + ...runtimeConfig, + min_gas_purchase_price: "1000000000", + account_creation_charge: "7000000000000000000000", + }, + }); + + const costs = await getActionCosts(); + + expect(costs.minGasPurchasePrice.toFixed()).toBe("1000000000"); + expect(costs.accountCreationCharge.toFixed()).toBe("7000000000000000000000"); + }); + + it("caches, so repeated pricing does not re-fetch the protocol config", async () => { + let calls = 0; + mockProtocolConfig({ runtime_config: runtimeConfig }, () => calls++); + + await getActionCosts(); + await getActionCosts(); + + expect(calls).toBe(1); + }); + + it("throws when the node returns no protocol config", async () => { + mockProtocolConfig(undefined); + + await expect(getActionCosts()).rejects.toThrow("NearProtocolConfigNotLoaded"); + }); +}); diff --git a/libs/coin-modules/coin-near/src/network/protocolConfig.ts b/libs/coin-modules/coin-near/src/network/protocolConfig.ts new file mode 100644 index 000000000000..83dcdd2631b7 --- /dev/null +++ b/libs/coin-modules/coin-near/src/network/protocolConfig.ts @@ -0,0 +1,83 @@ +import network from "@ledgerhq/live-network/network"; +import { makeLRUCache } from "@ledgerhq/live-network/cache"; +import { BigNumber } from "bignumber.js"; +import { getCoinConfig } from "../config"; +import { NearProtocolConfigNotLoaded } from "../errors"; +import type { NearProtocolConfig } from "./sdk.types"; + +// Lives here rather than in node.ts: fetchActionCosts is its only caller, and node.ts already +// imports getActionCosts from this module — importing back from node.ts would cycle the two files. +export const getProtocolConfig = async (): Promise => { + const currencyConfig = getCoinConfig(); + const { data } = await network<{ result: NearProtocolConfig }>({ + method: "POST", + url: currencyConfig.infra.API_NEAR_PRIVATE_NODE, + data: { + jsonrpc: "2.0", + id: "id", + method: "EXPERIMENTAL_protocol_config", + params: { + finality: "final", + }, + }, + }); + + return data.result; +}; + +// Per-action gas costs and storage price, derived from the protocol config. The account bridge +// reads these from preload; callers outside a bridge sync (CoinModuleApi) have none, so this reads +// them directly — cached since the config only changes on a protocol upgrade. +export type NearActionCosts = { + storageCost: BigNumber; + createAccountCostSend: BigNumber; + createAccountCostExecution: BigNumber; + transferCostSend: BigNumber; + transferCostExecution: BigNumber; + addKeyCostSend: BigNumber; + addKeyCostExecution: BigNumber; + receiptCreationSend: BigNumber; + receiptCreationExecution: BigNumber; + /** Floor on the execution-half gas price (protocol 85+); 0 on older protocol versions. */ + minGasPurchasePrice: BigNumber; + /** Yocto cost of activating a new account (protocol 85+); 0 on older protocol versions. */ + accountCreationCharge: BigNumber; +}; + +const fetchActionCosts = async (): Promise => { + const protocolConfig = await getProtocolConfig(); + + if (!protocolConfig) { + throw new NearProtocolConfigNotLoaded(); + } + + const { + storage_amount_per_byte, + transaction_costs, + min_gas_purchase_price, + account_creation_charge, + } = protocolConfig.runtime_config; + const { action_creation_config, action_receipt_creation_config } = transaction_costs; + + return { + storageCost: new BigNumber(storage_amount_per_byte), + createAccountCostSend: new BigNumber(action_creation_config.create_account_cost.send_not_sir), + createAccountCostExecution: new BigNumber(action_creation_config.create_account_cost.execution), + transferCostSend: new BigNumber(action_creation_config.transfer_cost.send_not_sir), + transferCostExecution: new BigNumber(action_creation_config.transfer_cost.execution), + addKeyCostSend: new BigNumber( + action_creation_config.add_key_cost.full_access_cost.send_not_sir, + ), + addKeyCostExecution: new BigNumber( + action_creation_config.add_key_cost.full_access_cost.execution, + ), + receiptCreationSend: new BigNumber(action_receipt_creation_config.send_not_sir), + receiptCreationExecution: new BigNumber(action_receipt_creation_config.execution), + minGasPurchasePrice: new BigNumber(min_gas_purchase_price ?? 0), + accountCreationCharge: new BigNumber(account_creation_charge ?? 0), + }; +}; + +export const getActionCosts = makeLRUCache(fetchActionCosts, () => "", { + ttl: 30 * 60 * 1000, +}); diff --git a/libs/coin-modules/coin-near/src/api/sdk.types.ts b/libs/coin-modules/coin-near/src/network/sdk.types.ts similarity index 80% rename from libs/coin-modules/coin-near/src/api/sdk.types.ts rename to libs/coin-modules/coin-near/src/network/sdk.types.ts index 3926ee609666..5901edf17cee 100644 --- a/libs/coin-modules/coin-near/src/api/sdk.types.ts +++ b/libs/coin-modules/coin-near/src/network/sdk.types.ts @@ -35,9 +35,20 @@ export type NearTransaction = { }[]; }; +export type NearBlockHeader = { + height: number; + hash: string; + /** Block time in nanoseconds since the epoch. */ + timestamp: number; +}; + export type NearProtocolConfig = { runtime_config: { storage_amount_per_byte: string; + /** Floor on the execution-half gas price, in effect since protocol 85. `#[serde(default)]` — absent (≡ 0) on older protocol versions. */ + min_gas_purchase_price?: string; + /** Yocto cost of activating a new account, in effect since protocol 85. `#[serde(default)]` — absent (≡ 0) on older protocol versions. */ + account_creation_charge?: string; transaction_costs: { action_creation_config: { add_key_cost: { diff --git a/libs/coin-modules/coin-near/src/preload-data.ts b/libs/coin-modules/coin-near/src/preload-data.ts index c6e842c1af16..cf9ae4892a44 100644 --- a/libs/coin-modules/coin-near/src/preload-data.ts +++ b/libs/coin-modules/coin-near/src/preload-data.ts @@ -14,6 +14,8 @@ let currentPreloadedData: NearPreloadedData = { addKeyCostExecution: new BigNumber(0), receiptCreationSend: new BigNumber(0), receiptCreationExecution: new BigNumber(0), + minGasPurchasePrice: new BigNumber(0), + accountCreationCharge: new BigNumber(0), validators: [], }; diff --git a/libs/coin-modules/coin-near/src/preload.ts b/libs/coin-modules/coin-near/src/preload.ts index 71c27521dbd0..10bcb5e78348 100644 --- a/libs/coin-modules/coin-near/src/preload.ts +++ b/libs/coin-modules/coin-near/src/preload.ts @@ -1,7 +1,8 @@ import { log } from "@ledgerhq/logs"; import { BigNumber } from "bignumber.js"; -import { getGasPrice, getProtocolConfig, getValidators } from "./api/node"; -import { NearProtocolConfigNotLoaded } from "./errors"; +import { VALIDATORS_COUNT } from "./constants"; +import { getGasPrice, getValidators } from "./network/node"; +import { getActionCosts } from "./network/protocolConfig"; import { getCurrentNearPreloadData, setNearPreloadData } from "./preload-data"; import type { NearPreloadedData } from "./types"; @@ -43,6 +44,12 @@ function fromHydratePreloadData(data: any): NearPreloadedData { if (data.receiptCreationExecution) { hydratedData.receiptCreationExecution = new BigNumber(data.receiptCreationExecution); } + if (data.minGasPurchasePrice) { + hydratedData.minGasPurchasePrice = new BigNumber(data.minGasPurchasePrice); + } + if (data.accountCreationCharge) { + hydratedData.accountCreationCharge = new BigNumber(data.accountCreationCharge); + } if (Array.isArray(data.validators) && data.validators.length) { hydratedData.validators = data.validators; } @@ -58,45 +65,23 @@ export const getPreloadStrategy = () => ({ export const preload = async (): Promise => { log("near/preload", "preloading near data..."); - const [protocolConfig, rawValidators, gasPrice] = await Promise.all([ - getProtocolConfig(), - getValidators({ total: 200 }), + // `force` so a preload always refreshes the protocol config, as it did before the derivation + // moved behind a cache; it also refills that cache for callers outside a bridge sync. + const [actionCosts, rawValidators, gasPrice] = await Promise.all([ + getActionCosts.force(), + getValidators({ total: VALIDATORS_COUNT }), getGasPrice(), ]); - const validators = await Promise.all( - rawValidators.map(async ({ account_id: validatorAddress, stake, commission }) => { - return { - validatorAddress, - tokens: stake, - commission, - }; - }), - ); - - if (!protocolConfig) { - throw new NearProtocolConfigNotLoaded(); - } - - const { storage_amount_per_byte, transaction_costs } = protocolConfig.runtime_config; - - const { action_creation_config, action_receipt_creation_config } = transaction_costs; + const validators = rawValidators.map(({ account_id: validatorAddress, stake, commission }) => ({ + validatorAddress, + tokens: stake, + commission, + })); return { - storageCost: new BigNumber(storage_amount_per_byte), + ...actionCosts, gasPrice: new BigNumber(gasPrice), - createAccountCostSend: new BigNumber(action_creation_config.create_account_cost.send_not_sir), - createAccountCostExecution: new BigNumber(action_creation_config.create_account_cost.execution), - transferCostSend: new BigNumber(action_creation_config.transfer_cost.send_not_sir), - transferCostExecution: new BigNumber(action_creation_config.transfer_cost.execution), - addKeyCostSend: new BigNumber( - action_creation_config.add_key_cost.full_access_cost.send_not_sir, - ), - addKeyCostExecution: new BigNumber( - action_creation_config.add_key_cost.full_access_cost.execution, - ), - receiptCreationSend: new BigNumber(action_receipt_creation_config.send_not_sir), - receiptCreationExecution: new BigNumber(action_receipt_creation_config.execution), validators, }; }; diff --git a/libs/coin-modules/coin-near/src/synchronisation.ts b/libs/coin-modules/coin-near/src/synchronisation.ts index 8cb1b741c0fb..314b2b3b929d 100644 --- a/libs/coin-modules/coin-near/src/synchronisation.ts +++ b/libs/coin-modules/coin-near/src/synchronisation.ts @@ -1,7 +1,7 @@ import { encodeAccountId } from "@ledgerhq/ledger-wallet-framework/account/accountId"; import type { GetAccountShape } from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers"; import { makeSync, mergeOps } from "@ledgerhq/ledger-wallet-framework/bridge/jsHelpers"; -import { getAccount, getOperations } from "./api"; +import { getAccount, getOperations } from "./network"; import { NearAccount } from "./types"; export const getAccountShape: GetAccountShape = async info => { diff --git a/libs/coin-modules/coin-near/src/test/coinConfig.ts b/libs/coin-modules/coin-near/src/test/coinConfig.ts new file mode 100644 index 000000000000..09c0e11d7990 --- /dev/null +++ b/libs/coin-modules/coin-near/src/test/coinConfig.ts @@ -0,0 +1,14 @@ +import { setCoinConfig } from "../config"; +import { NEAR_BASE_URL_MOCKED } from "../network/node.mock"; + +/** Point every endpoint at the msw-mocked host. */ +export const setMockCoinConfig = (): void => + setCoinConfig(() => ({ + status: { type: "active" }, + infra: { + API_NEAR_PRIVATE_NODE: NEAR_BASE_URL_MOCKED, + API_NEAR_PUBLIC_NODE: NEAR_BASE_URL_MOCKED, + API_NEAR_INDEXER: NEAR_BASE_URL_MOCKED, + API_NEARBLOCKS_INDEXER: NEAR_BASE_URL_MOCKED, + }, + })); diff --git a/libs/coin-modules/coin-near/src/types.ts b/libs/coin-modules/coin-near/src/types.ts index 7dd682e1b48e..e5cd5ace0897 100644 --- a/libs/coin-modules/coin-near/src/types.ts +++ b/libs/coin-modules/coin-near/src/types.ts @@ -7,9 +7,9 @@ import { TransactionStatusCommonRaw, } from "@ledgerhq/types-live"; import type { BigNumber } from "bignumber.js"; -import type { NearStakingPosition } from "./api/sdk.types"; +import type { NearStakingPosition } from "./network/sdk.types"; -export type { NearStakingPosition } from "./api/sdk.types"; +export type { NearStakingPosition } from "./network/sdk.types"; export type Transaction = TransactionCommon & { family: "near"; @@ -34,6 +34,8 @@ export type NearPreloadedData = { addKeyCostExecution: BigNumber; receiptCreationSend: BigNumber; receiptCreationExecution: BigNumber; + minGasPurchasePrice: BigNumber; + accountCreationCharge: BigNumber; validators: NearValidatorItem[]; }; diff --git a/libs/ledger-live-common/.unimportedrc.json b/libs/ledger-live-common/.unimportedrc.json index f93b3c8a0b55..9ee86637fd70 100644 --- a/libs/ledger-live-common/.unimportedrc.json +++ b/libs/ledger-live-common/.unimportedrc.json @@ -786,6 +786,7 @@ "src/families/multiversx/bridge/api.ts", "src/families/multiversx/coinModuleApi.ts", "src/families/multiversx/config.ts", + "src/families/near/coinModuleApi.ts", "src/families/near/config.ts", "src/families/polkadot/config.ts", "src/families/solana/bridge/api.ts", diff --git a/libs/ledger-live-common/src/coin-modules/loaders.ts b/libs/ledger-live-common/src/coin-modules/loaders.ts index ab4cd7bb7f99..ee323a045c8f 100644 --- a/libs/ledger-live-common/src/coin-modules/loaders.ts +++ b/libs/ledger-live-common/src/coin-modules/loaders.ts @@ -303,6 +303,7 @@ export const coinModuleLoaders: CoinModuleLoader[] = [ family: "near", supportedCoins: ["near"], loadSetup: () => import("../families/near/setup"), + loadLocalApi: () => import("../families/near/coinModuleApi").then(m => m.createLocalNearApi), loadTransaction: () => import("@ledgerhq/coin-near/transaction").then(m => m.default), loadDeviceTxConfig: () => import("@ledgerhq/coin-near/deviceTransactionConfig").then(m => m.default), diff --git a/libs/ledger-live-common/src/families/near/banner.test.ts b/libs/ledger-live-common/src/families/near/banner.test.ts index e6b54dc19a0f..571b4a592c9b 100644 --- a/libs/ledger-live-common/src/families/near/banner.test.ts +++ b/libs/ledger-live-common/src/families/near/banner.test.ts @@ -3,8 +3,11 @@ import * as preloadedData from "@ledgerhq/coin-near/preload"; import * as logic from "@ledgerhq/coin-near/logic"; import { BigNumber } from "bignumber.js"; -import type { NearAccount, NearValidatorItem } from "@ledgerhq/coin-near/types"; -import type { NearStakingPosition } from "@ledgerhq/coin-near/api/sdk.types"; +import type { + NearAccount, + NearStakingPosition, + NearValidatorItem, +} from "@ledgerhq/coin-near/types"; const ledgerValidator: NearValidatorItem = { validatorAddress: "ledgerbyfigment.poolv1.near", diff --git a/libs/ledger-live-common/src/families/near/coinModuleApi.ts b/libs/ledger-live-common/src/families/near/coinModuleApi.ts new file mode 100644 index 000000000000..0032816c688d --- /dev/null +++ b/libs/ledger-live-common/src/families/near/coinModuleApi.ts @@ -0,0 +1,12 @@ +import { createApi as createNearApi } from "@ledgerhq/coin-near/api/index"; +import type { NearCoinConfig } from "@ledgerhq/coin-near/config"; +import type { CoinModuleApi } from "@ledgerhq/coin-module-framework/api/types"; +import type { BridgeApi } from "@ledgerhq/ledger-wallet-framework/api/types"; +import { getCurrencyConfiguration } from "../../config"; + +export function createLocalNearApi(currencyId: string): CoinModuleApi & BridgeApi { + return createNearApi( + () => getCurrencyConfiguration>(currencyId), + currencyId, + ) as CoinModuleApi & BridgeApi; +}