From cc9d58f08ae38197ea2bd19115eda870d348f6aa Mon Sep 17 00:00:00 2001 From: Pawel Kruszelnicki Date: Thu, 30 Jul 2026 14:23:38 +0200 Subject: [PATCH] feat(coin-near): add CoinModuleApi for native NEAR and staking --- .changeset/near-coinmodule-api.md | 6 + .../coin-modules/coin-near/.unimportedrc.json | 4 +- .../coin-near/src/api/index.integ.test.ts | 374 ++++++++++++++++++ .../coin-near/src/api/index.test.ts | 147 +++++++ libs/coin-modules/coin-near/src/api/index.ts | 137 ++++++- .../coin-near/src/api/parity.integ.test.ts | 166 ++++++++ libs/coin-modules/coin-near/src/broadcast.ts | 2 +- .../coin-near/src/buildTransaction.ts | 60 +-- libs/coin-modules/coin-near/src/constants.ts | 2 + .../coin-near/src/getFeesForTransaction.ts | 37 +- .../src/getTransactionStatus.test.ts | 2 +- .../coin-near/src/getTransactionStatus.ts | 2 +- libs/coin-modules/coin-near/src/logic.ts | 8 +- .../src/logic/account/getBalance.test.ts | 138 +++++++ .../coin-near/src/logic/account/getBalance.ts | 40 ++ .../coin-near/src/logic/actions.test.ts | 67 ++++ .../coin-near/src/logic/actions.ts | 62 +++ .../coin-near/src/logic/fees.test.ts | 95 +++++ libs/coin-modules/coin-near/src/logic/fees.ts | 48 +++ .../src/logic/history/getBlockInfo.test.ts | 42 ++ .../src/logic/history/getBlockInfo.ts | 15 + .../coin-near/src/logic/history/lastBlock.ts | 8 + .../src/logic/history/listOperations.test.ts | 156 ++++++++ .../src/logic/history/listOperations.ts | 86 ++++ .../src/logic/staking/getStakes.test.ts | 112 ++++++ .../coin-near/src/logic/staking/getStakes.ts | 72 ++++ .../src/logic/staking/getValidators.test.ts | 48 +++ .../src/logic/staking/getValidators.ts | 20 + .../src/logic/staking/pooledAmount.ts | 41 ++ .../src/logic/transaction/broadcast.test.ts | 26 ++ .../src/logic/transaction/broadcast.ts | 10 + .../src/logic/transaction/combine.test.ts | 89 +++++ .../src/logic/transaction/combine.ts | 42 ++ .../craftTransaction.parity.test.ts | 110 ++++++ .../transaction/craftTransaction.test.ts | 166 ++++++++ .../src/logic/transaction/craftTransaction.ts | 117 ++++++ .../logic/transaction/estimateFees.test.ts | 163 ++++++++ .../src/logic/transaction/estimateFees.ts | 65 +++ .../logic/transaction/validateIntent.test.ts | 340 ++++++++++++++++ .../src/logic/transaction/validateIntent.ts | 215 ++++++++++ .../coin-near/src/network/getBlock.test.ts | 64 +++ .../coin-near/src/network/getBlock.ts | 45 +++ .../coin-near/src/network/index.ts | 13 + .../{api => network}/indexer.integ.test.ts | 0 .../src/{api => network}/indexer.test.ts | 0 .../coin-near/src/{api => network}/indexer.ts | 37 +- .../src/{api => network}/node.mock.ts | 0 .../src/{api => network}/node.test.ts | 0 .../coin-near/src/{api => network}/node.ts | 0 .../src/network/protocolConfig.test.ts | 71 ++++ .../coin-near/src/network/protocolConfig.ts | 54 +++ .../src/{api => network}/sdk.types.ts | 7 + libs/coin-modules/coin-near/src/preload.ts | 49 +-- .../coin-near/src/synchronisation.ts | 2 +- .../coin-near/src/test/coinConfig.ts | 14 + libs/coin-modules/coin-near/src/types.ts | 4 +- libs/ledger-live-common/.unimportedrc.json | 1 + .../src/coin-modules/loaders.ts | 1 + .../src/families/near/banner.test.ts | 7 +- .../src/families/near/coinModuleApi.ts | 12 + 60 files changed, 3581 insertions(+), 140 deletions(-) create mode 100644 .changeset/near-coinmodule-api.md create mode 100644 libs/coin-modules/coin-near/src/api/index.integ.test.ts create mode 100644 libs/coin-modules/coin-near/src/api/index.test.ts create mode 100644 libs/coin-modules/coin-near/src/api/parity.integ.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/account/getBalance.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/account/getBalance.ts create mode 100644 libs/coin-modules/coin-near/src/logic/actions.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/actions.ts create mode 100644 libs/coin-modules/coin-near/src/logic/fees.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/fees.ts create mode 100644 libs/coin-modules/coin-near/src/logic/history/getBlockInfo.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/history/getBlockInfo.ts create mode 100644 libs/coin-modules/coin-near/src/logic/history/lastBlock.ts create mode 100644 libs/coin-modules/coin-near/src/logic/history/listOperations.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/history/listOperations.ts create mode 100644 libs/coin-modules/coin-near/src/logic/staking/getStakes.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/staking/getStakes.ts create mode 100644 libs/coin-modules/coin-near/src/logic/staking/getValidators.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/staking/getValidators.ts create mode 100644 libs/coin-modules/coin-near/src/logic/staking/pooledAmount.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/broadcast.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/broadcast.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/combine.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/combine.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/craftTransaction.parity.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/craftTransaction.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/craftTransaction.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/estimateFees.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/estimateFees.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/validateIntent.test.ts create mode 100644 libs/coin-modules/coin-near/src/logic/transaction/validateIntent.ts create mode 100644 libs/coin-modules/coin-near/src/network/getBlock.test.ts create mode 100644 libs/coin-modules/coin-near/src/network/getBlock.ts create mode 100644 libs/coin-modules/coin-near/src/network/index.ts rename libs/coin-modules/coin-near/src/{api => network}/indexer.integ.test.ts (100%) rename libs/coin-modules/coin-near/src/{api => network}/indexer.test.ts (100%) rename libs/coin-modules/coin-near/src/{api => network}/indexer.ts (72%) rename libs/coin-modules/coin-near/src/{api => network}/node.mock.ts (100%) rename libs/coin-modules/coin-near/src/{api => network}/node.test.ts (100%) rename libs/coin-modules/coin-near/src/{api => network}/node.ts (100%) create mode 100644 libs/coin-modules/coin-near/src/network/protocolConfig.test.ts create mode 100644 libs/coin-modules/coin-near/src/network/protocolConfig.ts rename libs/coin-modules/coin-near/src/{api => network}/sdk.types.ts (93%) create mode 100644 libs/coin-modules/coin-near/src/test/coinConfig.ts create mode 100644 libs/ledger-live-common/src/families/near/coinModuleApi.ts 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/src/api/index.integ.test.ts b/libs/coin-modules/coin-near/src/api/index.integ.test.ts new file mode 100644 index 000000000000..83be150359c8 --- /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/transaction/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..c9d1abcd249d --- /dev/null +++ b/libs/coin-modules/coin-near/src/api/index.test.ts @@ -0,0 +1,147 @@ +import { NEAR_BASE_URL_MOCKED } from "../network/node.mock"; +import { getBalance } from "../logic/account/getBalance"; +import { getBlockInfo } from "../logic/history/getBlockInfo"; +import { lastBlock } from "../logic/history/lastBlock"; +import { listOperations } from "../logic/history/listOperations"; +import { getStakes } from "../logic/staking/getStakes"; +import { getValidators } from "../logic/staking/getValidators"; +import { broadcast } from "../logic/transaction/broadcast"; +import { craftTransaction } from "../logic/transaction/craftTransaction"; +import { estimateFees } from "../logic/transaction/estimateFees"; +import { validateIntent } from "../logic/transaction/validateIntent"; +import { createApi } from "./index"; + +jest.mock("../logic/account/getBalance", () => ({ getBalance: jest.fn() })); +jest.mock("../logic/history/getBlockInfo", () => ({ getBlockInfo: jest.fn() })); +jest.mock("../logic/history/lastBlock", () => ({ lastBlock: jest.fn() })); +jest.mock("../logic/history/listOperations", () => ({ listOperations: jest.fn() })); +jest.mock("../logic/staking/getStakes", () => ({ getStakes: jest.fn() })); +jest.mock("../logic/staking/getValidators", () => ({ getValidators: jest.fn() })); +jest.mock("../logic/transaction/broadcast", () => ({ broadcast: jest.fn() })); +jest.mock("../logic/transaction/craftTransaction", () => ({ craftTransaction: jest.fn() })); +jest.mock("../logic/transaction/estimateFees", () => ({ estimateFees: jest.fn() })); +jest.mock("../logic/transaction/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"); + } + }); + + it("declares staking support, which is what makes the framework read validators", () => { + expect(api.stakingSupported).toBe(true); + }); + + 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..fc07ecd6b584 100644 --- a/libs/coin-modules/coin-near/src/api/index.ts +++ b/libs/coin-modules/coin-near/src/api/index.ts @@ -1,9 +1,128 @@ -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/account/getBalance"; +import { getBlockInfo } from "../logic/history/getBlockInfo"; +import { lastBlock } from "../logic/history/lastBlock"; +import { listOperations } from "../logic/history/listOperations"; +import { getStakes } from "../logic/staking/getStakes"; +import { getValidators } from "../logic/staking/getValidators"; +import { broadcast } from "../logic/transaction/broadcast"; +import { combine } from "../logic/transaction/combine"; +import { craftTransaction, type NearIntent } from "../logic/transaction/craftTransaction"; +import { estimateFees } from "../logic/transaction/estimateFees"; +import { validateIntent } from "../logic/transaction/validateIntent"; + +/** + * CoinModuleApi ("Alpaca") entry point for NEAR. + * + * NEAR is account-based with real delegated staking through staking pool contracts, so the staking + * reads are implemented and `stakingSupported` is set. What is not implemented is deliberate: + * + * - `getRewards`: a staking pool compounds rewards into the staked balance, so there is no discrete + * reward-distribution event to list. + * - `getBlock`: reading a block's transactions costs one extra call per chunk; only the header, + * which `getBlockInfo` returns, is cheap. + * - `getNextSequence`: a NEAR nonce belongs to an access key, not to an account, so it cannot be + * resolved from an address alone. `craftTransaction` resolves it from the sender public key. + * - `call` and `craftRawTransaction`: no contract-call surface in this module. + * - tokens: the module has no NEP-141 support, so every balance and operation is native. + */ +/** + * `stakingSupported` is not part of `CoinModuleApi`; the wallet framework reads it off the bridge + * api to decide whether to fetch validators during a sync. It is declared inline rather than by + * importing `BridgeApi`, which coin modules are not allowed to depend on. + */ +export function createApi( + config: NearCoinConfig, + _currencyId: string, +): CoinModuleApi & { stakingSupported: true } { + 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 --- + stakingSupported: true, + 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..c5f55a7d54f9 --- /dev/null +++ b/libs/coin-modules/coin-near/src/api/parity.integ.test.ts @@ -0,0 +1,166 @@ +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), + 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); + + 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..1fa5087ca19c 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) { @@ -168,7 +172,7 @@ 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. */ -export const getStakingFees = (t: Transaction, gasPrice: BigNumber): BigNumber => { +export const getStakingFees = (t: StakingGasInput, gasPrice: BigNumber): BigNumber => { const stakingGas = getStakingGas(t); return stakingGas diff --git a/libs/coin-modules/coin-near/src/logic/account/getBalance.test.ts b/libs/coin-modules/coin-near/src/logic/account/getBalance.test.ts new file mode 100644 index 000000000000..c5df6f5f08f9 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/account/getBalance.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/account/getBalance.ts b/libs/coin-modules/coin-near/src/logic/account/getBalance.ts new file mode 100644 index 000000000000..9d9d76ace840 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/account/getBalance.ts @@ -0,0 +1,40 @@ +import type { AssetInfo, Balance } from "@ledgerhq/coin-module-framework/api/index"; +import { getAccount } from "../../network"; +import { toStakes } from "../staking/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/actions.test.ts b/libs/coin-modules/coin-near/src/logic/actions.test.ts new file mode 100644 index 000000000000..59c06f5731c5 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/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/actions.ts b/libs/coin-modules/coin-near/src/logic/actions.ts new file mode 100644 index 000000000000..2deeac53c9a8 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/actions.ts @@ -0,0 +1,62 @@ +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; +}; + +/** + * The actions of a NEAR transaction, one per supported mode. + * + * Staking goes through the staking pool contract: the amount is the attached deposit when staking, + * and a call argument when unstaking or withdrawing. The `_all` variants take no amount, which is + * how "use all" avoids leaving dust behind when 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/fees.test.ts b/libs/coin-modules/coin-near/src/logic/fees.test.ts new file mode 100644 index 000000000000..9d29fce6efa8 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/fees.test.ts @@ -0,0 +1,95 @@ +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), +}; + +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 / 10 + const expected = new BigNumber(STAKING_GAS_BASE) + .multipliedBy(6) + .multipliedBy(GAS_PRICE) + .dividedBy(10); + 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 / 10 + const expected = new BigNumber(STAKING_GAS_BASE) + .multipliedBy(8) + .multipliedBy(GAS_PRICE) + .dividedBy(10); + 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/fees.ts b/libs/coin-modules/coin-near/src/logic/fees.ts new file mode 100644 index 000000000000..48abc1169d38 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/fees.ts @@ -0,0 +1,48 @@ +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"]); + +/** + * The fee for a transaction, in yoctoNEAR. + * + * Single source of the formula for both callers: the account bridge, which sources `costs` from + * preloaded data, and the CoinModuleApi surface, which reads them from the protocol config. + * Sending to an implicit (hex) account that does not exist yet also pays for creating the account + * 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); + } + + return sendFee.multipliedBy(gasPrice).plus(executionFee.multipliedBy(gasPrice)); +}; diff --git a/libs/coin-modules/coin-near/src/logic/history/getBlockInfo.test.ts b/libs/coin-modules/coin-near/src/logic/history/getBlockInfo.test.ts new file mode 100644 index 000000000000..27636e0423b3 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/history/getBlockInfo.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/history/getBlockInfo.ts b/libs/coin-modules/coin-near/src/logic/history/getBlockInfo.ts new file mode 100644 index 000000000000..1d79e6b4b301 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/history/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/history/lastBlock.ts b/libs/coin-modules/coin-near/src/logic/history/lastBlock.ts new file mode 100644 index 000000000000..f34b088b0ca1 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/history/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/history/listOperations.test.ts b/libs/coin-modules/coin-near/src/logic/history/listOperations.test.ts new file mode 100644 index 000000000000..db5ab7094cc9 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/history/listOperations.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/history/listOperations.ts b/libs/coin-modules/coin-near/src/logic/history/listOperations.ts new file mode 100644 index 000000000000..c59783aa2846 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/history/listOperations.ts @@ -0,0 +1,86 @@ +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; + +/** + * Map an indexer transaction to a framework operation. + * + * Unlike the account bridge's mapping, an OUT value is the transferred amount **only**. The bridge + * folds the fee into the value itself, whereas the generic framework adds `tx.fees` on top of + * `value` for outgoing operations, so including it here would charge the fee twice. + */ +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 operation history, newest first. + * + * The cursor is the indexer's own opaque page token, forwarded unchanged. Paging is forward-only + * from newest to oldest, so ascending order cannot 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/staking/getStakes.test.ts b/libs/coin-modules/coin-near/src/logic/staking/getStakes.test.ts new file mode 100644 index 000000000000..20dfb020cc5f --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/staking/getStakes.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 no rewards, since a staking pool compounds them into the staked balance", () => { + const [stake] = toStakes(ADDRESS, [position({ staked: ABOVE_THRESHOLD })]); + + expect(stake.amountRewarded).toBeUndefined(); + expect(stake.amountDeposited).toBe(stake.amount); + }); +}); + +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/staking/getStakes.ts b/libs/coin-modules/coin-near/src/logic/staking/getStakes.ts new file mode 100644 index 000000000000..df8080b4c9b8 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/staking/getStakes.ts @@ -0,0 +1,72 @@ +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"; + +/** + * A NEAR delegation splits into up to three framework stakes per staking pool: the staked + * principal, the unstaked amount still inside the unbonding window, and the unstaked amount that + * has become withdrawable. + * + * Rewards are not a separate position: a staking pool compounds them into the staked balance, so + * `amount` already includes them and there is no `amountRewarded` to report. + * + * Amounts below the staking threshold are dropped. The node reports a staked balance one + * yoctoNEAR below what was staked, so a position can otherwise linger as unactionable dust. + */ +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)), + amountDeposited: 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)), + amountDeposited: 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)), + amountDeposited: 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/staking/getValidators.test.ts b/libs/coin-modules/coin-near/src/logic/staking/getValidators.test.ts new file mode 100644 index 000000000000..ebdc340a0fe3 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/staking/getValidators.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/staking/getValidators.ts b/libs/coin-modules/coin-near/src/logic/staking/getValidators.ts new file mode 100644 index 000000000000..b18cef03e760 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/staking/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/staking/pooledAmount.ts b/libs/coin-modules/coin-near/src/logic/staking/pooledAmount.ts new file mode 100644 index 000000000000..52d5e4329ef0 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/staking/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/transaction/broadcast.test.ts b/libs/coin-modules/coin-near/src/logic/transaction/broadcast.test.ts new file mode 100644 index 000000000000..44ef0da49a4f --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/broadcast.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/transaction/broadcast.ts b/libs/coin-modules/coin-near/src/logic/transaction/broadcast.ts new file mode 100644 index 000000000000..6ba4d3fadd1b --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/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/transaction/combine.test.ts b/libs/coin-modules/coin-near/src/logic/transaction/combine.test.ts new file mode 100644 index 000000000000..ec11bd3c7ff9 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/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/transaction/combine.ts b/libs/coin-modules/coin-near/src/logic/transaction/combine.ts new file mode 100644 index 000000000000..ba19b5988b51 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/combine.ts @@ -0,0 +1,42 @@ +import * as nearAPI from "near-api-js"; + +/** + * Decode a hex signature, refusing anything Node would quietly truncate. + * + * `Buffer.from(value, "hex")` stops at the first invalid character and drops a trailing half-byte, + * so a malformed signature would otherwise be attached at the wrong length and only fail once the + * network rejects the 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; +} + +/** + * Attach a device signature to a crafted transaction and return the signed transaction, base64'd + * and ready to broadcast. + * + * The key type is read back from the transaction's own public key rather than passed in, so the + * signature scheme always matches the key the transaction was crafted for. + */ +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/transaction/craftTransaction.parity.test.ts b/libs/coin-modules/coin-near/src/logic/transaction/craftTransaction.parity.test.ts new file mode 100644 index 000000000000..455efe2c5263 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/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/transaction/craftTransaction.test.ts b/libs/coin-modules/coin-near/src/logic/transaction/craftTransaction.test.ts new file mode 100644 index 000000000000..1a9c94875d02 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/craftTransaction.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/transaction/craftTransaction.ts b/libs/coin-modules/coin-near/src/logic/transaction/craftTransaction.ts new file mode 100644 index 000000000000..0b67adff5c7d --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/craftTransaction.ts @@ -0,0 +1,117 @@ +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 operation in two vocabularies: `mode`, from the delegation set, + * which it only fills in when the transaction carries a validator address or id, and `type`, which + * it always sets. 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 account the transaction is addressed to. For staking that is + * the pool contract, which the framework carries as `valAddress` when the transaction has one and + * as the recipient otherwise. + * + * An absent target is returned as an empty string rather than raised: the framework prices and + * validates a transaction while the user is still filling the form, and only crafting genuinely + * needs the target. + */ +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 || "" }; +} + +/** + * Craft an unsigned transaction, Borsh-encoded and base64'd, which is what {@link combine} takes + * back and what the device signs. + * + * The sender public key is required: a NEAR transaction embeds it, and the nonce is scoped to that + * key's access key rather than to the account, so it cannot be resolved from the address alone. + * That is also why `getNextSequence` is not implemented; the nonce is fetched here. + */ +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/transaction/estimateFees.test.ts b/libs/coin-modules/coin-near/src/logic/transaction/estimateFees.test.ts new file mode 100644 index 000000000000..cf17eb1c5f72 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/estimateFees.test.ts @@ -0,0 +1,163 @@ +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), +}; + +/** 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/transaction/estimateFees.ts b/libs/coin-modules/coin-near/src/logic/transaction/estimateFees.ts new file mode 100644 index 000000000000..4c3d7a0fdb16 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/estimateFees.ts @@ -0,0 +1,65 @@ +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 "../staking/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. + * + * The costs come from the protocol config rather than from preloaded data: nothing preloads on this + * code path, and the preload defaults are zeros, which would silently yield a fee of zero. + * + * A missing recipient yields a zero estimate instead of an error, so a form can be priced while the + * user is still filling it 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/transaction/validateIntent.test.ts b/libs/coin-modules/coin-near/src/logic/transaction/validateIntent.test.ts new file mode 100644 index 000000000000..dd0f8e3f9494 --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/validateIntent.test.ts @@ -0,0 +1,340 @@ +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"), + }); + (fetchAccountDetails as jest.Mock).mockResolvedValue({ amount: "1", storage_usage: 182 }); + }); + + 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"); + }); + }); + + 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/transaction/validateIntent.ts b/libs/coin-modules/coin-near/src/logic/transaction/validateIntent.ts new file mode 100644 index 000000000000..a7d3ff30ef8e --- /dev/null +++ b/libs/coin-modules/coin-near/src/logic/transaction/validateIntent.ts @@ -0,0 +1,215 @@ +import type { + Balance, + FeeEstimation, + TransactionValidation, +} from "@ledgerhq/coin-module-framework/api/index"; +import { formatCurrencyUnit } from "@ledgerhq/coin-module-framework/currencies/index"; +import { getCryptoCurrencyById } from "@ledgerhq/ledger-wallet-framework/currencies"; +import { + AmountRequired, + InvalidAddress, + InvalidAddressBecauseDestinationIsAlsoSource, + NotEnoughBalance, + RecipientRequired, +} from "@ledgerhq/ledger-wallet-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 } from "../../network"; +import { pooledAmount } from "../staking/pooledAmount"; +import { getYoctoThreshold, isImplicitAccount, isValidAddress } from "../../logic"; +import { resolveTarget, type NearIntent } from "./craftTransaction"; + +const STAKING_MODES = new Set(["stake", "unstake", "withdraw"]); + +/** Errors carry a human-readable amount, which their i18n message interpolates. */ +function formatNear(value: BigNumber): string { + return formatCurrencyUnit(getCryptoCurrencyById("near").units[0], 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); +} + +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)) { + const currencyName = getCryptoCurrencyById("near").name; + return { ...unusable, error: new InvalidAddress("", { currencyName }) }; + } + + const { storageCost: costPerByte } = await getActionCosts(); + const storageCost = costPerByte.multipliedBy(NEW_ACCOUNT_SIZE); + 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 && balances.some(b => b.stake !== undefined)) { + warnings.amount = new NearRecommendUnstake(); + } + + return { errors, warnings, estimatedFees, amount, totalSpent }; +} + +/** + * The most a staking operation can move. Unstaking and withdrawing move already-delegated funds, so + * only the fee comes out of the liquid balance; staking spends it. + */ +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 }; +} + +/** + * Validate 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..9f11872f86d6 --- /dev/null +++ b/libs/coin-modules/coin-near/src/network/getBlock.ts @@ -0,0 +1,45 @@ +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; the chunk list in + * the response is ignored, since reading a block's transactions would require 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..8008789726c0 --- /dev/null +++ b/libs/coin-modules/coin-near/src/network/index.ts @@ -0,0 +1,13 @@ +export { getOperations, fetchTransactionsPage } from "./indexer"; +export { getBlockHeaderAtHeight, getLastBlockHeader } from "./getBlock"; +export { getActionCosts, type NearActionCosts } from "./protocolConfig"; +export { + getAccount, + fetchAccountDetails, + getAccessKey, + getGasPrice, + getProtocolConfig, + 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 100% rename from libs/coin-modules/coin-near/src/api/node.test.ts rename to libs/coin-modules/coin-near/src/network/node.test.ts diff --git a/libs/coin-modules/coin-near/src/api/node.ts b/libs/coin-modules/coin-near/src/network/node.ts similarity index 100% rename from libs/coin-modules/coin-near/src/api/node.ts rename to libs/coin-modules/coin-near/src/network/node.ts 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..dd5d18b5fadb --- /dev/null +++ b/libs/coin-modules/coin-near/src/network/protocolConfig.test.ts @@ -0,0 +1,71 @@ +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("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..54279499c72d --- /dev/null +++ b/libs/coin-modules/coin-near/src/network/protocolConfig.ts @@ -0,0 +1,54 @@ +import { makeLRUCache } from "@ledgerhq/live-network/cache"; +import { BigNumber } from "bignumber.js"; +import { NearProtocolConfigNotLoaded } from "../errors"; +import { getProtocolConfig } from "./node"; + +/** + * Per-action gas costs and the storage price, derived from the protocol config. + * + * The account bridge gets these from preloaded data; callers that run outside a bridge sync (the + * CoinModuleApi surface) have no preload step, so they read them here instead. Cached because the + * protocol config only changes with a protocol upgrade. + */ +export type NearActionCosts = { + storageCost: BigNumber; + createAccountCostSend: BigNumber; + createAccountCostExecution: BigNumber; + transferCostSend: BigNumber; + transferCostExecution: BigNumber; + addKeyCostSend: BigNumber; + addKeyCostExecution: BigNumber; + receiptCreationSend: BigNumber; + receiptCreationExecution: BigNumber; +}; + +const fetchActionCosts = async (): Promise => { + const protocolConfig = await getProtocolConfig(); + + 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; + + 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), + }; +}; + +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 93% 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..684a6f97a088 100644 --- a/libs/coin-modules/coin-near/src/api/sdk.types.ts +++ b/libs/coin-modules/coin-near/src/network/sdk.types.ts @@ -35,6 +35,13 @@ 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; diff --git a/libs/coin-modules/coin-near/src/preload.ts b/libs/coin-modules/coin-near/src/preload.ts index 71c27521dbd0..f70e90c7c311 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"; @@ -58,45 +59,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..a69f2eca727f 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"; diff --git a/libs/ledger-live-common/.unimportedrc.json b/libs/ledger-live-common/.unimportedrc.json index 82433574be2a..d6011c478c0d 100644 --- a/libs/ledger-live-common/.unimportedrc.json +++ b/libs/ledger-live-common/.unimportedrc.json @@ -777,6 +777,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 615ff5444926..1b0c1d5dad5a 100644 --- a/libs/ledger-live-common/src/coin-modules/loaders.ts +++ b/libs/ledger-live-common/src/coin-modules/loaders.ts @@ -301,6 +301,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; +}