diff --git a/.github/workflows/bot-solana-silicium.yml b/.github/workflows/bot-solana-silicium.yml new file mode 100644 index 0000000000..0ac7d1bfa4 --- /dev/null +++ b/.github/workflows/bot-solana-silicium.yml @@ -0,0 +1,79 @@ +name: Bot 'Solana on Silicium' +on: + push: + branches: + - family/solana + +jobs: + start-runner: + name: "start ec2 instance (Linux)" + if: ${{ always() }} + uses: ledgerhq/actions/.github/workflows/start-linux-runner.yml@main + secrets: + CI_BOT_TOKEN: ${{ secrets.CI_BOT_TOKEN }} + + stop-runner: + name: "stop ec2 instance (Linux)" + needs: [start-runner, run-bot] + uses: ledgerhq/actions/.github/workflows/stop-linux-runner.yml@main + if: ${{ always() }} + with: + label: ${{ needs.start-runner.outputs.label }} + ec2-instance-id: ${{ needs.start-runner.outputs.ec2-instance-id }} + secrets: + CI_BOT_TOKEN: ${{ secrets.CI_BOT_TOKEN }} + + run-bot: + needs: [start-runner] + runs-on: ${{ needs.start-runner.outputs.label }} + steps: + - name: prepare runner + run: | + sudo growpart /dev/nvme0n1 1 + sudo resize2fs /dev/nvme0n1p1 + - uses: actions/checkout@v2 + - name: Retrieving coin apps + uses: actions/checkout@v2 + with: + repository: LedgerHQ/coin-apps + token: ${{ secrets.PAT }} + path: coin-apps + - uses: actions/setup-node@master + with: + node-version: 14.x + - name: install yarn + run: npm i -g yarn + - name: pull docker image + run: docker pull ghcr.io/ledgerhq/speculos + - name: kill apt-get + run: sudo killall -w apt-get apt || echo OK + - name: Install linux deps + run: sudo apt-get install -y libusb-1.0-0-dev jq + - name: Install dependencies + run: | + yarn global add yalc + yarn --frozen-lockfile + yarn ci-setup-cli + - name: BOT + env: + SEED: ${{ secrets.SEED3 }} + BOT_REPORT_FOLDER: botreport + VERBOSE_FILE: botreport/logs.txt + GITHUB_SHA: ${GITHUB_SHA} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_RUN_ID: ${{ github.run_id }} + GITHUB_WORKFLOW: ${{ github.workflow }} + SLACK_API_TOKEN: ${{ secrets.SLACK_API_TOKEN }} + SLACK_CHANNEL: ci-sol-ll + BOT_FILTER_FAMILY: solana + run: mkdir botreport; COINAPPS=$PWD/coin-apps yarn ci-test-bot + timeout-minutes: 120 + - name: Run coverage + if: failure() || success() + run: CODECOV_TOKEN=${{ secrets.CODECOV_TOKEN }} npx codecov + - name: upload logs + if: failure() || success() + uses: actions/upload-artifact@v1 + with: + name: botreport + path: botreport/ diff --git a/src/account/serialization.ts b/src/account/serialization.ts index d5cfe0b8cb..b1029d9115 100644 --- a/src/account/serialization.ts +++ b/src/account/serialization.ts @@ -48,6 +48,12 @@ import { toCryptoOrgResourcesRaw, fromCryptoOrgResourcesRaw, } from "../families/crypto_org/serialization"; + +import { + toSolanaResourcesRaw, + fromSolanaResourcesRaw, +} from "../families/solana/serialization"; + import { getCryptoCurrencyById, getTokenById, @@ -68,6 +74,7 @@ export { toPolkadotResourcesRaw, fromPolkadotResourcesRaw }; export { toTezosResourcesRaw, fromTezosResourcesRaw }; export { toElrondResourcesRaw, fromElrondResourcesRaw }; export { toCryptoOrgResourcesRaw, fromCryptoOrgResourcesRaw }; +export { toSolanaResourcesRaw, fromSolanaResourcesRaw }; export function toBalanceHistoryRaw(b: BalanceHistory): BalanceHistoryRaw { return b.map(({ date, value }) => [date.toISOString(), value.toString()]); @@ -707,6 +714,7 @@ export function fromAccountRaw(rawAccount: AccountRaw): Account { polkadotResources, elrondResources, cryptoOrgResources, + solanaResources, nfts, } = rawAccount; const subAccounts = @@ -828,6 +836,10 @@ export function fromAccountRaw(rawAccount: AccountRaw): Account { res.cryptoOrgResources = fromCryptoOrgResourcesRaw(cryptoOrgResources); } + if (solanaResources) { + res.solanaResources = fromSolanaResourcesRaw(solanaResources); + } + return res; } export function toAccountRaw({ @@ -866,6 +878,7 @@ export function toAccountRaw({ polkadotResources, elrondResources, cryptoOrgResources, + solanaResources, nfts, }: Account): AccountRaw { const res: AccountRaw = { @@ -946,6 +959,11 @@ export function toAccountRaw({ if (cryptoOrgResources) { res.cryptoOrgResources = toCryptoOrgResourcesRaw(cryptoOrgResources); } + + if (solanaResources) { + res.solanaResources = toSolanaResourcesRaw(solanaResources); + } + return res; } diff --git a/src/apps/support.ts b/src/apps/support.ts index 1b2bb7e865..14257985b4 100644 --- a/src/apps/support.ts +++ b/src/apps/support.ts @@ -32,6 +32,7 @@ const appVersionsRequired = { Polkadot: ">= 11.9170.0", Elrond: ">= 1.0.11", Ethereum: ">= 1.9.17", + Solana: ">= 1.2.0", }; export function mustUpgrade( deviceModel: DeviceModelId, diff --git a/src/env.ts b/src/env.ts index 92b8d30f41..6f9f3f1eff 100644 --- a/src/env.ts +++ b/src/env.ts @@ -135,6 +135,11 @@ const envDefinitions = { def: "https://solana.coin.ledger.com", desc: "proxy url for solana API", }, + SOLANA_VALIDATORS_APP_BASE_URL: { + parser: stringParser, + def: "http://validators-solana.coin.ledger.com/api/v1/validators/", + desc: "base url for validators.app validator list", + }, BASE_SOCKET_URL: { def: "wss://scriptrunner.api.live.ledger.com/update", parser: stringParser, diff --git a/src/families/bitcoin/__snapshots__/bridge.test.ts.snap b/src/families/bitcoin/__snapshots__/bridge.test.ts.snap index 8c67b448ac..2ab7f45e6e 100644 --- a/src/families/bitcoin/__snapshots__/bridge.test.ts.snap +++ b/src/families/bitcoin/__snapshots__/bridge.test.ts.snap @@ -22955,6 +22955,77 @@ Array [ "type": "IN", "value": "1000000", }, + ], + Array [], +] +`; + +exports[`solana currency bridge scanAccounts solana seed 1 1`] = ` +Array [ + Object { + "balance": "83389840", + "currencyId": "solana", + "derivationMode": "solanaMain", + "freshAddress": "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", + "freshAddressPath": "44'/501'", + "freshAddresses": Array [ + Object { + "address": "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", + "derivationPath": "44'/501'", + }, + ], + "id": "js:2:solana:AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh:solanaMain", + "index": 0, + "name": "Solana 1", + "nfts": undefined, + "operationsCount": 2, + "pendingOperations": Array [], + "seedIdentifier": "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", + "solanaResources": Object { + "stakes": "[]", + }, + "spendableBalance": "83389840", + "starred": false, + "swapHistory": Array [], + "syncHash": undefined, + "unitMagnitude": 9, + "used": true, + }, + Object { + "balance": "0", + "currencyId": "solana", + "derivationMode": "solanaSub", + "freshAddress": "6rEgdtB3sgjKJnRE172YEr9z6qUyr4nFW28vJokuD36A", + "freshAddressPath": "44'/501'/0'", + "freshAddresses": Array [ + Object { + "address": "6rEgdtB3sgjKJnRE172YEr9z6qUyr4nFW28vJokuD36A", + "derivationPath": "44'/501'/0'", + }, + ], + "id": "js:2:solana:6rEgdtB3sgjKJnRE172YEr9z6qUyr4nFW28vJokuD36A:solanaSub", + "index": 0, + "name": "Solana 1", + "nfts": undefined, + "operationsCount": 0, + "pendingOperations": Array [], + "seedIdentifier": "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", + "solanaResources": Object { + "stakes": "[]", + }, + "spendableBalance": "0", + "starred": false, + "swapHistory": Array [], + "syncHash": undefined, + "unitMagnitude": 9, + "used": false, + }, +] +`; + +exports[`solana currency bridge scanAccounts solana seed 1 2`] = ` +Array [ + Array [ Object { "accountId": "js:2:qtum:xpub6D97ABLAcapXNWjS2pNwYpmUjYYt5f2Tyj4PDSp7pvY2gySb2iAvejKNPm18raeU8WxtXVCpQfZjMN7eEdgtor8T5141ZLH7o2WkL1nyNQb:segwit", "blockHash": "eb38de048b4f1a0b52cafb7ee7a6708bd02ccf59c77cadd3b0d078652e785abd", diff --git a/src/families/solana/api/cached.ts b/src/families/solana/api/cached.ts index 923cdb7726..db3a821e05 100644 --- a/src/families/solana/api/cached.ts +++ b/src/families/solana/api/cached.ts @@ -17,6 +17,8 @@ const cacheKeyAddress = (address: string) => address; const cacheKeyEmpty = () => "" as const; const cacheKeyAssocTokenAccAddress = (owner: string, mint: string) => `${owner}:${mint}`; +const cacheKeyMinimumBalanceForRentExemption = (dataLengt: number) => + dataLengt.toString(); const cacheKeyTransactions = (signatures: string[]) => hash([...signatures].sort()); @@ -63,6 +65,36 @@ export function cached(api: ChainAPI): ChainAPI { minutes(1) ), + getStakeAccountsByStakeAuth: makeLRUCache( + api.getStakeAccountsByStakeAuth, + cacheKeyAddress, + minutes(1) + ), + + getStakeAccountsByWithdrawAuth: makeLRUCache( + api.getStakeAccountsByWithdrawAuth, + cacheKeyAddress, + minutes(1) + ), + + getStakeActivation: makeLRUCache( + api.getStakeActivation, + cacheKeyAddress, + minutes(1) + ), + + getInflationReward: makeLRUCache( + api.getInflationReward, + cacheKeyByArgs, + minutes(5) + ), + + getVoteAccounts: makeLRUCache( + api.getVoteAccounts, + cacheKeyEmpty, + minutes(1) + ), + getRecentBlockhash: makeLRUCache( api.getRecentBlockhash, cacheKeyEmpty, @@ -81,9 +113,17 @@ export function cached(api: ChainAPI): ChainAPI { seconds(30) ), + getMinimumBalanceForRentExemption: makeLRUCache( + api.getMinimumBalanceForRentExemption, + cacheKeyMinimumBalanceForRentExemption, + minutes(5) + ), + // do not cache sendRawTransaction: api.sendRawTransaction, + getEpochInfo: makeLRUCache(api.getEpochInfo, cacheKeyEmpty, minutes(1)), + config: api.config, }; } diff --git a/src/families/solana/api/chain/account/index.ts b/src/families/solana/api/chain/account/index.ts index 0e431b0a75..5cf5e7785b 100644 --- a/src/families/solana/api/chain/account/index.ts +++ b/src/families/solana/api/chain/account/index.ts @@ -1 +1,5 @@ -export { tryParseAsTokenAccount, parseTokenAccountInfo } from "./parser"; +export { + tryParseAsTokenAccount, + parseTokenAccountInfo, + tryParseAsVoteAccount, +} from "./parser"; diff --git a/src/families/solana/api/chain/account/parser.ts b/src/families/solana/api/chain/account/parser.ts index 3cd8f31f54..dfed1b671f 100644 --- a/src/families/solana/api/chain/account/parser.ts +++ b/src/families/solana/api/chain/account/parser.ts @@ -1,7 +1,10 @@ import { ParsedAccountData } from "@solana/web3.js"; import { create } from "superstruct"; +import { PARSED_PROGRAMS } from "../program/constants"; import { ParsedInfo } from "../validators"; +import { StakeAccountInfo } from "./stake"; import { TokenAccount, TokenAccountInfo } from "./token"; +import { VoteAccount, VoteAccountInfo } from "./vote"; export function parseTokenAccountInfo(info: unknown): TokenAccountInfo { return create(info, TokenAccountInfo); @@ -26,6 +29,31 @@ export function tryParseAsTokenAccount( return onThrowReturnError(routine); } +export function parseVoteAccountInfo(info: unknown): VoteAccountInfo { + return create(info, VoteAccountInfo); +} + +export function tryParseAsVoteAccount( + data: ParsedAccountData +): VoteAccountInfo | undefined | Error { + const routine = () => { + const info = create(data.parsed, ParsedInfo); + + if (data.program === PARSED_PROGRAMS.VOTE) { + const parsed = create(info, VoteAccount); + return parseVoteAccountInfo(parsed.info); + } + + return undefined; + }; + + return onThrowReturnError(routine); +} + +export function parseStakeAccountInfo(info: unknown): StakeAccountInfo { + return create(info, StakeAccountInfo); +} + function onThrowReturnError(fn: () => R) { try { return fn(); diff --git a/src/families/solana/api/chain/account/vote.ts b/src/families/solana/api/chain/account/vote.ts new file mode 100644 index 0000000000..594679d01d --- /dev/null +++ b/src/families/solana/api/chain/account/vote.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-redeclare */ + +import { + Infer, + enums, + number, + array, + type, + nullable, + string, +} from "superstruct"; +import { PublicKeyFromString } from "../validators/pubkey"; + +export type VoteAccountType = Infer; +export const VoteAccountType = enums(["vote"]); + +export type AuthorizedVoter = Infer; +export const AuthorizedVoter = type({ + authorizedVoter: PublicKeyFromString, + epoch: number(), +}); + +export type PriorVoter = Infer; +export const PriorVoter = type({ + authorizedPubkey: PublicKeyFromString, + epochOfLastAuthorizedSwitch: number(), + targetEpoch: number(), +}); + +export type EpochCredits = Infer; +export const EpochCredits = type({ + epoch: number(), + credits: string(), + previousCredits: string(), +}); + +export type Vote = Infer; +export const Vote = type({ + slot: number(), + confirmationCount: number(), +}); + +export type VoteAccountInfo = Infer; +export const VoteAccountInfo = type({ + authorizedVoters: array(AuthorizedVoter), + authorizedWithdrawer: PublicKeyFromString, + commission: number(), + epochCredits: array(EpochCredits), + lastTimestamp: type({ + slot: number(), + timestamp: number(), + }), + nodePubkey: PublicKeyFromString, + priorVoters: array(PriorVoter), + rootSlot: nullable(number()), + votes: array(Vote), +}); + +export type VoteAccount = Infer; +export const VoteAccount = type({ + type: VoteAccountType, + info: VoteAccountInfo, +}); diff --git a/src/families/solana/api/chain/index.ts b/src/families/solana/api/chain/index.ts index 51f208bfe6..b54b6e08d0 100644 --- a/src/families/solana/api/chain/index.ts +++ b/src/families/solana/api/chain/index.ts @@ -10,6 +10,7 @@ import { PublicKey, sendAndConfirmRawTransaction, SignaturesForAddressOptions, + StakeProgram, } from "@solana/web3.js"; import { Awaited } from "../../logic"; @@ -32,6 +33,24 @@ export type ChainAPI = Readonly<{ address: string ) => ReturnType; + getStakeAccountsByStakeAuth: ( + authAddr: string + ) => ReturnType; + + getStakeAccountsByWithdrawAuth: ( + authAddr: string + ) => ReturnType; + + getStakeActivation: ( + stakeAccAddr: string + ) => ReturnType; + + getInflationReward: ( + addresses: string[] + ) => ReturnType; + + getVoteAccounts: () => ReturnType; + getSignaturesForAddress: ( address: string, opts?: SignaturesForAddressOptions @@ -55,6 +74,10 @@ export type ChainAPI = Readonly<{ getAssocTokenAccMinNativeBalance: () => Promise; + getMinimumBalanceForRentExemption: (dataLength: number) => Promise; + + getEpochInfo: () => ReturnType; + config: Config; }>; @@ -98,6 +121,41 @@ export function getChainAPI( connection().getParsedTokenAccountsByOwner(new PublicKey(address), { programId: TOKEN_PROGRAM_ID, }), + + getStakeAccountsByStakeAuth: (authAddr: string) => + connection().getParsedProgramAccounts(StakeProgram.programId, { + filters: [ + { + memcmp: { + offset: 12, + bytes: authAddr, + }, + }, + ], + }), + + getStakeAccountsByWithdrawAuth: (authAddr: string) => + connection().getParsedProgramAccounts(StakeProgram.programId, { + filters: [ + { + memcmp: { + offset: 44, + bytes: authAddr, + }, + }, + ], + }), + + getStakeActivation: (stakeAccAddr: string) => + connection().getStakeActivation(new PublicKey(stakeAccAddr)), + + getInflationReward: (addresses: string[]) => + connection().getInflationReward( + addresses.map((addr) => new PublicKey(addr)) + ), + + getVoteAccounts: () => connection().getVoteAccounts(), + getSignaturesForAddress: ( address: string, opts?: SignaturesForAddressOptions @@ -128,6 +186,11 @@ export function getChainAPI( getAssocTokenAccMinNativeBalance: () => Token.getMinBalanceRentForExemptAccount(connection()), + getMinimumBalanceForRentExemption: (dataLength: number) => + connection().getMinimumBalanceForRentExemption(dataLength), + + getEpochInfo: () => connection().getEpochInfo(), + config, }; } diff --git a/src/families/solana/api/chain/instruction/system/index.ts b/src/families/solana/api/chain/instruction/system/index.ts new file mode 100644 index 0000000000..a2ac9bffae --- /dev/null +++ b/src/families/solana/api/chain/instruction/system/index.ts @@ -0,0 +1,30 @@ +import { ParsedInstruction } from "@solana/web3.js"; +import { IX_STRUCTS, IX_TITLES, SystemInstructionType } from "./types"; + +import { ParsedInfo } from "../../validators"; +import { create, Infer } from "superstruct"; +import { PARSED_PROGRAMS } from "../../program/constants"; + +export function parseSystemInstruction( + ix: ParsedInstruction & { program: typeof PARSED_PROGRAMS.SYSTEM } +): SystemInstructionDescriptor { + const parsed = create(ix.parsed, ParsedInfo); + const { type: rawType, info } = parsed; + const type = create(rawType, SystemInstructionType); + const title = IX_TITLES[type]; + const struct = IX_STRUCTS[type]; + + return { + type, + title: title as any, + info: create(info, struct as any) as any, + }; +} + +export type SystemInstructionDescriptor = { + [K in SystemInstructionType]: { + title: typeof IX_TITLES[K]; + type: K; + info: Infer; + }; +}[SystemInstructionType]; diff --git a/src/families/solana/api/chain/instruction/system/types.ts b/src/families/solana/api/chain/instruction/system/types.ts new file mode 100644 index 0000000000..9f13b4eaf7 --- /dev/null +++ b/src/families/solana/api/chain/instruction/system/types.ts @@ -0,0 +1,141 @@ +import { enums, number, type, string, Infer } from "superstruct"; +import { PublicKeyFromString } from "../../validators/pubkey"; + +export type CreateAccountInfo = Infer; +export const CreateAccountInfo = type({ + source: PublicKeyFromString, + newAccount: PublicKeyFromString, + lamports: number(), + space: number(), + owner: PublicKeyFromString, +}); + +export type AssignInfo = Infer; +export const AssignInfo = type({ + account: PublicKeyFromString, + owner: PublicKeyFromString, +}); + +export type TransferInfo = Infer; +export const TransferInfo = type({ + source: PublicKeyFromString, + destination: PublicKeyFromString, + lamports: number(), +}); + +export type CreateAccountWithSeedInfo = Infer; +export const CreateAccountWithSeedInfo = type({ + source: PublicKeyFromString, + newAccount: PublicKeyFromString, + base: PublicKeyFromString, + seed: string(), + lamports: number(), + space: number(), + owner: PublicKeyFromString, +}); + +export type AdvanceNonceInfo = Infer; +export const AdvanceNonceInfo = type({ + nonceAccount: PublicKeyFromString, + nonceAuthority: PublicKeyFromString, +}); + +export type WithdrawNonceInfo = Infer; +export const WithdrawNonceInfo = type({ + nonceAccount: PublicKeyFromString, + destination: PublicKeyFromString, + nonceAuthority: PublicKeyFromString, + lamports: number(), +}); + +export type InitializeNonceInfo = Infer; +export const InitializeNonceInfo = type({ + nonceAccount: PublicKeyFromString, + nonceAuthority: PublicKeyFromString, +}); + +export type AuthorizeNonceInfo = Infer; +export const AuthorizeNonceInfo = type({ + nonceAccount: PublicKeyFromString, + nonceAuthority: PublicKeyFromString, + newAuthorized: PublicKeyFromString, +}); + +export type AllocateInfo = Infer; +export const AllocateInfo = type({ + account: PublicKeyFromString, + space: number(), +}); + +export type AllocateWithSeedInfo = Infer; +export const AllocateWithSeedInfo = type({ + account: PublicKeyFromString, + base: PublicKeyFromString, + seed: string(), + space: number(), + owner: PublicKeyFromString, +}); + +export type AssignWithSeedInfo = Infer; +export const AssignWithSeedInfo = type({ + account: PublicKeyFromString, + base: PublicKeyFromString, + seed: string(), + owner: PublicKeyFromString, +}); + +export type TransferWithSeedInfo = Infer; +export const TransferWithSeedInfo = type({ + source: PublicKeyFromString, + sourceBase: PublicKeyFromString, + destination: PublicKeyFromString, + lamports: number(), + sourceSeed: string(), + sourceOwner: PublicKeyFromString, +}); + +export type SystemInstructionType = Infer; +export const SystemInstructionType = enums([ + "createAccount", + "createAccountWithSeed", + "allocate", + "allocateWithSeed", + "assign", + "assignWithSeed", + "transfer", + "advanceNonce", + "withdrawNonce", + "authorizeNonce", + "initializeNonce", + "transferWithSeed", +]); + +export const IX_STRUCTS = { + createAccount: CreateAccountInfo, + createAccountWithSeed: CreateAccountWithSeedInfo, + allocate: AllocateInfo, + allocateWithSeed: AllocateWithSeedInfo, + assign: AssignInfo, + assignWithSeed: AssignWithSeedInfo, + transfer: TransferInfo, + advanceNonce: AdvanceNonceInfo, + withdrawNonce: WithdrawNonceInfo, + authorizeNonce: AuthorizeNonceInfo, + initializeNonce: InitializeNonceInfo, + transferWithSeed: TransferWithSeedInfo, +}; + +export const IX_TITLES = { + createAccount: "Create Account", + createAccountWithSeed: "Create Account With Seed", + allocate: "Allocate", + allocateWithSeed: "Allocate With Seed", + assign: "Assign", + assignWithSeed: "Assign With Seed", + transfer: "Transfer", + advanceNonce: "Advance Nonce", + withdrawNonce: "Withdraw Nonce", + authorizeNonce: "Authorize Nonce", + initializeNonce: "Initialize Nonce", + transferWithSeed: "Transfer With Seed", +}; diff --git a/src/families/solana/api/chain/program/parser.ts b/src/families/solana/api/chain/program/parser.ts index 54832a95fb..9e2b302f77 100644 --- a/src/families/solana/api/chain/program/parser.ts +++ b/src/families/solana/api/chain/program/parser.ts @@ -19,8 +19,17 @@ import { parseStakeInstruction, StakeInstructionDescriptor, } from "../instruction/stake"; +import { + parseSystemInstruction, + SystemInstructionDescriptor, +} from "../instruction/system"; type ParsedProgram = + | { + program: "system"; + title: string; + instruction: SystemInstructionDescriptor; + } | { program: "spl-associated-token-account"; title: string; @@ -55,6 +64,15 @@ export const parse = ( ix.program as any; switch (program) { + case "system": + return { + program, + title: "System", + instruction: parseSystemInstruction({ + ...ix, + program, + }), + }; case "spl-associated-token-account": return { program, diff --git a/src/families/solana/api/chain/web3.ts b/src/families/solana/api/chain/web3.ts index ad645e86b7..e61cf9be47 100644 --- a/src/families/solana/api/chain/web3.ts +++ b/src/families/solana/api/chain/web3.ts @@ -6,9 +6,15 @@ import { ConfirmedSignatureInfo, ParsedConfirmedTransaction, TransactionInstruction, + StakeProgram, } from "@solana/web3.js"; import { chunk } from "lodash"; import { + StakeCreateAccountCommand, + StakeDelegateCommand, + StakeSplitCommand, + StakeUndelegateCommand, + StakeWithdrawCommand, TokenCreateATACommand, TokenTransferCommand, TransferCommand, @@ -18,11 +24,18 @@ import { ASSOCIATED_TOKEN_PROGRAM_ID, Token, } from "@solana/spl-token"; -import { tryParseAsTokenAccount, parseTokenAccountInfo } from "./account"; +import { + tryParseAsTokenAccount, + parseTokenAccountInfo, + tryParseAsVoteAccount, +} from "./account"; import { TokenAccountInfo } from "./account/token"; import { drainSeqAsyncGen } from "../../utils"; import { Awaited } from "../../logic"; import { ChainAPI } from "."; +import { VoteAccountInfo } from "./account/vote"; +import { parseStakeAccountInfo } from "./account/parser"; +import { StakeAccountInfo } from "./account/stake"; const MEMO_PROGRAM_ID = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; @@ -30,11 +43,20 @@ type ParsedOnChainTokenAccount = Awaited< ReturnType >["value"][number]; +type ParsedOnChainStakeAccount = Awaited< + ReturnType +>[number]; + export type ParsedOnChainTokenAccountWithInfo = { onChainAcc: ParsedOnChainTokenAccount; info: TokenAccountInfo; }; +export type ParsedOnChainStakeAccountWithInfo = { + onChainAcc: ParsedOnChainStakeAccount; + info: StakeAccountInfo; +}; + export function toTokenAccountWithInfo( onChainAcc: ParsedOnChainTokenAccount ): ParsedOnChainTokenAccountWithInfo { @@ -43,6 +65,17 @@ export function toTokenAccountWithInfo( return { onChainAcc, info }; } +export function toStakeAccountWithInfo( + onChainAcc: ParsedOnChainStakeAccount +): ParsedOnChainStakeAccountWithInfo | undefined { + if ("parsed" in onChainAcc.account.data) { + const parsedInfo = onChainAcc.account.data.parsed.info; + const info = parseStakeAccountInfo(parsedInfo); + return { onChainAcc, info }; + } + return undefined; +} + export type TransactionDescriptor = { parsed: ParsedConfirmedTransaction; info: ConfirmedSignatureInfo; @@ -234,6 +267,39 @@ export const getMaybeTokenAccount = async ( return tokenAccount; }; +export async function getMaybeVoteAccount( + address: string, + api: ChainAPI +): Promise { + const accInfo = await api.getAccountInfo(address); + const voteAccount = + accInfo !== null && "parsed" in accInfo.data + ? tryParseAsVoteAccount(accInfo.data) + : undefined; + + return voteAccount; +} + +export function getStakeAccountMinimumBalanceForRentExemption(api: ChainAPI) { + return api.getMinimumBalanceForRentExemption(StakeProgram.space); +} + +export async function getStakeAccountAddressWithSeed({ + fromAddress, + seed, +}: { + fromAddress: string; + seed: string; +}) { + const pubkey = await PublicKey.createWithSeed( + new PublicKey(fromAddress), + seed, + StakeProgram.programId + ); + + return pubkey.toBase58(); +} + export function buildCreateAssociatedTokenAccountInstruction({ mint, owner, @@ -256,3 +322,109 @@ export function buildCreateAssociatedTokenAccountInstruction({ return instructions; } + +export function buildStakeDelegateInstructions({ + authorizedAccAddr, + stakeAccAddr, + voteAccAddr, +}: StakeDelegateCommand): TransactionInstruction[] { + const tx = StakeProgram.delegate({ + authorizedPubkey: new PublicKey(authorizedAccAddr), + stakePubkey: new PublicKey(stakeAccAddr), + votePubkey: new PublicKey(voteAccAddr), + }); + + return tx.instructions; +} + +export function buildStakeUndelegateInstructions({ + authorizedAccAddr, + stakeAccAddr, +}: StakeUndelegateCommand): TransactionInstruction[] { + const tx = StakeProgram.deactivate({ + authorizedPubkey: new PublicKey(authorizedAccAddr), + stakePubkey: new PublicKey(stakeAccAddr), + }); + + return tx.instructions; +} + +export function buildStakeWithdrawInstructions({ + authorizedAccAddr, + stakeAccAddr, + amount, + toAccAddr, +}: StakeWithdrawCommand): TransactionInstruction[] { + const tx = StakeProgram.withdraw({ + authorizedPubkey: new PublicKey(authorizedAccAddr), + stakePubkey: new PublicKey(stakeAccAddr), + lamports: amount, + toPubkey: new PublicKey(toAccAddr), + }); + + return tx.instructions; +} + +export function buildStakeSplitInstructions({ + authorizedAccAddr, + stakeAccAddr, + seed, + amount, + splitStakeAccAddr, +}: StakeSplitCommand): TransactionInstruction[] { + // HACK: switch to split_with_seed when supported by @solana/web3.js + const splitIx = StakeProgram.split({ + authorizedPubkey: new PublicKey(authorizedAccAddr), + lamports: amount, + stakePubkey: new PublicKey(stakeAccAddr), + splitStakePubkey: new PublicKey(splitStakeAccAddr), + }).instructions[1]; + + if (splitIx === undefined) { + throw new Error("expected split instruction"); + } + + const allocateIx = SystemProgram.allocate({ + accountPubkey: new PublicKey(splitStakeAccAddr), + basePubkey: new PublicKey(authorizedAccAddr), + programId: StakeProgram.programId, + seed, + space: StakeProgram.space, + }); + + return [allocateIx, splitIx]; +} + +export function buildStakeCreateAccountInstructions({ + fromAccAddress, + stakeAccAddress, + seed, + amount, + stakeAccRentExemptAmount, + delegate, +}: StakeCreateAccountCommand): TransactionInstruction[] { + const fromPubkey = new PublicKey(fromAccAddress); + const stakePubkey = new PublicKey(stakeAccAddress); + + const tx = StakeProgram.createAccountWithSeed({ + fromPubkey, + stakePubkey, + basePubkey: fromPubkey, + seed, + lamports: amount + stakeAccRentExemptAmount, + authorized: { + staker: fromPubkey, + withdrawer: fromPubkey, + }, + }); + + tx.add( + StakeProgram.delegate({ + authorizedPubkey: fromPubkey, + stakePubkey, + votePubkey: new PublicKey(delegate.voteAccAddress), + }) + ); + + return tx.instructions; +} diff --git a/src/families/solana/api/logged.ts b/src/families/solana/api/logged.ts index 32be16cbbb..d315de7baa 100644 --- a/src/families/solana/api/logged.ts +++ b/src/families/solana/api/logged.ts @@ -1,6 +1,7 @@ import { ChainAPI } from "./chain"; //import fs from "fs"; +import { PublicKey } from "@solana/web3.js"; /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ function write(file: string, str: string) { @@ -24,10 +25,14 @@ export function logged(api: ChainAPI, file: string): ChainAPI { params: args, answer, }; + const publicKeytoJSON = PublicKey.prototype.toJSON; + // @ts-expect-error hack to temporary remove toJSON so it doesn't affect JSON.stringify + delete PublicKey.prototype.toJSON; const summaryJson = JSON.stringify(summary).replace( /{"_bn":(".*?")}/g, "new PublicKey(Buffer.from($1, 'hex'))" ); + PublicKey.prototype.toJSON = publicKeytoJSON; write(file, summaryJson + ",\n"); }; if (result instanceof Promise) { diff --git a/src/families/solana/bridge/bridge.ts b/src/families/solana/bridge/bridge.ts index aa9a28f4a9..f92835f937 100644 --- a/src/families/solana/bridge/bridge.ts +++ b/src/families/solana/bridge/bridge.ts @@ -9,6 +9,7 @@ import type { AccountBridge, AccountLike, BroadcastFnSignature, + CryptoCurrency, CurrencyBridge, SignOperationFnSignature, } from "../../../types"; @@ -20,6 +21,7 @@ import createTransaction, { updateTransaction } from "../js-createTransaction"; import { signOperationWithAPI } from "../js-signOperation"; import { broadcastWithAPI } from "../js-broadcast"; import { prepareTransaction as prepareTransactionWithAPI } from "../js-prepareTransaction"; +import { hydrate, preloadWithAPI } from "../js-preload"; import { ChainAPI, Config } from "../api"; import { makeLRUCache } from "../../../cache"; import { endpointByCurrencyId } from "../utils"; @@ -100,8 +102,16 @@ function makeEstimateMaxSpendable( return estimateMaxSpendableWithAPI(arg, api); } - const cacheKeyByAccBalance = ({ account }: { account: AccountLike }) => - `${account.id}:${account.balance.toString()}`; + const cacheKeyByAccBalance = ({ + account, + transaction, + }: { + account: AccountLike; + transaction?: Transaction | null; + }) => + `${account.id}:${account.balance.toString()}:tx:${ + transaction?.model.kind ?? "" + }`; return makeLRUCache(estimateMaxSpendable, cacheKeyByAccBalance, minutes(5)); } @@ -130,6 +140,19 @@ function makeSign( }; } +function makePreload( + getChainAPI: (config: Config) => Promise +): CurrencyBridge["preload"] { + const preload = (currency: CryptoCurrency): Promise> => { + const config: Config = { + endpoint: endpointByCurrencyId(currency.id), + }; + const api = () => getChainAPI(config); + return preloadWithAPI(currency, api); + }; + return preload; +} + export function makeBridges({ getAPI, getQueuedAPI, @@ -157,8 +180,8 @@ export function makeBridges({ }; const currencyBridge: CurrencyBridge = { - preload: async (): Promise => {}, - hydrate: (): void => {}, + preload: makePreload(getQueuedAndCachedAPI), + hydrate, scanAccounts: scan, }; diff --git a/src/families/solana/bridge/mock-data.ts b/src/families/solana/bridge/mock-data.ts index e4e437bcf2..b9be3b10b5 100644 --- a/src/families/solana/bridge/mock-data.ts +++ b/src/families/solana/bridge/mock-data.ts @@ -3,592 +3,23 @@ import { PublicKey } from "@solana/web3.js"; export const getMockedMethods = () => [ // generated - { - method: "getBalanceAndContext", - params: ["AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"], - answer: { context: { slot: 109865128 }, value: 83389840 }, - }, - { - method: "getSignaturesForAddress", - params: ["AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", { limit: 100 }], - answer: [ - { - blockTime: 1637781134, - confirmationStatus: "finalized", - err: null, - memo: null, - signature: - "A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk", - slot: 108521109, - }, - { - blockTime: 1637780906, - confirmationStatus: "finalized", - err: null, - memo: null, - signature: - "25KWBvKtVgKR3yoRmozTY6wmiW8atwrnzAnTXdsms8jqg5aR8GnCDxdJzWXtzMZPvbsE6SUuBkGFXudy2mrcTYna", - slot: 108520722, - }, - ], - }, - { - method: "getParsedConfirmedTransactions", - params: [ - [ - "A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk", - "25KWBvKtVgKR3yoRmozTY6wmiW8atwrnzAnTXdsms8jqg5aR8GnCDxdJzWXtzMZPvbsE6SUuBkGFXudy2mrcTYna", - ], - ], - answer: [ - { - blockTime: 1637781134, - meta: { - err: null, - fee: 5000, - innerInstructions: [ - { - index: 1, - instructions: [ - { - parsed: { - info: { - account: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - space: 165, - }, - type: "allocate", - }, - program: "system", - programId: new PublicKey(Buffer.from("00", "hex")), - }, - { - parsed: { - info: { - account: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - owner: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - }, - type: "assign", - }, - program: "system", - programId: new PublicKey(Buffer.from("00", "hex")), - }, - { - parsed: { - info: { - account: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - mint: "So11111111111111111111111111111111111111112", - owner: "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - rentSysvar: "SysvarRent111111111111111111111111111111111", - }, - type: "initializeAccount", - }, - program: "spl-token", - programId: new PublicKey( - Buffer.from( - "06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9", - "hex" - ) - ), - }, - ], - }, - ], - logMessages: [ - "Program 11111111111111111111111111111111 invoke [1]", - "Program 11111111111111111111111111111111 success", - "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]", - "Program log: Allocate space for the associated token account", - "Program 11111111111111111111111111111111 invoke [2]", - "Program 11111111111111111111111111111111 success", - "Program log: Assign the associated token account to the SPL Token program", - "Program 11111111111111111111111111111111 invoke [2]", - "Program 11111111111111111111111111111111 success", - "Program log: Initialize the associated token account", - "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", - "Program log: Instruction: InitializeAccount", - "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3683 of 183452 compute units", - "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", - "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 20880 of 200000 compute units", - "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success", - ], - postBalances: [ - 83389840, 10000000, 151314748907, 1, 1089991680, 1009200, 898174080, - ], - postTokenBalances: [ - { - accountIndex: 1, - mint: "So11111111111111111111111111111111111111112", - uiTokenAmount: { - amount: "7960720", - decimals: 9, - uiAmount: 0.00796072, - uiAmountString: "0.00796072", - }, - }, - ], - preBalances: [ - 93394840, 0, 151314748907, 1, 1089991680, 1009200, 898174080, - ], - preTokenBalances: [], - rewards: [], - status: { Ok: null }, - }, - slot: 108521109, - transaction: { - message: { - accountKeys: [ - { - pubkey: new PublicKey( - Buffer.from( - "8bc4d3e507c0550e3d02ffb5f6daf0772240af8a09e32d236615b4a227243702", - "hex" - ) - ), - signer: true, - writable: true, - }, - { - pubkey: new PublicKey( - Buffer.from( - "6e6279fa638560ce9c178033f5b88eacfb5fba6d46ec5902769f1b09eaabc017", - "hex" - ) - ), - signer: false, - writable: true, - }, - { - pubkey: new PublicKey( - Buffer.from( - "069b8857feab8184fb687f634618c035dac439dc1aeb3b5598a0f00000000001", - "hex" - ) - ), - signer: false, - writable: false, - }, - { - pubkey: new PublicKey(Buffer.from("00", "hex")), - signer: false, - writable: false, - }, - { - pubkey: new PublicKey( - Buffer.from( - "06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9", - "hex" - ) - ), - signer: false, - writable: false, - }, - { - pubkey: new PublicKey( - Buffer.from( - "06a7d517192c5c51218cc94c3d4af17f58daee089ba1fd44e3dbd98a00000000", - "hex" - ) - ), - signer: false, - writable: false, - }, - { - pubkey: new PublicKey( - Buffer.from( - "8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859", - "hex" - ) - ), - signer: false, - writable: false, - }, - ], - instructions: [ - { - parsed: { - info: { - destination: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - lamports: 10000000, - source: "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - }, - type: "transfer", - }, - program: "system", - programId: new PublicKey(Buffer.from("00", "hex")), - }, - { - parsed: { - info: { - account: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - mint: "So11111111111111111111111111111111111111112", - rentSysvar: "SysvarRent111111111111111111111111111111111", - source: "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - systemProgram: "11111111111111111111111111111111", - tokenProgram: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - wallet: "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - }, - type: "create", - }, - program: "spl-associated-token-account", - programId: new PublicKey( - Buffer.from( - "8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859", - "hex" - ) - ), - }, - ], - recentBlockhash: "9tPbgLaETEenufCt5SzXMuWijgFJj549W9j5cJLbaogn", - }, - signatures: [ - "A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk", - ], - }, - }, - { - blockTime: 1637780906, - meta: { - err: null, - fee: 5000, - innerInstructions: [], - logMessages: [ - "Program 11111111111111111111111111111111 invoke [1]", - "Program 11111111111111111111111111111111 success", - ], - postBalances: [0, 93394840, 1], - postTokenBalances: [], - preBalances: [93399840, 0, 1], - preTokenBalances: [], - rewards: [], - status: { Ok: null }, - }, - slot: 108520722, - transaction: { - message: { - accountKeys: [ - { - pubkey: new PublicKey( - Buffer.from( - "5c1c77c3d1e8edad4cfb2b2f7e4497d0d83f19e176713876a1d01eeb30a9bf3f", - "hex" - ) - ), - signer: true, - writable: true, - }, - { - pubkey: new PublicKey( - Buffer.from( - "8bc4d3e507c0550e3d02ffb5f6daf0772240af8a09e32d236615b4a227243702", - "hex" - ) - ), - signer: false, - writable: true, - }, - { - pubkey: new PublicKey(Buffer.from("00", "hex")), - signer: false, - writable: false, - }, - ], - instructions: [ - { - parsed: { - info: { - destination: "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - lamports: 93394840, - source: "7CZgkK494jMdoY8xpXY3ViLjpDGMbNikCzMtAT5cAjKk", - }, - type: "transfer", - }, - program: "system", - programId: new PublicKey(Buffer.from("00", "hex")), - }, - ], - recentBlockhash: "4NSL4VrfWd2eUccMD95dLQsdy5UGz8yhokpfH1et1R2c", - }, - signatures: [ - "25KWBvKtVgKR3yoRmozTY6wmiW8atwrnzAnTXdsms8jqg5aR8GnCDxdJzWXtzMZPvbsE6SUuBkGFXudy2mrcTYna", - ], - }, - }, - ], - }, - { - method: "getBalanceAndContext", - params: ["6rEgdtB3sgjKJnRE172YEr9z6qUyr4nFW28vJokuD36A"], - answer: { context: { slot: 109865129 }, value: 0 }, - }, - { - method: "getSignaturesForAddress", - params: ["6rEgdtB3sgjKJnRE172YEr9z6qUyr4nFW28vJokuD36A", { limit: 100 }], - answer: [], - }, - { - method: "getSignaturesForAddress", - params: [ - "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - { - until: - "25KWBvKtVgKR3yoRmozTY6wmiW8atwrnzAnTXdsms8jqg5aR8GnCDxdJzWXtzMZPvbsE6SUuBkGFXudy2mrcTYna", - limit: 100, - }, - ], - answer: [ - { - blockTime: 1637781134, - confirmationStatus: "finalized", - err: null, - memo: null, - signature: - "A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk", - slot: 108521109, - }, - ], - }, - { - method: "getParsedConfirmedTransactions", - params: [ - [ - "A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk", - ], - ], - answer: [ - { - blockTime: 1637781134, - meta: { - err: null, - fee: 5000, - innerInstructions: [ - { - index: 1, - instructions: [ - { - parsed: { - info: { - account: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - space: 165, - }, - type: "allocate", - }, - program: "system", - programId: new PublicKey(Buffer.from("00", "hex")), - }, - { - parsed: { - info: { - account: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - owner: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - }, - type: "assign", - }, - program: "system", - programId: new PublicKey(Buffer.from("00", "hex")), - }, - { - parsed: { - info: { - account: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - mint: "So11111111111111111111111111111111111111112", - owner: "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - rentSysvar: "SysvarRent111111111111111111111111111111111", - }, - type: "initializeAccount", - }, - program: "spl-token", - programId: new PublicKey( - Buffer.from( - "06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9", - "hex" - ) - ), - }, - ], - }, - ], - logMessages: [ - "Program 11111111111111111111111111111111 invoke [1]", - "Program 11111111111111111111111111111111 success", - "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]", - "Program log: Allocate space for the associated token account", - "Program 11111111111111111111111111111111 invoke [2]", - "Program 11111111111111111111111111111111 success", - "Program log: Assign the associated token account to the SPL Token program", - "Program 11111111111111111111111111111111 invoke [2]", - "Program 11111111111111111111111111111111 success", - "Program log: Initialize the associated token account", - "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]", - "Program log: Instruction: InitializeAccount", - "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3683 of 183452 compute units", - "Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success", - "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 20880 of 200000 compute units", - "Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success", - ], - postBalances: [ - 83389840, 10000000, 151314748907, 1, 1089991680, 1009200, 898174080, - ], - postTokenBalances: [ - { - accountIndex: 1, - mint: "So11111111111111111111111111111111111111112", - uiTokenAmount: { - amount: "7960720", - decimals: 9, - uiAmount: 0.00796072, - uiAmountString: "0.00796072", - }, - }, - ], - preBalances: [ - 93394840, 0, 151314748907, 1, 1089991680, 1009200, 898174080, - ], - preTokenBalances: [], - rewards: [], - status: { Ok: null }, - }, - slot: 108521109, - transaction: { - message: { - accountKeys: [ - { - pubkey: new PublicKey( - Buffer.from( - "8bc4d3e507c0550e3d02ffb5f6daf0772240af8a09e32d236615b4a227243702", - "hex" - ) - ), - signer: true, - writable: true, - }, - { - pubkey: new PublicKey( - Buffer.from( - "6e6279fa638560ce9c178033f5b88eacfb5fba6d46ec5902769f1b09eaabc017", - "hex" - ) - ), - signer: false, - writable: true, - }, - { - pubkey: new PublicKey( - Buffer.from( - "069b8857feab8184fb687f634618c035dac439dc1aeb3b5598a0f00000000001", - "hex" - ) - ), - signer: false, - writable: false, - }, - { - pubkey: new PublicKey(Buffer.from("00", "hex")), - signer: false, - writable: false, - }, - { - pubkey: new PublicKey( - Buffer.from( - "06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9", - "hex" - ) - ), - signer: false, - writable: false, - }, - { - pubkey: new PublicKey( - Buffer.from( - "06a7d517192c5c51218cc94c3d4af17f58daee089ba1fd44e3dbd98a00000000", - "hex" - ) - ), - signer: false, - writable: false, - }, - { - pubkey: new PublicKey( - Buffer.from( - "8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859", - "hex" - ) - ), - signer: false, - writable: false, - }, - ], - instructions: [ - { - parsed: { - info: { - destination: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - lamports: 10000000, - source: "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - }, - type: "transfer", - }, - program: "system", - programId: new PublicKey(Buffer.from("00", "hex")), - }, - { - parsed: { - info: { - account: "8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN", - mint: "So11111111111111111111111111111111111111112", - rentSysvar: "SysvarRent111111111111111111111111111111111", - source: "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - systemProgram: "11111111111111111111111111111111", - tokenProgram: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - wallet: "AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh", - }, - type: "create", - }, - program: "spl-associated-token-account", - programId: new PublicKey( - Buffer.from( - "8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859", - "hex" - ) - ), - }, - ], - recentBlockhash: "9tPbgLaETEenufCt5SzXMuWijgFJj549W9j5cJLbaogn", - }, - signatures: [ - "A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk", - ], - }, - }, - ], - }, - { - method: "getRecentBlockhash", - params: [], - answer: { - blockhash: "CfCUxX9U6hibcKxHV9Sy1CyG3Z2uTTitxAbina1ZXygF", - feeCalculator: { lamportsPerSignature: 5000 }, - }, - }, - { - method: "getBalance", - params: ["ARRKL4FT4LMwpkhUw4xNbfiHqR7UdePtzGLvkszgydqZ"], - answer: 1000000, - }, - { - method: "getBalance", - params: ["7b6Q3ap8qRzfyvDw1Qce3fUV8C7WgFNzJQwYNTJm3KQo"], - answer: 0, - }, - { - method: "getBalance", - params: ["6D8GtWkKJgToM5UoiByHqjQCCC9Dq1Hh7iNmU4jKSs14"], - answer: 0, - }, - - // manual - { - method: "getTxFeeCalculator", - params: [], - answer: { lamportsPerSignature: 5000 }, - }, +{"method":"getBalanceAndContext","params":["AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"],"answer":{"context":{"slot":126304968},"value":83389840}}, +{"method":"getStakeAccountsByStakeAuth","params":["AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"],"answer":[]}, +{"method":"getStakeAccountsByWithdrawAuth","params":["AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"],"answer":[]}, +{"method":"getEpochInfo","params":[],"answer":{"absoluteSlot":126304982,"blockHeight":114328655,"epoch":292,"slotIndex":160982,"slotsInEpoch":432000,"transactionCount":64939645162}}, +{"method":"getSignaturesForAddress","params":["AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh",{"limit":100}],"answer":[{"blockTime":1637781134,"confirmationStatus":"finalized","err":null,"memo":null,"signature":"A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk","slot":108521109},{"blockTime":1637780906,"confirmationStatus":"finalized","err":null,"memo":null,"signature":"25KWBvKtVgKR3yoRmozTY6wmiW8atwrnzAnTXdsms8jqg5aR8GnCDxdJzWXtzMZPvbsE6SUuBkGFXudy2mrcTYna","slot":108520722}]}, +{"method":"getParsedConfirmedTransactions","params":[["A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk","25KWBvKtVgKR3yoRmozTY6wmiW8atwrnzAnTXdsms8jqg5aR8GnCDxdJzWXtzMZPvbsE6SUuBkGFXudy2mrcTYna"]],"answer":[{"blockTime":1637781134,"meta":{"err":null,"fee":5000,"innerInstructions":[{"index":1,"instructions":[{"parsed":{"info":{"account":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","space":165},"type":"allocate"},"program":"system","programId":new PublicKey(Buffer.from("00", 'hex'))},{"parsed":{"info":{"account":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},"type":"assign"},"program":"system","programId":new PublicKey(Buffer.from("00", 'hex'))},{"parsed":{"info":{"account":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","mint":"So11111111111111111111111111111111111111112","owner":"AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh","rentSysvar":"SysvarRent111111111111111111111111111111111"},"type":"initializeAccount"},"program":"spl-token","programId":new PublicKey(Buffer.from("06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9", 'hex'))}]}],"logMessages":["Program 11111111111111111111111111111111 invoke [1]","Program 11111111111111111111111111111111 success","Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]","Program log: Allocate space for the associated token account","Program 11111111111111111111111111111111 invoke [2]","Program 11111111111111111111111111111111 success","Program log: Assign the associated token account to the SPL Token program","Program 11111111111111111111111111111111 invoke [2]","Program 11111111111111111111111111111111 success","Program log: Initialize the associated token account","Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]","Program log: Instruction: InitializeAccount","Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3683 of 183452 compute units","Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success","Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 20880 of 200000 compute units","Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success"],"postBalances":[83389840,10000000,151314748907,1,1089991680,1009200,898174080],"postTokenBalances":[{"accountIndex":1,"mint":"So11111111111111111111111111111111111111112","uiTokenAmount":{"amount":"7960720","decimals":9,"uiAmount":0.00796072,"uiAmountString":"0.00796072"}}],"preBalances":[93394840,0,151314748907,1,1089991680,1009200,898174080],"preTokenBalances":[],"rewards":[],"status":{"Ok":null}},"slot":108521109,"transaction":{"message":{"accountKeys":[{"pubkey":new PublicKey(Buffer.from("8bc4d3e507c0550e3d02ffb5f6daf0772240af8a09e32d236615b4a227243702", 'hex')),"signer":true,"writable":true},{"pubkey":new PublicKey(Buffer.from("6e6279fa638560ce9c178033f5b88eacfb5fba6d46ec5902769f1b09eaabc017", 'hex')),"signer":false,"writable":true},{"pubkey":new PublicKey(Buffer.from("069b8857feab8184fb687f634618c035dac439dc1aeb3b5598a0f00000000001", 'hex')),"signer":false,"writable":false},{"pubkey":new PublicKey(Buffer.from("00", 'hex')),"signer":false,"writable":false},{"pubkey":new PublicKey(Buffer.from("06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9", 'hex')),"signer":false,"writable":false},{"pubkey":new PublicKey(Buffer.from("06a7d517192c5c51218cc94c3d4af17f58daee089ba1fd44e3dbd98a00000000", 'hex')),"signer":false,"writable":false},{"pubkey":new PublicKey(Buffer.from("8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859", 'hex')),"signer":false,"writable":false}],"instructions":[{"parsed":{"info":{"destination":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","lamports":10000000,"source":"AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"},"type":"transfer"},"program":"system","programId":new PublicKey(Buffer.from("00", 'hex'))},{"parsed":{"info":{"account":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","mint":"So11111111111111111111111111111111111111112","rentSysvar":"SysvarRent111111111111111111111111111111111","source":"AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh","systemProgram":"11111111111111111111111111111111","tokenProgram":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","wallet":"AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"},"type":"create"},"program":"spl-associated-token-account","programId":new PublicKey(Buffer.from("8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859", 'hex'))}],"recentBlockhash":"9tPbgLaETEenufCt5SzXMuWijgFJj549W9j5cJLbaogn"},"signatures":["A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk"]}},{"blockTime":1637780906,"meta":{"err":null,"fee":5000,"innerInstructions":[],"logMessages":["Program 11111111111111111111111111111111 invoke [1]","Program 11111111111111111111111111111111 success"],"postBalances":[0,93394840,1],"postTokenBalances":[],"preBalances":[93399840,0,1],"preTokenBalances":[],"rewards":[],"status":{"Ok":null}},"slot":108520722,"transaction":{"message":{"accountKeys":[{"pubkey":new PublicKey(Buffer.from("5c1c77c3d1e8edad4cfb2b2f7e4497d0d83f19e176713876a1d01eeb30a9bf3f", 'hex')),"signer":true,"writable":true},{"pubkey":new PublicKey(Buffer.from("8bc4d3e507c0550e3d02ffb5f6daf0772240af8a09e32d236615b4a227243702", 'hex')),"signer":false,"writable":true},{"pubkey":new PublicKey(Buffer.from("00", 'hex')),"signer":false,"writable":false}],"instructions":[{"parsed":{"info":{"destination":"AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh","lamports":93394840,"source":"7CZgkK494jMdoY8xpXY3ViLjpDGMbNikCzMtAT5cAjKk"},"type":"transfer"},"program":"system","programId":new PublicKey(Buffer.from("00", 'hex'))}],"recentBlockhash":"4NSL4VrfWd2eUccMD95dLQsdy5UGz8yhokpfH1et1R2c"},"signatures":["25KWBvKtVgKR3yoRmozTY6wmiW8atwrnzAnTXdsms8jqg5aR8GnCDxdJzWXtzMZPvbsE6SUuBkGFXudy2mrcTYna"]}}]}, +{"method":"getBalanceAndContext","params":["6rEgdtB3sgjKJnRE172YEr9z6qUyr4nFW28vJokuD36A"],"answer":{"context":{"slot":126304996},"value":0}}, +{"method":"getStakeAccountsByStakeAuth","params":["6rEgdtB3sgjKJnRE172YEr9z6qUyr4nFW28vJokuD36A"],"answer":[]}, +{"method":"getStakeAccountsByWithdrawAuth","params":["6rEgdtB3sgjKJnRE172YEr9z6qUyr4nFW28vJokuD36A"],"answer":[]}, +{"method":"getSignaturesForAddress","params":["6rEgdtB3sgjKJnRE172YEr9z6qUyr4nFW28vJokuD36A",{"limit":100}],"answer":[]}, +{"method":"getSignaturesForAddress","params":["AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh",{"until":"25KWBvKtVgKR3yoRmozTY6wmiW8atwrnzAnTXdsms8jqg5aR8GnCDxdJzWXtzMZPvbsE6SUuBkGFXudy2mrcTYna","limit":100}],"answer":[{"blockTime":1637781134,"confirmationStatus":"finalized","err":null,"memo":null,"signature":"A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk","slot":108521109}]}, +{"method":"getParsedConfirmedTransactions","params":[["A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk"]],"answer":[{"blockTime":1637781134,"meta":{"err":null,"fee":5000,"innerInstructions":[{"index":1,"instructions":[{"parsed":{"info":{"account":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","space":165},"type":"allocate"},"program":"system","programId":new PublicKey(Buffer.from("00", 'hex'))},{"parsed":{"info":{"account":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},"type":"assign"},"program":"system","programId":new PublicKey(Buffer.from("00", 'hex'))},{"parsed":{"info":{"account":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","mint":"So11111111111111111111111111111111111111112","owner":"AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh","rentSysvar":"SysvarRent111111111111111111111111111111111"},"type":"initializeAccount"},"program":"spl-token","programId":new PublicKey(Buffer.from("06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9", 'hex'))}]}],"logMessages":["Program 11111111111111111111111111111111 invoke [1]","Program 11111111111111111111111111111111 success","Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL invoke [1]","Program log: Allocate space for the associated token account","Program 11111111111111111111111111111111 invoke [2]","Program 11111111111111111111111111111111 success","Program log: Assign the associated token account to the SPL Token program","Program 11111111111111111111111111111111 invoke [2]","Program 11111111111111111111111111111111 success","Program log: Initialize the associated token account","Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [2]","Program log: Instruction: InitializeAccount","Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA consumed 3683 of 183452 compute units","Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success","Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL consumed 20880 of 200000 compute units","Program ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL success"],"postBalances":[83389840,10000000,151314748907,1,1089991680,1009200,898174080],"postTokenBalances":[{"accountIndex":1,"mint":"So11111111111111111111111111111111111111112","uiTokenAmount":{"amount":"7960720","decimals":9,"uiAmount":0.00796072,"uiAmountString":"0.00796072"}}],"preBalances":[93394840,0,151314748907,1,1089991680,1009200,898174080],"preTokenBalances":[],"rewards":[],"status":{"Ok":null}},"slot":108521109,"transaction":{"message":{"accountKeys":[{"pubkey":new PublicKey(Buffer.from("8bc4d3e507c0550e3d02ffb5f6daf0772240af8a09e32d236615b4a227243702", 'hex')),"signer":true,"writable":true},{"pubkey":new PublicKey(Buffer.from("6e6279fa638560ce9c178033f5b88eacfb5fba6d46ec5902769f1b09eaabc017", 'hex')),"signer":false,"writable":true},{"pubkey":new PublicKey(Buffer.from("069b8857feab8184fb687f634618c035dac439dc1aeb3b5598a0f00000000001", 'hex')),"signer":false,"writable":false},{"pubkey":new PublicKey(Buffer.from("00", 'hex')),"signer":false,"writable":false},{"pubkey":new PublicKey(Buffer.from("06ddf6e1d765a193d9cbe146ceeb79ac1cb485ed5f5b37913a8cf5857eff00a9", 'hex')),"signer":false,"writable":false},{"pubkey":new PublicKey(Buffer.from("06a7d517192c5c51218cc94c3d4af17f58daee089ba1fd44e3dbd98a00000000", 'hex')),"signer":false,"writable":false},{"pubkey":new PublicKey(Buffer.from("8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859", 'hex')),"signer":false,"writable":false}],"instructions":[{"parsed":{"info":{"destination":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","lamports":10000000,"source":"AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"},"type":"transfer"},"program":"system","programId":new PublicKey(Buffer.from("00", 'hex'))},{"parsed":{"info":{"account":"8RtwWeqdFz4EFuZU3MAadfYMWSdRMamjFrfq6BXkHuNN","mint":"So11111111111111111111111111111111111111112","rentSysvar":"SysvarRent111111111111111111111111111111111","source":"AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh","systemProgram":"11111111111111111111111111111111","tokenProgram":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","wallet":"AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"},"type":"create"},"program":"spl-associated-token-account","programId":new PublicKey(Buffer.from("8c97258f4e2489f1bb3d1029148e0d830b5a1399daff1084048e7bd8dbe9f859", 'hex'))}],"recentBlockhash":"9tPbgLaETEenufCt5SzXMuWijgFJj549W9j5cJLbaogn"},"signatures":["A29zPnK1jPr2tGziTnaAvSnadYR2kLCv9sPywj9FJsaEFjtpwmUonspN3WJgz4u6XWmjtVpoFsDrygEnvW51cgk"]}}]}, +{"method":"getTxFeeCalculator","params":[],"answer":{"lamportsPerSignature":5000}}, +{"method":"getBalance","params":["ARRKL4FT4LMwpkhUw4xNbfiHqR7UdePtzGLvkszgydqZ"],"answer":1000000}, +{"method":"getBalance","params":["7b6Q3ap8qRzfyvDw1Qce3fUV8C7WgFNzJQwYNTJm3KQo"],"answer":0}, +{"method":"getBalance","params":["6D8GtWkKJgToM5UoiByHqjQCCC9Dq1Hh7iNmU4jKSs14"],"answer":0}, +{"method":"getMinimumBalanceForRentExemption","params":[200],"answer":2282880}, +{"method":"getAccountInfo","params":["9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF"],"answer":{"data":{"parsed":{"info":{"authorizedVoters":[{"authorizedVoter":"EvnRmnMrd69kFdbLMxWkTn1icZ7DCceRhvmb2SJXqDo4","epoch":292}],"authorizedWithdrawer":"EvnRmnMrd69kFdbLMxWkTn1icZ7DCceRhvmb2SJXqDo4","commission":7,"epochCredits":[{"credits":"78811573","epoch":229,"previousCredits":"78431315"},{"credits":"79192750","epoch":230,"previousCredits":"78811573"},{"credits":"79568204","epoch":231,"previousCredits":"79192750"},{"credits":"79971932","epoch":232,"previousCredits":"79568204"},{"credits":"80385503","epoch":233,"previousCredits":"79971932"},{"credits":"80802228","epoch":234,"previousCredits":"80385503"},{"credits":"81218975","epoch":235,"previousCredits":"80802228"},{"credits":"81635216","epoch":236,"previousCredits":"81218975"},{"credits":"82042143","epoch":237,"previousCredits":"81635216"},{"credits":"82452807","epoch":238,"previousCredits":"82042143"},{"credits":"82862312","epoch":239,"previousCredits":"82452807"},{"credits":"83257955","epoch":240,"previousCredits":"82862312"},{"credits":"83658257","epoch":241,"previousCredits":"83257955"},{"credits":"84043555","epoch":242,"previousCredits":"83658257"},{"credits":"84430030","epoch":243,"previousCredits":"84043555"},{"credits":"84826592","epoch":244,"previousCredits":"84430030"},{"credits":"85206845","epoch":245,"previousCredits":"84826592"},{"credits":"85584036","epoch":246,"previousCredits":"85206845"},{"credits":"85969381","epoch":247,"previousCredits":"85584036"},{"credits":"86337460","epoch":248,"previousCredits":"85969381"},{"credits":"86726082","epoch":249,"previousCredits":"86337460"},{"credits":"87095806","epoch":250,"previousCredits":"86726082"},{"credits":"87466570","epoch":251,"previousCredits":"87095806"},{"credits":"87843044","epoch":252,"previousCredits":"87466570"},{"credits":"88204705","epoch":253,"previousCredits":"87843044"},{"credits":"88556614","epoch":254,"previousCredits":"88204705"},{"credits":"88926147","epoch":255,"previousCredits":"88556614"},{"credits":"89293908","epoch":256,"previousCredits":"88926147"},{"credits":"89643798","epoch":257,"previousCredits":"89293908"},{"credits":"90019527","epoch":258,"previousCredits":"89643798"},{"credits":"90405484","epoch":259,"previousCredits":"90019527"},{"credits":"90779860","epoch":260,"previousCredits":"90405484"},{"credits":"91169419","epoch":261,"previousCredits":"90779860"},{"credits":"91566757","epoch":262,"previousCredits":"91169419"},{"credits":"91931723","epoch":263,"previousCredits":"91566757"},{"credits":"92312039","epoch":264,"previousCredits":"91931723"},{"credits":"92675982","epoch":265,"previousCredits":"92312039"},{"credits":"93003571","epoch":266,"previousCredits":"92675982"},{"credits":"93348277","epoch":267,"previousCredits":"93003571"},{"credits":"93719518","epoch":268,"previousCredits":"93348277"},{"credits":"94087375","epoch":269,"previousCredits":"93719518"},{"credits":"94426808","epoch":270,"previousCredits":"94087375"},{"credits":"94664196","epoch":271,"previousCredits":"94426808"},{"credits":"95046380","epoch":272,"previousCredits":"94664196"},{"credits":"95425125","epoch":273,"previousCredits":"95046380"},{"credits":"95808239","epoch":274,"previousCredits":"95425125"},{"credits":"96179315","epoch":275,"previousCredits":"95808239"},{"credits":"96560176","epoch":276,"previousCredits":"96179315"},{"credits":"96927342","epoch":277,"previousCredits":"96560176"},{"credits":"97293583","epoch":278,"previousCredits":"96927342"},{"credits":"97663356","epoch":279,"previousCredits":"97293583"},{"credits":"98027621","epoch":280,"previousCredits":"97663356"},{"credits":"98381819","epoch":281,"previousCredits":"98027621"},{"credits":"98728105","epoch":282,"previousCredits":"98381819"},{"credits":"99072452","epoch":283,"previousCredits":"98728105"},{"credits":"99420965","epoch":284,"previousCredits":"99072452"},{"credits":"99765853","epoch":285,"previousCredits":"99420965"},{"credits":"100119574","epoch":286,"previousCredits":"99765853"},{"credits":"100458879","epoch":287,"previousCredits":"100119574"},{"credits":"100821985","epoch":288,"previousCredits":"100458879"},{"credits":"101173449","epoch":289,"previousCredits":"100821985"},{"credits":"101565565","epoch":290,"previousCredits":"101173449"},{"credits":"101955002","epoch":291,"previousCredits":"101565565"},{"credits":"102102222","epoch":292,"previousCredits":"101955002"}],"lastTimestamp":{"slot":126305019,"timestamp":1648044550},"nodePubkey":"EvnRmnMrd69kFdbLMxWkTn1icZ7DCceRhvmb2SJXqDo4","priorVoters":[],"rootSlot":126304978,"votes":[{"confirmationCount":31,"slot":126304979},{"confirmationCount":30,"slot":126304980},{"confirmationCount":29,"slot":126304981},{"confirmationCount":28,"slot":126304982},{"confirmationCount":27,"slot":126304983},{"confirmationCount":26,"slot":126304994},{"confirmationCount":25,"slot":126304995},{"confirmationCount":24,"slot":126304996},{"confirmationCount":23,"slot":126304997},{"confirmationCount":22,"slot":126304998},{"confirmationCount":21,"slot":126304999},{"confirmationCount":20,"slot":126305000},{"confirmationCount":19,"slot":126305001},{"confirmationCount":18,"slot":126305002},{"confirmationCount":17,"slot":126305003},{"confirmationCount":16,"slot":126305004},{"confirmationCount":15,"slot":126305005},{"confirmationCount":14,"slot":126305006},{"confirmationCount":13,"slot":126305007},{"confirmationCount":12,"slot":126305008},{"confirmationCount":11,"slot":126305009},{"confirmationCount":10,"slot":126305010},{"confirmationCount":9,"slot":126305011},{"confirmationCount":8,"slot":126305012},{"confirmationCount":7,"slot":126305013},{"confirmationCount":6,"slot":126305014},{"confirmationCount":5,"slot":126305015},{"confirmationCount":4,"slot":126305016},{"confirmationCount":3,"slot":126305017},{"confirmationCount":2,"slot":126305018},{"confirmationCount":1,"slot":126305019}]},"type":"vote"},"program":"vote","space":3731},"executable":false,"lamports":4340563811517,"owner":new PublicKey(Buffer.from("0761481d357474bb7c4d7624ebd3bdb3d8355e73d11043fc0da3538000000000", 'hex')),"rentEpoch":292}}, +{"method":"getAccountInfo","params":["AQbkEagmPgmsdAfS4X8V8UyJnXXjVPMvjeD15etqQ3Jh"],"answer":{"data":{"type":"Buffer","data":[]},"executable":false,"lamports":83389840,"owner":new PublicKey(Buffer.from("00", 'hex')),"rentEpoch":291}}, ]; diff --git a/src/families/solana/bridge/mock.ts b/src/families/solana/bridge/mock.ts index a5d0f55898..81521ae1af 100644 --- a/src/families/solana/bridge/mock.ts +++ b/src/families/solana/bridge/mock.ts @@ -50,6 +50,7 @@ function removeUndefineds(input: any) { // Bridge with this api will log all api calls to a file. // The calls data can be copied to mock-data.ts from the file. +// Uncomment fs module in logged.ts /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ function createMockDataForAPI() { const apiGetter = makeLRUCache( diff --git a/src/families/solana/cli-transaction.ts b/src/families/solana/cli-transaction.ts index 4efbf01d22..aa6ca9ef4d 100644 --- a/src/families/solana/cli-transaction.ts +++ b/src/families/solana/cli-transaction.ts @@ -10,12 +10,31 @@ import type { import { Transaction as SolanaTransaction } from "./types"; import { assertUnreachable } from "./utils"; -const modes = ["send", "optIn"] as const; +const modes = [ + "send", + "optIn", + "stake.createAccount", + "stake.delegate", + "stake.undelegate", + "stake.withdraw", + "stake.split", +] as const; type Mode = typeof modes[number]; -// options already specified in other blockchains like ethereum. +// some options already specified for other blockchains like ethereum. // trying to reuse existing ones like , , etc. -const options = []; +const options = [ + { + name: "solanaValidator", + type: String, + desc: "validator address to delegate to", + }, + { + name: "solanaStakeAccount", + type: String, + desc: "stake account address to use in the transaction", + }, +]; function inferTransactions( transactions: Array<{ @@ -99,6 +118,65 @@ function inferTransactions( }; return solanaTx; } + case "stake.createAccount": { + const validator = opts.solanaValidator; + return { + ...transaction, + model: { + kind: "stake.createAccount", + uiState: { + delegate: { + voteAccAddress: validator ?? "", + }, + }, + }, + }; + } + case "stake.delegate": + return { + ...transaction, + model: { + kind: "stake.delegate", + uiState: { + stakeAccAddr: opts.solanaStakeAccount ?? "", + voteAccAddr: opts.solanaValidator ?? "", + }, + }, + }; + case "stake.undelegate": + return { + ...transaction, + model: { + kind: "stake.undelegate", + uiState: { + stakeAccAddr: opts.solanaStakeAccount ?? "", + }, + }, + }; + case "stake.withdraw": + return { + ...transaction, + model: { + kind: "stake.withdraw", + uiState: { + stakeAccAddr: opts.solanaStakeAccount ?? "", + }, + }, + }; + case "stake.split": + if (opts.solanaStakeAccount === undefined) { + throw new Error("stake account is required"); + } + + return { + ...transaction, + model: { + kind: "stake.split", + uiState: { + stakeAccAddr: opts.solanaStakeAccount, + }, + }, + }; default: return assertUnreachable(mode); } @@ -146,6 +224,12 @@ function inferAccounts( return [subAccount]; } case "optIn": + case "stake.createAccount": + case "stake.delegate": + case "stake.undelegate": + case "stake.withdraw": + case "stake.split": + // TODO: infer stake account for stake ops return [mainAccount]; default: return assertUnreachable(mode); diff --git a/src/families/solana/deviceTransactionConfig.ts b/src/families/solana/deviceTransactionConfig.ts index 039c866488..b3f172e8ef 100644 --- a/src/families/solana/deviceTransactionConfig.ts +++ b/src/families/solana/deviceTransactionConfig.ts @@ -1,17 +1,25 @@ -import type { AccountLike, Account } from "../../types"; +import BigNumber from "bignumber.js"; +import { formatCurrencyUnit } from "../../currencies"; +import type { DeviceTransactionField } from "../../transaction"; +import type { Account, AccountLike } from "../../types"; import type { + CommandDescriptor, + StakeCreateAccountCommand, + StakeDelegateCommand, + StakeSplitCommand, + StakeUndelegateCommand, + StakeWithdrawCommand, TokenCreateATACommand, TokenTransferCommand, Transaction, TransferCommand, - ValidCommandDescriptor, } from "./types"; -import type { DeviceTransactionField } from "../../transaction"; import { assertUnreachable } from "./utils"; // do not show fields like 'To', 'Recipient', etc., as per Ledger policy function getDeviceTransactionConfig({ + account, transaction, }: { account: AccountLike; @@ -22,19 +30,18 @@ function getDeviceTransactionConfig({ if (commandDescriptor === undefined) { throw new Error("missing command descriptor"); } - switch (commandDescriptor.status) { - case "valid": - return fieldsForCommand(commandDescriptor); - case "invalid": - throw new Error("unexpected invalid command"); - default: - return assertUnreachable(commandDescriptor); + if (Object.keys(commandDescriptor.errors).length > 0) { + throw new Error("unexpected invalid command"); } + + return fieldsForCommand(commandDescriptor, account); } export default getDeviceTransactionConfig; + function fieldsForCommand( - commandDescriptor: ValidCommandDescriptor + commandDescriptor: CommandDescriptor, + account: AccountLike ): DeviceTransactionField[] { const { command } = commandDescriptor; switch (command.kind) { @@ -44,12 +51,24 @@ function fieldsForCommand( return fieldsForTokenTransfer(command); case "token.createATA": return fieldsForCreateATA(command); + case "stake.createAccount": + return fieldsForStakeCreateAccount(command, account); + case "stake.delegate": + return fieldsForStakeDelegate(command); + case "stake.undelegate": + return fieldsForStakeUndelegate(command); + case "stake.withdraw": + return fieldsForStakeWithdraw(command); + case "stake.split": + return fieldsForStakeSplit(command); default: return assertUnreachable(command); } } -function fieldsForTransfer(command: TransferCommand): DeviceTransactionField[] { +function fieldsForTransfer( + _command: TransferCommand +): DeviceTransactionField[] { const fields: Array = []; fields.push({ @@ -57,18 +76,6 @@ function fieldsForTransfer(command: TransferCommand): DeviceTransactionField[] { label: "Transfer", }); - fields.push({ - type: "address", - address: command.sender, - label: "Sender", - }); - - fields.push({ - type: "text", - label: "Fee payer", - value: "Sender", - }); - return fields; } function fieldsForTokenTransfer( @@ -158,3 +165,152 @@ function fieldsForCreateATA( return fields; } +function fieldsForStakeCreateAccount( + command: StakeCreateAccountCommand, + account: AccountLike +): DeviceTransactionField[] { + if (account.type !== "Account") { + throw new Error("unsupported account type"); + } + + const unit = account.currency.units[0]; + + const fields: Array = []; + + fields.push({ + type: "address", + label: "Delegate from", + address: command.stakeAccAddress, + }); + + // not using 'amount' field here because the field should + // show sum of amount and rent exempt amount + fields.push({ + type: "text", + label: "Deposit", + value: formatCurrencyUnit( + unit, + new BigNumber(command.amount + command.stakeAccRentExemptAmount), + { + disableRounding: true, + showCode: true, + } + ), + }); + + fields.push({ + type: "address", + label: "New authority", + address: command.fromAccAddress, + }); + + fields.push({ + type: "address", + label: "Vote account", + address: command.delegate.voteAccAddress, + }); + + return fields; +} + +function fieldsForStakeDelegate( + command: StakeDelegateCommand +): DeviceTransactionField[] { + const fields: Array = []; + + fields.push({ + type: "address", + label: "Delegate from", + address: command.stakeAccAddr, + }); + + fields.push({ + type: "address", + label: "Vote account", + address: command.voteAccAddr, + }); + + return fields; +} + +function fieldsForStakeUndelegate( + command: StakeUndelegateCommand +): DeviceTransactionField[] { + const fields: Array = []; + + fields.push({ + type: "address", + label: "Deactivate stake", + address: command.stakeAccAddr, + }); + + return fields; +} + +function fieldsForStakeWithdraw( + command: StakeWithdrawCommand +): DeviceTransactionField[] { + const fields: Array = []; + + fields.push({ + type: "amount", + label: "Stake withdraw", + }); + + fields.push({ + type: "address", + label: "From", + address: command.stakeAccAddr, + }); + + return fields; +} + +function fieldsForStakeSplit( + command: StakeSplitCommand +): DeviceTransactionField[] { + const fields: Array = []; + + fields.push({ + type: "amount", + label: "Split stake", + }); + + fields.push({ + type: "address", + label: "From", + address: command.stakeAccAddr, + }); + + fields.push({ + type: "address", + label: "To", + address: command.splitStakeAccAddr, + }); + + fields.push({ + type: "address", + label: "Base", + address: command.authorizedAccAddr, + }); + + fields.push({ + type: "text", + label: "Seed", + value: command.seed, + }); + + fields.push({ + type: "address", + label: "Authorized by", + address: command.authorizedAccAddr, + }); + + fields.push({ + type: "address", + label: "Fee payer", + address: command.authorizedAccAddr, + }); + + return fields; +} diff --git a/src/families/solana/errors.ts b/src/families/solana/errors.ts index 22e58526d5..5f586fccde 100644 --- a/src/families/solana/errors.ts +++ b/src/families/solana/errors.ts @@ -26,3 +26,43 @@ export const SolanaAddressOffEd25519 = createCustomErrorClass( export const SolanaTokenRecipientIsSenderATA = createCustomErrorClass( "SolanaTokenRecipientIsSenderATA" ); + +export const SolanaValidatorRequired = createCustomErrorClass( + "SolanaValidatorRequired" +); + +export const SolanaInvalidValidator = createCustomErrorClass( + "SolanaInvalidValidator" +); + +export const SolanaStakeAccountRequired = createCustomErrorClass( + "SolanaStakeAccountRequired" +); + +export const SolanaStakeAccountNotFound = createCustomErrorClass( + "SolanaStakeAccountNotFound" +); + +export const SolanaStakeAccountNothingToWithdraw = createCustomErrorClass( + "SolanaStakeAccountNothingToWithdraw" +); + +export const SolanaStakeAccountIsNotDelegatable = createCustomErrorClass( + "SolanaStakeAccountIsNotDelegatable" +); + +export const SolanaStakeAccountIsNotUndelegatable = createCustomErrorClass( + "SolanaStakeAccountIsNotUndelegatable" +); + +export const SolanaStakeAccountValidatorIsUnchangeable = createCustomErrorClass( + "SolanaStakeAccountValidatorIsUnchangeable" +); + +export const SolanaStakeNoWithdrawAuth = createCustomErrorClass( + "SolanaStakeNoWithdrawAuth" +); + +export const SolanaStakeNoStakeAuth = createCustomErrorClass( + "SolanaStakeNoStakeAuth" +); diff --git a/src/families/solana/js-buildTransaction.ts b/src/families/solana/js-buildTransaction.ts index d4d3179f4c..d75c7fe20d 100644 --- a/src/families/solana/js-buildTransaction.ts +++ b/src/families/solana/js-buildTransaction.ts @@ -5,6 +5,11 @@ import { buildTransferInstructions, buildTokenTransferInstructions, buildCreateAssociatedTokenAccountInstruction, + buildStakeCreateAccountInstructions, + buildStakeDelegateInstructions, + buildStakeUndelegateInstructions, + buildStakeWithdrawInstructions, + buildStakeSplitInstructions, } from "./api/chain/web3"; import { assertUnreachable } from "./utils"; import { @@ -44,19 +49,15 @@ export const buildTransactionWithAPI = async ( ] as const; }; -function buildInstructions(tx: Transaction) { +function buildInstructions(tx: Transaction): TransactionInstruction[] { const { commandDescriptor } = tx.model; if (commandDescriptor === undefined) { throw new Error("missing command descriptor"); } - switch (commandDescriptor.status) { - case "valid": - return buildInstructionsForCommand(commandDescriptor.command); - case "invalid": - throw new Error("can not build invalid command"); - default: - return assertUnreachable(commandDescriptor); + if (Object.keys(commandDescriptor.errors).length > 0) { + throw new Error("can not build invalid command"); } + return buildInstructionsForCommand(commandDescriptor.command); } function buildInstructionsForCommand( @@ -69,6 +70,16 @@ function buildInstructionsForCommand( return buildTokenTransferInstructions(command); case "token.createATA": return buildCreateAssociatedTokenAccountInstruction(command); + case "stake.createAccount": + return buildStakeCreateAccountInstructions(command); + case "stake.delegate": + return buildStakeDelegateInstructions(command); + case "stake.undelegate": + return buildStakeUndelegateInstructions(command); + case "stake.withdraw": + return buildStakeWithdrawInstructions(command); + case "stake.split": + return buildStakeSplitInstructions(command); default: return assertUnreachable(command); } diff --git a/src/families/solana/js-estimateMaxSpendable.ts b/src/families/solana/js-estimateMaxSpendable.ts index e523ee635c..a4a03fe708 100644 --- a/src/families/solana/js-estimateMaxSpendable.ts +++ b/src/families/solana/js-estimateMaxSpendable.ts @@ -2,6 +2,7 @@ import type { AccountBridge } from "../../types"; import type { Transaction } from "./types"; import BigNumber from "bignumber.js"; import { ChainAPI } from "./api"; +import { getStakeAccountMinimumBalanceForRentExemption } from "./api/chain/web3"; const estimateMaxSpendableWithAPI = async ( { @@ -10,15 +11,27 @@ const estimateMaxSpendableWithAPI = async ( }: Parameters["estimateMaxSpendable"]>[0], api: ChainAPI ): Promise => { - const feeCalculator = - transaction?.feeCalculator ?? (await api.getTxFeeCalculator()); + const txFee = (await api.getTxFeeCalculator()).lamportsPerSignature; switch (account.type) { - case "Account": - return BigNumber.max( - account.balance.minus(feeCalculator.lamportsPerSignature), - 0 - ); + case "Account": { + const forTransfer = BigNumber.max(account.balance.minus(txFee), 0); + if (!transaction) { + return forTransfer; + } + switch (transaction.model.kind) { + case "stake.createAccount": { + const stakeAccRentExempt = + await getStakeAccountMinimumBalanceForRentExemption(api); + return BigNumber.max( + account.balance.minus(txFee).minus(stakeAccRentExempt), + 0 + ); + } + default: + return forTransfer; + } + } case "TokenAccount": return account.balance; } diff --git a/src/families/solana/js-getTransactionStatus.ts b/src/families/solana/js-getTransactionStatus.ts index aac36a0284..a237de0c66 100644 --- a/src/families/solana/js-getTransactionStatus.ts +++ b/src/families/solana/js-getTransactionStatus.ts @@ -1,128 +1,69 @@ import { BigNumber } from "bignumber.js"; import type { Account, TransactionStatus } from "../../types"; -import type { Command, Transaction, TransactionModel } from "./types"; +import type { Command, CommandDescriptor, Transaction } from "./types"; import { assertUnreachable } from "./utils"; const getTransactionStatus = async ( - account: Account, + _account: Account, tx: Transaction ): Promise => { - const txFees = new BigNumber(tx.feeCalculator?.lamportsPerSignature ?? 0); - const { commandDescriptor } = tx.model; if (commandDescriptor === undefined) { - const amount = getAmountForModel(account, tx, txFees); - const totalSpent = getTotalSpentForModel(tx.model, amount, txFees); return { - amount, + amount: tx.amount, + estimatedFees: new BigNumber(0), + totalSpent: tx.amount, errors: {}, warnings: {}, - estimatedFees: txFees, - totalSpent, }; } - switch (commandDescriptor.status) { - case "invalid": { - const amount = getAmountForModel(account, tx, txFees); - const totalSpent = getTotalSpentForModel(tx.model, amount, txFees); - return { - amount, - errors: commandDescriptor.errors, - warnings: commandDescriptor.warnings ?? {}, - estimatedFees: txFees, - totalSpent, - }; - } - case "valid": { - const { command } = commandDescriptor; - const estimatedFees = txFees.plus(commandDescriptor.fees ?? 0); - const amount = getAmountForCommand(command); - const totalSpent = getTotalSpentForCommand( - command, - amount, - estimatedFees - ); - return { - amount, - estimatedFees, - totalSpent, - warnings: commandDescriptor.warnings ?? {}, - errors: {}, - }; - } - default: - return assertUnreachable(commandDescriptor); - } -}; + const { command, fee, errors, warnings } = commandDescriptor; -function getAmountForModel( - account: Account, - tx: Transaction, - estimatedFees: BigNumber -) { - const { model } = tx; - switch (model.kind) { - case "transfer": { - if (tx.amount.lte(0)) { - return tx.amount; - } - const amount = tx.useAllAmount - ? account.balance.minus(estimatedFees) - : BigNumber.max(tx.amount, 0); - return amount.gte(0) ? amount : account.balance; - } - case "token.transfer": - return tx.useAllAmount ? account.balance : tx.amount; - case "token.createATA": - return new BigNumber(0); - default: - return assertUnreachable(model); - } -} + const amount = new BigNumber(getAmount(command)); + const estimatedFees = new BigNumber(fee); + const totalSpent = new BigNumber(getTotalSpent(commandDescriptor)); -function getTotalSpentForModel( - model: TransactionModel, - amount: BigNumber, - estimatedFees: BigNumber -) { - switch (model.kind) { - case "transfer": - return amount.plus(estimatedFees); - case "token.transfer": - return amount; - case "token.createATA": - return estimatedFees; - default: - return assertUnreachable(model); - } -} + return { + amount, + estimatedFees, + totalSpent, + warnings, + errors, + }; +}; -function getAmountForCommand(command: Command) { +function getAmount(command: Command) { switch (command.kind) { case "transfer": case "token.transfer": - return new BigNumber(command.amount); + case "stake.createAccount": + case "stake.withdraw": + return command.amount; case "token.createATA": - return new BigNumber(0); + case "stake.delegate": + case "stake.undelegate": + case "stake.split": + return 0; default: return assertUnreachable(command); } } -function getTotalSpentForCommand( - command: Command, - amount: BigNumber, - estimatedFees: BigNumber -) { +function getTotalSpent({ command, fee }: CommandDescriptor) { switch (command.kind) { case "transfer": - return amount.plus(estimatedFees); + case "stake.createAccount": + return command.amount < 0 ? 0 : command.amount + fee; case "token.transfer": - return amount; + return Math.max(command.amount, 0); case "token.createATA": - return estimatedFees; + case "stake.delegate": + case "stake.undelegate": + case "stake.withdraw": + case "stake.split": + return fee; default: return assertUnreachable(command); } diff --git a/src/families/solana/js-preload-data.ts b/src/families/solana/js-preload-data.ts new file mode 100644 index 0000000000..baa92a257a --- /dev/null +++ b/src/families/solana/js-preload-data.ts @@ -0,0 +1,53 @@ +import { CryptoCurrency } from "@ledgerhq/cryptoassets"; +import { BehaviorSubject, Observable } from "rxjs"; +import { SolanaPreloadDataV1 } from "./types"; + +const initialData: SolanaPreloadDataV1 = { + version: "1", + validatorsWithMeta: [], + validators: [], +}; + +const dataByCurrency = new Map([ + ["solana", initialData], + ["solana_testnet", initialData], + ["solana_devnet", initialData], +]); + +const dataUpdatesByCurrency = new Map([ + ["solana", new BehaviorSubject(initialData)], + ["solana_testnet", new BehaviorSubject(initialData)], + ["solana_devnet", new BehaviorSubject(initialData)], +]); + +export function setSolanaPreloadData( + data: SolanaPreloadDataV1, + currency: CryptoCurrency +): void { + dataByCurrency.set(currency.id, data); + const subject = dataUpdatesByCurrency.get(currency.id); + if (subject === undefined) { + throw new Error(`unsupported currency ${currency.id}`); + } + subject.next(data); +} + +export function getSolanaPreloadData( + currency: CryptoCurrency +): Observable { + const subject = dataUpdatesByCurrency.get(currency.id); + if (subject === undefined) { + throw new Error(`unsupported currency ${currency.id}`); + } + return subject.asObservable(); +} + +export function getCurrentSolanaPreloadData( + currency: CryptoCurrency +): SolanaPreloadDataV1 { + const data = dataByCurrency.get(currency.id); + if (data === undefined) { + throw new Error(`unsupported currency ${currency.id}`); + } + return data; +} diff --git a/src/families/solana/js-preload.ts b/src/families/solana/js-preload.ts new file mode 100644 index 0000000000..4302d3a218 --- /dev/null +++ b/src/families/solana/js-preload.ts @@ -0,0 +1,66 @@ +import { CryptoCurrency } from "@ledgerhq/cryptoassets"; +import { ChainAPI } from "./api"; +import { SolanaPreloadData, SolanaPreloadDataV1 } from "./types"; +import { assertUnreachable, clusterByCurrencyId } from "./utils"; +import { setSolanaPreloadData as setPreloadData } from "./js-preload-data"; +import { getValidators, ValidatorsAppValidator } from "./validator-app"; + +export async function preloadWithAPI( + currency: CryptoCurrency, + getAPI: () => Promise +): Promise> { + const api = await getAPI(); + + const cluster = clusterByCurrencyId(currency.id); + + const validators: ValidatorsAppValidator[] = + cluster === "devnet" + ? await loadDevnetValidators(api) + : await getValidators(cluster); + + const data: SolanaPreloadData = { + version: "1", + validatorsWithMeta: [], + validators, + }; + + setPreloadData(data, currency); + + return data; +} + +async function loadDevnetValidators(api: ChainAPI) { + const voteAccs = await api.getVoteAccounts(); + const validators: ValidatorsAppValidator[] = voteAccs.current.map((acc) => ({ + activeStake: acc.activatedStake, + commission: acc.commission, + totalScore: 0, + voteAccount: acc.votePubkey, + })); + return validators; +} + +export function hydrate( + data: SolanaPreloadData | undefined, + currency: CryptoCurrency +): void { + if (data === undefined) { + return; + } + + switch (data.version) { + case "1": + hydrateV1(data, currency); + return; + case "2": + throw new Error( + "version 2 for now exists only to support discriminated union" + ); + default: + return assertUnreachable(data); + } +} + +function hydrateV1(data: SolanaPreloadDataV1, currency: CryptoCurrency) { + setPreloadData(data, currency); +} diff --git a/src/families/solana/js-prepareTransaction.ts b/src/families/solana/js-prepareTransaction.ts index 5fb160c6a8..a0bba24696 100644 --- a/src/families/solana/js-prepareTransaction.ts +++ b/src/families/solana/js-prepareTransaction.ts @@ -10,7 +10,12 @@ import BigNumber from "bignumber.js"; import { findSubAccountById } from "../../account"; import type { Account } from "../../types"; import { ChainAPI } from "./api"; -import { getMaybeTokenAccount } from "./api/chain/web3"; +import { + getMaybeTokenAccount, + getMaybeVoteAccount, + getStakeAccountAddressWithSeed, + getStakeAccountMinimumBalanceForRentExemption, +} from "./api/chain/web3"; import { SolanaAccountNotFunded, SolanaAddressOffEd25519, @@ -19,6 +24,16 @@ import { SolanaRecipientAssociatedTokenAccountWillBeFunded, SolanaTokenRecipientIsSenderATA, SolanaTokenAccounNotInitialized, + SolanaInvalidValidator, + SolanaValidatorRequired, + SolanaStakeAccountRequired, + SolanaStakeAccountNotFound, + SolanaStakeAccountNothingToWithdraw, + SolanaStakeAccountIsNotDelegatable, + SolanaStakeAccountValidatorIsUnchangeable, + SolanaStakeAccountIsNotUndelegatable, + SolanaStakeNoWithdrawAuth, + SolanaStakeNoStakeAuth, } from "./errors"; import { decodeAccountIdWithTokenAccountAddress, @@ -29,63 +44,55 @@ import { import type { CommandDescriptor, + SolanaStake, + StakeCreateAccountTransaction, + StakeDelegateTransaction, + StakeSplitTransaction, + StakeUndelegateTransaction, + StakeWithdrawTransaction, TokenCreateATATransaction, TokenRecipientDescriptor, TokenTransferTransaction, Transaction, TransactionModel, + TransferCommand, TransferTransaction, } from "./types"; import { assertUnreachable } from "./utils"; -type TransactionWithFeeCalculator = Transaction & { - feeCalculator: Exclude; -}; - async function deriveCommandDescriptor( mainAccount: Account, - tx: TransactionWithFeeCalculator, + tx: Transaction, api: ChainAPI ): Promise { - const errors: Record = {}; - const { model } = tx; switch (model.kind) { case "transfer": + return deriveTransferCommandDescriptor(mainAccount, tx, model, api); case "token.transfer": - if (!tx.recipient) { - errors.recipient = new RecipientRequired(); - } else if (mainAccount.freshAddress === tx.recipient) { - errors.recipient = new InvalidAddressBecauseDestinationIsAlsoSource(); - } else if (!isValidBase58Address(tx.recipient)) { - errors.recipient = new InvalidAddress(); - } - - if (model.uiState.memo) { - const memoBytes = Buffer.from(model.uiState.memo, "utf-8"); - if (memoBytes.byteLength > MAX_MEMO_LENGTH) { - errors.memo = errors.memo = new SolanaMemoIsTooLong(undefined, { - maxLength: MAX_MEMO_LENGTH, - }); - // LLM expects as error key to disable continue button - errors.transaction = errors.memo; - } - } - - if (Object.keys(errors).length > 0) { - return toInvalidStatusCommand(errors); - } - - return model.kind === "transfer" - ? deriveTransferCommandDescriptor(mainAccount, tx, model, api) - : deriveTokenTransferCommandDescriptor(mainAccount, tx, model, api); + return deriveTokenTransferCommandDescriptor(mainAccount, tx, model, api); case "token.createATA": return deriveCreateAssociatedTokenAccountCommandDescriptor( mainAccount, model, api ); + case "stake.createAccount": + return deriveStakeCreateAccountCommandDescriptor( + mainAccount, + tx, + model, + api + ); + case "stake.delegate": + return deriveStakeDelegateCommandDescriptor(mainAccount, model, api); + case "stake.undelegate": + return deriveStakeUndelegateCommandDescriptor(mainAccount, model, api); + case "stake.withdraw": + return deriveStakeWithdrawCommandDescriptor(mainAccount, tx, model, api); + case "stake.split": + return deriveStakeSplitCommandDescriptor(mainAccount, tx, model, api); default: return assertUnreachable(model); } @@ -96,21 +103,7 @@ const prepareTransaction = async ( tx: Transaction, api: ChainAPI ): Promise => { - const patch: Partial = {}; - const errors: Record = {}; - - const feeCalculator = tx.feeCalculator ?? (await api.getTxFeeCalculator()); - - if (tx.feeCalculator === undefined) { - patch.feeCalculator = feeCalculator; - // LLM requires this field to be truthy to show fees - (patch as any).networkInfo = true; - } - - const txToDeriveFrom = { - ...updateModelIfSubAccountIdPresent(tx), - feeCalculator, - }; + const txToDeriveFrom = updateModelIfSubAccountIdPresent(tx); const commandDescriptor = await deriveCommandDescriptor( mainAccount, @@ -118,48 +111,20 @@ const prepareTransaction = async ( api ); - if (commandDescriptor.status === "invalid") { - return toInvalidTx( - tx, - patch, - commandDescriptor.errors, - commandDescriptor.warnings - ); - } - - const command = commandDescriptor.command; - switch (command.kind) { - case "transfer": { - const totalSpend = command.amount + feeCalculator.lamportsPerSignature; - if (mainAccount.balance.lt(totalSpend)) { - errors.amount = new NotEnoughBalance(); - } - break; - } - default: { - const totalFees = - feeCalculator.lamportsPerSignature + (commandDescriptor.fees ?? 0); - if (mainAccount.balance.lt(totalFees)) { - errors.amount = new NotEnoughBalance(); - } - } - } - - if (Object.keys(errors).length > 0) { - return toInvalidTx(tx, patch, errors); - } - - patch.model = { + const model: TransactionModel = { ...tx.model, commandDescriptor, }; - return Object.keys(patch).length > 0 - ? { - ...tx, - ...patch, - } - : tx; + const preparedTx: Transaction = { + ...tx, + model, + }; + + // LLM requires this field to be truthy to show fees + (preparedTx as any).networkInfo = true; + + return preparedTx; }; const deriveTokenTransferCommandDescriptor = async ( @@ -180,6 +145,14 @@ const deriveTokenTransferCommandDescriptor = async ( throw new Error("subaccount not found"); } + await validateRecipientCommon(mainAccount, tx, errors, warnings, api); + + const memo = model.uiState.memo; + + if (typeof memo === "string" && memo.length > 0) { + validateMemoCommon(memo, errors); + } + const tokenIdParts = subAccount.token.id.split("/"); const mintAddress = tokenIdParts[tokenIdParts.length - 1]; const mintDecimals = subAccount.token.units[0].magnitude; @@ -187,51 +160,57 @@ const deriveTokenTransferCommandDescriptor = async ( const senderAssociatedTokenAccountAddress = decodeAccountIdWithTokenAccountAddress(subAccount.id).address; - if (tx.recipient === senderAssociatedTokenAccountAddress) { + if ( + !errors.recipient && + tx.recipient === senderAssociatedTokenAccountAddress + ) { errors.recipient = new SolanaTokenRecipientIsSenderATA(); - return toInvalidStatusCommand(errors, warnings); } - const recipientDescriptor = await getTokenRecipient( - tx.recipient, - mintAddress, - api - ); + const defaultRecipientDescriptor: TokenRecipientDescriptor = { + shouldCreateAsAssociatedTokenAccount: false, + tokenAccAddress: "", + walletAddress: "", + }; - if (recipientDescriptor instanceof Error) { - errors.recipient = recipientDescriptor; - return toInvalidStatusCommand(errors, warnings); + const recipientDescriptorOrError = errors.recipient + ? defaultRecipientDescriptor + : await getTokenRecipient(tx.recipient, mintAddress, api); + + if (!errors.recipient && recipientDescriptorOrError instanceof Error) { + errors.recipient = recipientDescriptorOrError; } - const fees = recipientDescriptor.shouldCreateAsAssociatedTokenAccount - ? await api.getAssocTokenAccMinNativeBalance() - : 0; + const recipientDescriptor: TokenRecipientDescriptor = + recipientDescriptorOrError instanceof Error + ? defaultRecipientDescriptor + : recipientDescriptorOrError; + + // TODO: check if SOL balance enough to pay fees + const txFee = (await api.getTxFeeCalculator()).lamportsPerSignature; + const assocAccRentExempt = + recipientDescriptor.shouldCreateAsAssociatedTokenAccount + ? await api.getAssocTokenAccMinNativeBalance() + : 0; if (recipientDescriptor.shouldCreateAsAssociatedTokenAccount) { - warnings.recipientAssociatedTokenAccount = + warnings.recipient = new SolanaRecipientAssociatedTokenAccountWillBeFunded(); - - if (!(await isAccountFunded(tx.recipient, api))) { - warnings.recipient = new SolanaAccountNotFunded(); - } } if (!tx.useAllAmount && tx.amount.lte(0)) { errors.amount = new AmountRequired(); - return toInvalidStatusCommand(errors, warnings); } const txAmount = tx.useAllAmount ? subAccount.spendableBalance.toNumber() : tx.amount.toNumber(); - if (txAmount > subAccount.spendableBalance.toNumber()) { + if (!errors.amount && txAmount > subAccount.spendableBalance.toNumber()) { errors.amount = new NotEnoughBalance(); - return toInvalidStatusCommand(errors, warnings); } return { - status: "valid", command: { kind: "token.transfer", ownerAddress: mainAccount.freshAddress, @@ -242,8 +221,9 @@ const deriveTokenTransferCommandDescriptor = async ( recipientDescriptor: recipientDescriptor, memo: model.uiState.memo, }, - fees, + fee: txFee + assocAccRentExempt, warnings, + errors, }; }; @@ -309,91 +289,315 @@ async function deriveCreateAssociatedTokenAccountCommandDescriptor( mint ); - const fees = await api.getAssocTokenAccMinNativeBalance(); + const txFee = (await api.getTxFeeCalculator()).lamportsPerSignature; + const assocAccRentExempt = await api.getAssocTokenAccMinNativeBalance(); return { - status: "valid", - fees, + fee: txFee + assocAccRentExempt, command: { kind: model.kind, mint: mint, owner: mainAccount.freshAddress, associatedTokenAccountAddress, }, + warnings: {}, + errors: {}, }; } async function deriveTransferCommandDescriptor( mainAccount: Account, - tx: TransactionWithFeeCalculator, + tx: Transaction, model: TransactionModel & { kind: TransferTransaction["kind"] }, api: ChainAPI ): Promise { const errors: Record = {}; const warnings: Record = {}; - if (!isEd25519Address(tx.recipient)) { - warnings.recipientOffCurve = new SolanaAddressOffEd25519(); + await validateRecipientCommon(mainAccount, tx, errors, warnings, api); + + const memo = model.uiState.memo; + + if (typeof memo === "string" && memo.length > 0) { + validateMemoCommon(memo, errors); } - const recipientWalletIsUnfunded = !(await isAccountFunded(tx.recipient, api)); - if (recipientWalletIsUnfunded) { - warnings.recipient = new SolanaAccountNotFunded(); + const fee = (await api.getTxFeeCalculator()).lamportsPerSignature; + + const txAmount = tx.useAllAmount + ? BigNumber.max(mainAccount.balance.minus(fee), 0) + : tx.amount; + + if (tx.useAllAmount) { + if (txAmount.eq(0)) { + errors.amount = new NotEnoughBalance(); + } + } else { + if (txAmount.lte(0)) { + errors.amount = new AmountRequired(); + } else if (txAmount.plus(fee).gt(mainAccount.balance)) { + errors.amount = new NotEnoughBalance(); + } } + const command: TransferCommand = { + kind: "transfer", + amount: txAmount.toNumber(), + sender: mainAccount.freshAddress, + recipient: tx.recipient, + memo: model.uiState.memo, + }; + + return { + command, + fee, + warnings, + errors, + }; +} + +async function deriveStakeCreateAccountCommandDescriptor( + mainAccount: Account, + tx: Transaction, + model: TransactionModel & { kind: StakeCreateAccountTransaction["kind"] }, + api: ChainAPI +): Promise { + const errors: Record = {}; if (!tx.useAllAmount && tx.amount.lte(0)) { errors.amount = new AmountRequired(); - return toInvalidStatusCommand(errors, warnings); } - const fee = tx.feeCalculator.lamportsPerSignature; + const txFee = (await api.getTxFeeCalculator()).lamportsPerSignature; + const stakeAccRentExemptAmount = + await getStakeAccountMinimumBalanceForRentExemption(api); - const txAmount = tx.useAllAmount + const fee = txFee + stakeAccRentExemptAmount; + + const amount = tx.useAllAmount ? BigNumber.max(mainAccount.balance.minus(fee), 0) : tx.amount; - if (txAmount.plus(fee).gt(mainAccount.balance)) { + if (!errors.amount && mainAccount.balance.lt(amount.plus(fee))) { errors.amount = new NotEnoughBalance(); - return toInvalidStatusCommand(errors, warnings); } + const { + uiState: { delegate }, + } = model; + + await validateValidatorCommon(delegate.voteAccAddress, errors, api); + + const { addr: stakeAccAddress, seed: stakeAccAddressSeed } = + await nextStakeAccAddr(mainAccount); + return { - status: "valid", command: { - kind: "transfer", - sender: mainAccount.freshAddress, - recipient: tx.recipient, - amount: txAmount.toNumber(), - memo: model.uiState.memo, + kind: "stake.createAccount", + amount: amount.toNumber(), + stakeAccRentExemptAmount, + fromAccAddress: mainAccount.freshAddress, + stakeAccAddress, + delegate, + seed: stakeAccAddressSeed, }, - warnings: Object.keys(warnings).length > 0 ? warnings : undefined, + fee, + warnings: {}, + errors, }; } -function toInvalidTx( - tx: Transaction, - patch: Partial, - errors: Record, - warnings?: Record -): Transaction { +async function deriveStakeDelegateCommandDescriptor( + mainAccount: Account, + model: TransactionModel & { kind: StakeDelegateTransaction["kind"] }, + api: ChainAPI +): Promise { + const errors: Record = {}; + + const { uiState } = model; + + const stake = validateAndTryGetStakeAccount( + mainAccount, + uiState.stakeAccAddr, + errors + ); + + if (stake !== undefined && !stake.hasStakeAuth && !stake.hasWithdrawAuth) { + errors.stakeAccAddr = new SolanaStakeNoStakeAuth(); + } + + await validateValidatorCommon(uiState.voteAccAddr, errors, api); + + if (!errors.voteAccAddr && stake !== undefined) { + switch (stake.activation.state) { + case "active": + case "activating": + errors.stakeAccAddr = new SolanaStakeAccountIsNotDelegatable(); + break; + case "inactive": + break; + case "deactivating": + if (stake.delegation?.voteAccAddr !== uiState.voteAccAddr) { + errors.stakeAccAddr = new SolanaStakeAccountValidatorIsUnchangeable(); + } + break; + default: + return assertUnreachable(stake.activation.state); + } + } + + const txFee = (await api.getTxFeeCalculator()).lamportsPerSignature; + + if (mainAccount.balance.lt(txFee)) { + errors.fee = new NotEnoughBalance(); + } + return { - ...tx, - ...patch, - model: { - ...tx.model, - commandDescriptor: toInvalidStatusCommand(errors, warnings), + command: { + kind: "stake.delegate", + authorizedAccAddr: mainAccount.freshAddress, + stakeAccAddr: uiState.stakeAccAddr, + voteAccAddr: uiState.voteAccAddr, }, + fee: txFee, + warnings: {}, + errors, }; } -function toInvalidStatusCommand( - errors: Record, - warnings?: Record -) { +async function deriveStakeUndelegateCommandDescriptor( + mainAccount: Account, + model: TransactionModel & { kind: StakeUndelegateTransaction["kind"] }, + api: ChainAPI +): Promise { + const errors: Record = {}; + + const { uiState } = model; + + const stake = validateAndTryGetStakeAccount( + mainAccount, + uiState.stakeAccAddr, + errors + ); + + if (stake !== undefined) { + switch (stake.activation.state) { + case "active": + case "activating": + break; + case "inactive": + case "deactivating": + errors.stakeAccAddr = new SolanaStakeAccountIsNotUndelegatable(); + break; + default: + return assertUnreachable(stake.activation.state); + } + + if (!errors.stakeAccAddr && !stake.hasStakeAuth && !stake.hasWithdrawAuth) { + errors.stakeAccAddr = new SolanaStakeNoStakeAuth(); + } + } + + const txFee = (await api.getTxFeeCalculator()).lamportsPerSignature; + + if (mainAccount.balance.lt(txFee)) { + errors.fee = new NotEnoughBalance(); + } + return { - status: "invalid" as const, + command: { + kind: "stake.undelegate", + authorizedAccAddr: mainAccount.freshAddress, + stakeAccAddr: uiState.stakeAccAddr, + }, + fee: txFee, + warnings: {}, errors, - warnings, + }; +} + +async function deriveStakeWithdrawCommandDescriptor( + mainAccount: Account, + tx: Transaction, + model: TransactionModel & { kind: StakeWithdrawTransaction["kind"] }, + api: ChainAPI +): Promise { + const errors: Record = {}; + const { uiState } = model; + + const stake = validateAndTryGetStakeAccount( + mainAccount, + uiState.stakeAccAddr, + errors + ); + + if (!errors.stakeAccAddr && stake !== undefined) { + if (!stake.hasWithdrawAuth) { + errors.stakeAccAddr = new SolanaStakeNoWithdrawAuth(); + } else if (stake.withdrawable <= 0) { + errors.stakeAccAddr = new SolanaStakeAccountNothingToWithdraw(); + } + } + + const txFee = (await api.getTxFeeCalculator()).lamportsPerSignature; + + if (mainAccount.balance.lt(txFee)) { + errors.fee = new NotEnoughBalance(); + } + + return { + command: { + kind: "stake.withdraw", + authorizedAccAddr: mainAccount.freshAddress, + stakeAccAddr: uiState.stakeAccAddr, + amount: stake?.withdrawable ?? 0, + toAccAddr: mainAccount.freshAddress, + }, + fee: txFee, + warnings: {}, + errors, + }; +} + +async function deriveStakeSplitCommandDescriptor( + mainAccount: Account, + tx: Transaction, + model: TransactionModel & { kind: StakeSplitTransaction["kind"] }, + api: ChainAPI +): Promise { + const errors: Record = {}; + + // TODO: find stake account in the main acc when synced + const { uiState } = model; + + // TODO: use all amount + if (tx.amount.lte(0)) { + errors.amount = new AmountRequired(); + } + // TODO: else if amount > stake balance + + if (!isValidBase58Address(uiState.stakeAccAddr)) { + errors.stakeAccAddr = new InvalidAddress(); + } + + mainAccount.solanaResources?.stakes ?? []; + + const commandFees = await getStakeAccountMinimumBalanceForRentExemption(api); + + const { addr: splitStakeAccAddr, seed: splitStakeAccAddrSeed } = + await nextStakeAccAddr(mainAccount); + + return { + command: { + kind: "stake.split", + authorizedAccAddr: mainAccount.freshAddress, + stakeAccAddr: uiState.stakeAccAddr, + amount: tx.amount.toNumber(), + seed: splitStakeAccAddrSeed, + splitStakeAccAddr, + }, + fee: commandFees, + warnings: {}, + errors: {}, }; } @@ -423,4 +627,123 @@ async function isAccountFunded( return balance > 0; } +async function nextStakeAccAddr(account: Account, base = "stake") { + const usedStakeAccAddrs = (account.solanaResources?.stakes ?? []).map( + (s) => s.stakeAccAddr + ); + + return nextStakeAccAddrRoutine( + account.freshAddress, + new Set(usedStakeAccAddrs), + base, + 0 + ); +} + +async function nextStakeAccAddrRoutine( + fromAddress: string, + usedAddresses: Set, + base: string, + idx: number +): Promise<{ + seed: string; + addr: string; +}> { + const seed = `${base}:${idx}`; + const addr = await getStakeAccountAddressWithSeed({ + fromAddress, + seed, + }); + + return usedAddresses.has(addr) + ? nextStakeAccAddrRoutine(fromAddress, usedAddresses, base, idx + 1) + : { + seed, + addr, + }; +} + +async function validateRecipientCommon( + mainAccount: Account, + tx: Transaction, + errors: Record, + warnings: Record, + api: ChainAPI +) { + if (!tx.recipient) { + errors.recipient = new RecipientRequired(); + } else if (mainAccount.freshAddress === tx.recipient) { + errors.recipient = new InvalidAddressBecauseDestinationIsAlsoSource(); + } else if (!isValidBase58Address(tx.recipient)) { + errors.recipient = new InvalidAddress(); + } else { + const recipientWalletIsUnfunded = !(await isAccountFunded( + tx.recipient, + api + )); + + if (recipientWalletIsUnfunded) { + warnings.recipient = new SolanaAccountNotFunded(); + } + if (!isEd25519Address(tx.recipient)) { + warnings.recipientOffCurve = new SolanaAddressOffEd25519(); + } + } +} + +function validateMemoCommon(memo: string, errors: Record) { + const memoBytes = Buffer.from(memo, "utf-8"); + if (memoBytes.byteLength > MAX_MEMO_LENGTH) { + errors.memo = errors.memo = new SolanaMemoIsTooLong(undefined, { + maxLength: MAX_MEMO_LENGTH, + }); + // LLM expects as error key to disable continue button + errors.transaction = errors.memo; + } +} + +async function validateValidatorCommon( + addr: string, + errors: Record, + api: ChainAPI +) { + if (addr.length === 0) { + errors.voteAccAddr = new SolanaValidatorRequired(); + } else if (!isValidBase58Address(addr)) { + errors.voteAccAddr = new InvalidAddress(); + } else { + const voteAcc = await getMaybeVoteAccount(addr, api); + + if (voteAcc instanceof Error || voteAcc === undefined) { + errors.voteAccAddr = new SolanaInvalidValidator(); + } + } +} + +function validateAndTryGetStakeAccount( + account: Account, + stakeAccAddr: string, + errors: Record +): SolanaStake | undefined { + if (stakeAccAddr.length === 0) { + errors.stakeAccAddr = new SolanaStakeAccountRequired(); + } else if (!isValidBase58Address(stakeAccAddr)) { + errors.stakeAccAddr = new InvalidAddress(); + } + + if (!errors.stakeAccAddr) { + const stake = account.solanaResources?.stakes.find( + (stake) => stake.stakeAccAddr === stakeAccAddr + ); + + if (stake === undefined) { + errors.stakeAccAddr = new SolanaStakeAccountNotFound(); + } + + return stake; + } + + return undefined; +} + export { prepareTransaction }; diff --git a/src/families/solana/js-signOperation.ts b/src/families/solana/js-signOperation.ts index 42577f091b..3dd77165f6 100644 --- a/src/families/solana/js-signOperation.ts +++ b/src/families/solana/js-signOperation.ts @@ -5,14 +5,18 @@ import type { OperationType, SignOperationEvent, } from "../../types"; -import { open, close } from "../../hw"; +import { withDevice } from "../../hw/deviceAccess"; import type { Command, - TokenCreateATACommand, + CommandDescriptor, + StakeCreateAccountCommand, + StakeDelegateCommand, + StakeSplitCommand, + StakeUndelegateCommand, + StakeWithdrawCommand, TokenTransferCommand, Transaction, TransferCommand, - ValidCommandDescriptor, } from "./types"; import { buildTransactionWithAPI } from "./js-buildTransaction"; import Solana from "@ledgerhq/hw-app-solana"; @@ -31,18 +35,15 @@ const buildOptimisticOperation = ( const { commandDescriptor } = transaction.model; - switch (commandDescriptor.status) { - case "valid": - return buildOptimisticOperationForCommand( - account, - transaction, - commandDescriptor - ); - case "invalid": - throw new Error("invalid command"); - default: - return assertUnreachable(commandDescriptor); + if (Object.keys(commandDescriptor.errors).length > 0) { + throw new Error("invalid command"); } + + return buildOptimisticOperationForCommand( + account, + transaction, + commandDescriptor + ); }; export const signOperationWithAPI = ( @@ -57,54 +58,51 @@ export const signOperationWithAPI = ( }, api: () => Promise ): Observable => - new Observable((subscriber) => { - const main = async () => { - const transport = await open(deviceId); + withDevice(deviceId)( + (transport) => + new Observable((subscriber) => { + const main = async () => { + const [msgToHardwareBytes, signOnChainTransaction] = + await buildTransactionWithAPI(account, transaction, await api()); - try { - const [msgToHardwareBytes, signOnChainTransaction] = - await buildTransactionWithAPI(account, transaction, await api()); + const hwApp = new Solana(transport); - const hwApp = new Solana(transport); + subscriber.next({ + type: "device-signature-requested", + }); - subscriber.next({ - type: "device-signature-requested", - }); + const { signature } = await hwApp.signTransaction( + account.freshAddressPath, + msgToHardwareBytes + ); - const { signature } = await hwApp.signTransaction( - account.freshAddressPath, - msgToHardwareBytes - ); + subscriber.next({ + type: "device-signature-granted", + }); - subscriber.next({ - type: "device-signature-granted", - }); - - const signedOnChainTxBytes = signOnChainTransaction(signature); - - subscriber.next({ - type: "signed", - signedOperation: { - operation: buildOptimisticOperation(account, transaction), - signature: signedOnChainTxBytes.toString("hex"), - expirationDate: null, - }, - }); - } finally { - close(transport, deviceId); - } - }; + const signedOnChainTxBytes = signOnChainTransaction(signature); - main().then( - () => subscriber.complete(), - (e) => subscriber.error(e) - ); - }); + subscriber.next({ + type: "signed", + signedOperation: { + operation: buildOptimisticOperation(account, transaction), + signature: signedOnChainTxBytes.toString("hex"), + expirationDate: null, + }, + }); + }; + + main().then( + () => subscriber.complete(), + (e) => subscriber.error(e) + ); + }) + ); function buildOptimisticOperationForCommand( account: Account, transaction: Transaction, - commandDescriptor: ValidCommandDescriptor + commandDescriptor: CommandDescriptor ): Operation { const { command } = commandDescriptor; switch (command.kind) { @@ -123,12 +121,26 @@ function buildOptimisticOperationForCommand( commandDescriptor ); case "token.createATA": - return optimisticOpForCATA( + return optimisticOpForCATA(account, commandDescriptor); + case "stake.createAccount": + return optimisticOpForStakeCreateAccount( account, transaction, command, commandDescriptor ); + case "stake.delegate": + return optimisticOpForStakeDelegate(account, command, commandDescriptor); + case "stake.undelegate": + return optimisticOpForStakeUndelegate( + account, + command, + commandDescriptor + ); + case "stake.withdraw": + return optimisticOpForStakeWithdraw(account, command, commandDescriptor); + case "stake.split": + return optimisticOpForStakeSplit(account, command, commandDescriptor); default: return assertUnreachable(command); } @@ -137,9 +149,9 @@ function optimisticOpForTransfer( account: Account, transaction: Transaction, command: TransferCommand, - commandDescriptor: ValidCommandDescriptor + commandDescriptor: CommandDescriptor ): Operation { - const commons = optimisticOpcommons(transaction, commandDescriptor); + const commons = optimisticOpcommons(commandDescriptor); return { ...commons, id: encodeOperationId(account.id, "", "OUT"), @@ -156,13 +168,13 @@ function optimisticOpForTokenTransfer( account: Account, transaction: Transaction, command: TokenTransferCommand, - commandDescriptor: ValidCommandDescriptor + commandDescriptor: CommandDescriptor ): Operation { if (!transaction.subAccountId) { throw new Error("sub account id is required for token transfer"); } return { - ...optimisticOpcommons(transaction, commandDescriptor), + ...optimisticOpcommons(commandDescriptor), id: encodeOperationId(account.id, "", "FEES"), type: "FEES", accountId: account.id, @@ -172,7 +184,7 @@ function optimisticOpForTokenTransfer( extra: getOpExtras(command), subOperations: [ { - ...optimisticOpcommons(transaction, commandDescriptor), + ...optimisticOpcommons(commandDescriptor), id: encodeOperationId(transaction.subAccountId, "", "OUT"), type: "OUT", accountId: transaction.subAccountId, @@ -187,36 +199,25 @@ function optimisticOpForTokenTransfer( function optimisticOpForCATA( account: Account, - transaction: Transaction, - _: TokenCreateATACommand, - commandDescriptor: ValidCommandDescriptor + commandDescriptor: CommandDescriptor ): Operation { const opType: OperationType = "OPT_IN"; return { - ...optimisticOpcommons(transaction, commandDescriptor), + ...optimisticOpcommons(commandDescriptor), id: encodeOperationId(account.id, "", opType), type: opType, accountId: account.id, senders: [], recipients: [], - value: new BigNumber(commandDescriptor.fees ?? 0), + value: new BigNumber(commandDescriptor.fee), }; } -function optimisticOpcommons( - transaction: Transaction, - commandDescriptor: ValidCommandDescriptor -) { - if (!transaction.feeCalculator) { - throw new Error("fee calculator is not loaded"); - } - const fees = - transaction.feeCalculator.lamportsPerSignature + - (commandDescriptor.fees ?? 0); +function optimisticOpcommons(commandDescriptor: CommandDescriptor) { return { hash: "", - fee: new BigNumber(fees), + fee: new BigNumber(commandDescriptor.fee), blockHash: null, blockHeight: null, date: new Date(), @@ -234,9 +235,111 @@ function getOpExtras(command: Command): Record { } break; case "token.createATA": + case "stake.createAccount": + case "stake.delegate": + case "stake.undelegate": + case "stake.withdraw": + case "stake.split": break; default: return assertUnreachable(command); } return extra; } + +function optimisticOpForStakeCreateAccount( + account: Account, + transaction: Transaction, + command: StakeCreateAccountCommand, + commandDescriptor: CommandDescriptor +): Operation { + const opType: OperationType = "DELEGATE"; + const commons = optimisticOpcommons(commandDescriptor); + + return { + ...commons, + id: encodeOperationId(account.id, "", opType), + type: opType, + accountId: account.id, + senders: [], + recipients: [], + value: new BigNumber(command.amount).plus(commons.fee), + extra: getOpExtras(command), + }; +} + +function optimisticOpForStakeDelegate( + account: Account, + command: StakeDelegateCommand, + commandDescriptor: CommandDescriptor +): Operation { + const commons = optimisticOpcommons(commandDescriptor); + const opType: OperationType = "DELEGATE"; + return { + ...commons, + id: encodeOperationId(account.id, "", opType), + type: opType, + accountId: account.id, + senders: [], + recipients: [], + value: commons.fee, + extra: getOpExtras(command), + }; +} + +function optimisticOpForStakeUndelegate( + account: Account, + command: StakeUndelegateCommand, + commandDescriptor: CommandDescriptor +): Operation { + const commons = optimisticOpcommons(commandDescriptor); + const opType: OperationType = "UNDELEGATE"; + return { + ...commons, + id: encodeOperationId(account.id, "", opType), + type: opType, + accountId: account.id, + senders: [], + recipients: [], + value: commons.fee, + extra: getOpExtras(command), + }; +} + +function optimisticOpForStakeWithdraw( + account: Account, + command: StakeWithdrawCommand, + commandDescriptor: CommandDescriptor +): Operation { + const commons = optimisticOpcommons(commandDescriptor); + const opType: OperationType = "IN"; + return { + ...commons, + id: encodeOperationId(account.id, "", opType), + type: opType, + accountId: account.id, + senders: [command.stakeAccAddr], + recipients: [command.toAccAddr], + value: new BigNumber(command.amount).minus(commons.fee), + extra: getOpExtras(command), + }; +} + +function optimisticOpForStakeSplit( + account: Account, + command: StakeSplitCommand, + commandDescriptor: CommandDescriptor +): Operation { + const commons = optimisticOpcommons(commandDescriptor); + const opType: OperationType = "OUT"; + return { + ...commons, + id: encodeOperationId(account.id, "", opType), + type: opType, + accountId: account.id, + senders: [command.stakeAccAddr], + recipients: [command.splitStakeAccAddr], + value: commons.fee, + extra: getOpExtras(command), + }; +} diff --git a/src/families/solana/js-synchronization.ts b/src/families/solana/js-synchronization.ts index 8aa92b3fa8..4eda6c5a22 100644 --- a/src/families/solana/js-synchronization.ts +++ b/src/families/solana/js-synchronization.ts @@ -9,23 +9,42 @@ import { import BigNumber from "bignumber.js"; import { emptyHistoryCache } from "../../account"; -import { getTransactions, TransactionDescriptor } from "./api/chain/web3"; +import { + getTransactions, + ParsedOnChainStakeAccountWithInfo, + toStakeAccountWithInfo, + TransactionDescriptor, +} from "./api/chain/web3"; import { getTokenById } from "@ledgerhq/cryptoassets"; import { encodeOperationId } from "../../operation"; import { Awaited, encodeAccountIdWithTokenAccountAddress, + isStakeLockUpInForce, tokenIsListedOnLedger, toTokenId, toTokenMint, + withdrawableFromStake, } from "./logic"; -/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ -import { compact, filter, groupBy, keyBy, toPairs, pipe, map } from "lodash/fp"; +import { + compact, + filter, + groupBy, + keyBy, + toPairs, + pipe, + map, + uniqBy, + flow, + sortBy, +} from "lodash/fp"; import { parseQuiet } from "./api/chain/program"; import { + InflationReward, ParsedConfirmedTransactionMeta, ParsedMessageAccount, ParsedTransaction, + StakeActivationData, } from "@solana/web3.js"; import { ChainAPI } from "./api"; import { @@ -33,6 +52,8 @@ import { /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ toTokenAccountWithInfo, } from "./api/chain/web3"; +import { drainSeq } from "./utils"; +import { SolanaStake } from "./types"; type OnChainTokenAccount = Awaited< ReturnType @@ -54,6 +75,7 @@ export const getAccountShapeWithAPI = async ( balance: mainAccBalance, spendableBalance: mainAccSpendableBalance, tokenAccounts: onChaintokenAccounts, + stakes: onChainStakes, } = await getAccount(mainAccAddress, api); const mainAccountId = encodeAccountId({ @@ -123,6 +145,65 @@ export const getAccountShapeWithAPI = async ( nextSubAccs.push(nextSubAcc); } + const { epoch } = await api.getEpochInfo(); + + const stakes: SolanaStake[] = onChainStakes.map( + ({ account, activation, reward }) => { + const { + info: { meta, stake }, + } = account; + const rentExemptReserve = account.info.meta.rentExemptReserve.toNumber(); + const stakeAccBalance = account.onChainAcc.account.lamports; + const hasWithdrawAuth = + meta.authorized.withdrawer.toBase58() === mainAccAddress && + !isStakeLockUpInForce({ + lockup: meta.lockup, + custodianAddress: mainAccAddress, + epoch, + }); + return { + stakeAccAddr: account.onChainAcc.pubkey.toBase58(), + stakeAccBalance, + rentExemptReserve, + hasStakeAuth: meta.authorized.staker.toBase58() === mainAccAddress, + hasWithdrawAuth, + delegation: + stake === null + ? undefined + : { + stake: + activation.state === "inactive" + ? 0 + : stake.delegation.stake.toNumber(), + voteAccAddr: stake.delegation.voter.toBase58(), + }, + activation, + withdrawable: hasWithdrawAuth + ? withdrawableFromStake({ + stakeAccBalance, + activation, + rentExemptReserve, + }) + : 0, + reward: + reward === null + ? undefined + : { + amount: reward.amount, + }, + }; + } + ); + + const sortedStakes = flow( + () => stakes, + sortBy([ + (stake) => -(stake.delegation?.stake ?? 0), + (stake) => -stake.withdrawable, + (stake) => -stake.stakeAccAddr, + ]) + )(); + const mainAccountLastTxSignature = mainInitialAcc?.operations[0]?.hash; const newMainAccTxs = await getTransactions( @@ -150,6 +231,9 @@ export const getAccountShapeWithAPI = async ( spendableBalance: mainAccSpendableBalance, operations: mainAccTotalOperations, operationsCount: mainAccTotalOperations.length, + solanaResources: { + stakes: sortedStakes, + }, }; return shape; @@ -263,26 +347,28 @@ function txToMainAccOperation( balanceDelta, }); - const { senders, recipients } = message.accountKeys.reduce( - (acc, account, i) => { - const delta = new BigNumber(postBalances[i]).minus( - new BigNumber(preBalances[i]) - ); - if (delta.lt(0)) { - const shouldConsiderAsSender = i > 0 || !delta.negated().eq(txFee); - if (shouldConsiderAsSender) { - acc.senders.push(account.pubkey.toBase58()); - } - } else if (delta.gt(0)) { - acc.recipients.push(account.pubkey.toBase58()); - } - return acc; - }, - { - senders: [] as string[], - recipients: [] as string[], - } - ); + const accum = { + senders: [] as string[], + recipients: [] as string[], + }; + + const { senders, recipients } = + opType === "IN" || opType === "OUT" + ? message.accountKeys.reduce((acc, account, i) => { + const delta = new BigNumber(postBalances[i]).minus( + new BigNumber(preBalances[i]) + ); + if (delta.lt(0)) { + const shouldConsiderAsSender = i > 0 || !delta.negated().eq(txFee); + if (shouldConsiderAsSender) { + acc.senders.push(account.pubkey.toBase58()); + } + } else if (delta.gt(0)) { + acc.recipients.push(account.pubkey.toBase58()); + } + return acc; + }, accum) + : accum; const txHash = tx.info.signature; const txDate = new Date(tx.info.blockTime * 1000); @@ -406,42 +492,59 @@ function getMainAccOperationTypeFromTx( tx: ParsedTransaction ): OperationType | undefined { const { instructions } = tx.message; - const [mainIx, ...otherIxs] = instructions + + const parsedIxs = instructions .map((ix) => parseQuiet(ix)) .filter(({ program }) => program !== "spl-memo"); - if (mainIx === undefined || otherIxs.length > 0) { - return undefined; + if (parsedIxs.length === 3) { + const [first, second, third] = parsedIxs; + if ( + first.program === "system" && + (first.instruction.type === "createAccountWithSeed" || + first.instruction.type === "createAccount") && + second.program === "stake" && + second.instruction.type === "initialize" && + third.program === "stake" && + third.instruction.type === "delegate" + ) { + return "DELEGATE"; + } } - switch (mainIx.program) { - case "spl-associated-token-account": - switch (mainIx.instruction.type) { - case "associate": - return "OPT_IN"; - } - // needed for lint - break; - case "spl-token": - switch (mainIx.instruction.type) { - case "closeAccount": - return "OPT_OUT"; - } - break; - // disabled until staking support - /* - case "stake": - switch (mainIx.instruction.type) { - case "delegate": - return "DELEGATE"; - case "deactivate": - return "UNDELEGATE"; - } - break; - */ - default: - return undefined; + if (parsedIxs.length === 1) { + const first = parsedIxs[0]; + + switch (first.program) { + case "spl-associated-token-account": + switch (first.instruction.type) { + case "associate": + return "OPT_IN"; + } + // needed for lint + break; + case "spl-token": + switch (first.instruction.type) { + case "closeAccount": + return "OPT_OUT"; + } + break; + case "stake": + switch (first.instruction.type) { + case "delegate": + return "DELEGATE"; + case "deactivate": + return "UNDELEGATE"; + case "withdraw": + return "IN"; + } + break; + default: + return undefined; + } } + + return undefined; } function getTokenSendersRecipients({ @@ -525,6 +628,11 @@ async function getAccount( spendableBalance: BigNumber; blockHeight: number; tokenAccounts: ParsedOnChainTokenAccountWithInfo[]; + stakes: { + account: ParsedOnChainStakeAccountWithInfo; + activation: StakeActivationData; + reward: InflationReward | null; + }[]; }> { const balanceLamportsWithContext = await api.getBalanceAndContext(address); @@ -537,13 +645,46 @@ async function getAccount( .then(map(toTokenAccountWithInfo)); */ + const stakeAccountsRaw = [ + ...(await api.getStakeAccountsByStakeAuth(address)), + ...(await api.getStakeAccountsByWithdrawAuth(address)), + ]; + + const stakeAccounts = flow( + () => stakeAccountsRaw, + uniqBy((acc) => acc.pubkey.toBase58()), + map(toStakeAccountWithInfo), + compact + )(); + + /* + Ledger team still decides if we should show rewards + const stakeRewards = await api.getInflationReward( + stakeAccounts.map(({ onChainAcc }) => onChainAcc.pubkey.toBase58()) + ); + */ + + const stakes = await drainSeq( + stakeAccounts.map((account) => async () => { + return { + account, + activation: await api.getStakeActivation( + account.onChainAcc.pubkey.toBase58() + ), + //reward: stakeRewards[idx], + reward: null, + }; + }) + ); + const balance = new BigNumber(balanceLamportsWithContext.value); const blockHeight = balanceLamportsWithContext.context.slot; return { - tokenAccounts, balance, spendableBalance: balance, blockHeight, + tokenAccounts, + stakes, }; } diff --git a/src/families/solana/logic.ts b/src/families/solana/logic.ts index 43711be6ec..6257973836 100644 --- a/src/families/solana/logic.ts +++ b/src/families/solana/logic.ts @@ -1,6 +1,9 @@ import { findTokenById } from "@ledgerhq/cryptoassets"; import { PublicKey } from "@solana/web3.js"; import { TokenAccount } from "../../types/account"; +import { StakeMeta } from "./api/chain/account/stake"; +import { SolanaStake } from "./types"; +import { assertUnreachable } from "./utils"; export type Awaited = T extends PromiseLike ? U : T; @@ -50,3 +53,80 @@ export function toSubAccMint(subAcc: TokenAccount): string { export function tokenIsListedOnLedger(mint: string): boolean { return findTokenById(toTokenId(mint))?.type === "TokenCurrency"; } + +type StakeAction = "deactivate" | "activate" | "withdraw" | "reactivate"; + +export function stakeActions(stake: SolanaStake): StakeAction[] { + const actions: StakeAction[] = []; + + if (stake.withdrawable > 0) { + actions.push("withdraw"); + } + + switch (stake.activation.state) { + case "active": + case "activating": + actions.push("deactivate"); + break; + case "deactivating": + actions.push("reactivate"); + break; + case "inactive": + actions.push("activate"); + break; + default: + return assertUnreachable(stake.activation.state); + } + + return actions; +} + +export function withdrawableFromStake({ + stakeAccBalance, + activation, + rentExemptReserve, +}: { + stakeAccBalance: number; + activation: SolanaStake["activation"]; + rentExemptReserve: number; +}) { + switch (activation.state) { + case "active": + case "activating": + return ( + stakeAccBalance - + rentExemptReserve - + activation.active - + activation.inactive + ); + case "deactivating": + return stakeAccBalance - rentExemptReserve - activation.active; + case "inactive": + return stakeAccBalance; + default: + return assertUnreachable(activation.state); + } +} + +export function isStakeLockUpInForce({ + lockup, + custodianAddress, + epoch, +}: { + lockup: StakeMeta["lockup"]; + custodianAddress: string; + epoch: number; +}) { + if (custodianAddress === lockup.custodian.toBase58()) { + return false; + } + return lockup.unixTimestamp > Date.now() / 1000 || lockup.epoch > epoch; +} + +export function stakeActivePercent(stake: SolanaStake) { + const amount = stake.delegation?.stake; + if (amount === undefined || amount === 0) { + return 0; + } + return (stake.activation.active / amount) * 100; +} diff --git a/src/families/solana/react.ts b/src/families/solana/react.ts new file mode 100644 index 0000000000..403a06d85f --- /dev/null +++ b/src/families/solana/react.ts @@ -0,0 +1,81 @@ +import { CryptoCurrency } from "@ledgerhq/cryptoassets"; +import { shuffle } from "lodash/fp"; +import { useMemo } from "react"; +import { useObservable } from "../../observable"; +import { + getCurrentSolanaPreloadData, + getSolanaPreloadData, +} from "./js-preload-data"; +import { SolanaPreloadDataV1, SolanaStake, SolanaStakeWithMeta } from "./types"; +import { LEDGER_VALIDATOR_ADDRESS, swap } from "./utils"; +import { ValidatorsAppValidator } from "./validator-app"; + +export function useSolanaPreloadData( + currency: CryptoCurrency +): SolanaPreloadDataV1 | undefined | null { + return useObservable( + getSolanaPreloadData(currency), + getCurrentSolanaPreloadData(currency) + ); +} + +export function useLedgerFirstShuffledValidators(currency: CryptoCurrency) { + const data = useSolanaPreloadData(currency); + + return useMemo(() => { + return reorderValidators(data?.validators ?? []); + }, [data]); +} + +export function useSolanaStakesWithMeta( + currency: CryptoCurrency, + stakes: SolanaStake[] +): SolanaStakeWithMeta[] { + const data = useSolanaPreloadData(currency); + + if (data === null || data === undefined) { + return []; + } + + const { validators } = data; + + const validatorByVoteAccAddr = new Map( + validators.map((v) => [v.voteAccount, v]) + ); + + return stakes.map((stake) => { + const voteAccAddr = stake.delegation?.voteAccAddr; + const validator = + voteAccAddr === undefined + ? undefined + : validatorByVoteAccAddr.get(voteAccAddr); + + return { + stake, + meta: { + validator: { + img: validator?.avatarUrl, + name: validator?.name, + url: validator?.wwwUrl, + }, + }, + }; + }); +} + +function reorderValidators( + validators: ValidatorsAppValidator[] +): ValidatorsAppValidator[] { + const shuffledValidators = shuffle(validators); + + // move Ledger validator to the first position + const ledgerValidatorIdx = shuffledValidators.findIndex( + (v) => v.voteAccount === LEDGER_VALIDATOR_ADDRESS + ); + + if (ledgerValidatorIdx !== -1) { + swap(shuffledValidators, ledgerValidatorIdx, 0); + } + + return shuffledValidators; +} diff --git a/src/families/solana/serialization.ts b/src/families/solana/serialization.ts new file mode 100644 index 0000000000..799e8ee04e --- /dev/null +++ b/src/families/solana/serialization.ts @@ -0,0 +1,17 @@ +import { SolanaResources, SolanaResourcesRaw } from "./types"; + +export function toSolanaResourcesRaw( + resources: SolanaResources +): SolanaResourcesRaw { + return { + stakes: JSON.stringify(resources.stakes), + }; +} + +export function fromSolanaResourcesRaw( + resourcesRaw: SolanaResourcesRaw +): SolanaResources { + return { + stakes: JSON.parse(resourcesRaw.stakes), + }; +} diff --git a/src/families/solana/specs.ts b/src/families/solana/specs.ts index c77f21a2a2..3e4517436e 100644 --- a/src/families/solana/specs.ts +++ b/src/families/solana/specs.ts @@ -5,16 +5,27 @@ import { DeviceModelId } from "@ledgerhq/devices"; import { pickSiblings } from "../../bot/specs"; import { AppSpec, TransactionTestInput } from "../../bot/types"; import { Transaction } from "./types"; -import { acceptTransferTransaction } from "./speculos-deviceActions"; +import { + acceptStakeCreateAccountTransaction, + acceptStakeDelegateTransaction, + acceptStakeUndelegateTransaction, + acceptStakeWithdrawTransaction, + acceptTransferTransaction, +} from "./speculos-deviceActions"; import { assertUnreachable } from "./utils"; +import { getCurrentSolanaPreloadData } from "./js-preload-data"; +import { sample } from "lodash/fp"; +import BigNumber from "bignumber.js"; const solana: AppSpec = { name: "Solana", appQuery: { model: DeviceModelId.nanoS, firmware: "2", + appVersion: "1.2.0", appName: "solana", }, + testTimeout: 2 * 60 * 1000, currency: getCryptoCurrencyById("solana"), mutations: [ { @@ -58,6 +69,400 @@ const solana: AppSpec = { expectCorrectMemo(input); }, }, + { + name: "Delegate", + maxRun: 1, + deviceAction: acceptStakeCreateAccountTransaction, + transaction: ({ account, bridge }) => { + const { solanaResources } = account; + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + invariant( + solanaResources.stakes.length < 10, + "already enough delegations" + ); + + invariant(account.balance.gte(3000000), "not enough balance"); + + const { validators } = getCurrentSolanaPreloadData(account.currency); + + const notUsedValidators = validators.filter((v) => + solanaResources.stakes.every( + (s) => s.delegation?.voteAccAddr !== v.voteAccount + ) + ); + + const validator = sample(notUsedValidators); + + if (validator === undefined) { + throw new Error("no not used validators found"); + } + + const transaction = bridge.createTransaction(account); + + return { + transaction, + updates: [ + { + model: { + kind: "stake.createAccount", + uiState: { + delegate: { voteAccAddress: validator.voteAccount }, + }, + }, + }, + { + amount: new BigNumber(100000), + }, + ], + }; + }, + test: ({ account, transaction }) => { + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + if (transaction.model.kind !== "stake.createAccount") { + throw new Error("wrong transaction"); + } + + const voteAccAddrUsedInTx = + transaction.model.uiState.delegate.voteAccAddress; + + const { stakes } = solanaResources; + const stake = stakes.find( + (s) => s.delegation?.voteAccAddr === voteAccAddrUsedInTx + ); + if (stake === undefined) { + throw new Error("expected delegation not found in account resources"); + } + + expect(transaction.amount.toNumber()).toBe(stake.delegation?.stake); + }, + }, + { + name: "Deactivate Activating Delegation", + maxRun: 1, + deviceAction: acceptStakeUndelegateTransaction, + transaction: ({ account, bridge }) => { + invariant(account.balance.gt(0), "not enough balance"); + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + const activatingStakes = solanaResources.stakes.filter( + (s) => s.activation.state === "activating" + ); + + const stake = sample(activatingStakes); + + if (stake === undefined) { + throw new Error("no activating stakes found"); + } + + const transaction = bridge.createTransaction(account); + + return { + transaction, + updates: [ + { + model: { + kind: "stake.undelegate", + uiState: { + stakeAccAddr: stake.stakeAccAddr, + }, + }, + }, + ], + }; + }, + test: ({ account, transaction }) => { + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + if (transaction.model.kind !== "stake.undelegate") { + throw new Error("wrong transaction"); + } + + const stakeAccAddrUsedInTx = transaction.model.uiState.stakeAccAddr; + + const stake = solanaResources.stakes.find( + (s) => s.stakeAccAddr === stakeAccAddrUsedInTx + ); + + if (stake === undefined) { + throw new Error("expected stake not found in account resources"); + } + + expect(stake.activation.state).toBe("inactive"); + }, + }, + { + name: "Deactivate Active Delegation", + maxRun: 1, + deviceAction: acceptStakeUndelegateTransaction, + transaction: ({ account, bridge }) => { + invariant(account.balance.gt(0), "not enough balance"); + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + const activeStakes = solanaResources.stakes.filter( + (s) => s.activation.state === "active" + ); + + const stake = sample(activeStakes); + + if (stake === undefined) { + throw new Error("no active stakes found"); + } + + const transaction = bridge.createTransaction(account); + + return { + transaction, + updates: [ + { + model: { + kind: "stake.undelegate", + uiState: { + stakeAccAddr: stake.stakeAccAddr, + }, + }, + }, + ], + }; + }, + test: ({ account, transaction }) => { + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + if (transaction.model.kind !== "stake.undelegate") { + throw new Error("wrong transaction"); + } + + const stakeAccAddrUsedInTx = transaction.model.uiState.stakeAccAddr; + + const stake = solanaResources.stakes.find( + (s) => s.stakeAccAddr === stakeAccAddrUsedInTx + ); + + if (stake === undefined) { + throw new Error("expected stake not found in account resources"); + } + + expect(stake.activation.state).toBe("deactivating"); + }, + }, + { + name: "Reactivate Deactivating Delegation", + maxRun: 1, + deviceAction: acceptStakeDelegateTransaction, + transaction: ({ account, bridge }) => { + invariant(account.balance.gt(0), "not enough balance"); + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + const deactivatingStakes = solanaResources.stakes.filter( + (s) => s.activation.state === "deactivating" + ); + + const stake = sample(deactivatingStakes); + + if (stake === undefined) { + throw new Error("no deactivating stakes found"); + } + + if (stake.delegation === undefined) { + throw new Error("unexpected undefined delegation"); + } + + const transaction = bridge.createTransaction(account); + + return { + transaction, + updates: [ + { + model: { + kind: "stake.delegate", + uiState: { + stakeAccAddr: stake.stakeAccAddr, + voteAccAddr: stake.delegation.voteAccAddr, + }, + }, + }, + ], + }; + }, + test: ({ account, transaction }) => { + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + if (transaction.model.kind !== "stake.delegate") { + throw new Error("wrong transaction"); + } + + const dataUsedInTx = transaction.model.uiState; + + const stake = solanaResources.stakes.find( + (s) => + s.stakeAccAddr === dataUsedInTx.stakeAccAddr && + s.delegation?.voteAccAddr === dataUsedInTx.voteAccAddr + ); + + if (stake === undefined) { + throw new Error("expected stake not found in account resources"); + } + + expect(stake.activation.state).toBe("active"); + }, + }, + { + name: "Activate Inactive Delegation", + maxRun: 1, + deviceAction: acceptStakeDelegateTransaction, + transaction: ({ account, bridge }) => { + invariant(account.balance.gt(0), "not enough balance"); + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + const inactiveStakes = solanaResources.stakes.filter( + (s) => s.activation.state === "inactive" + ); + + const stake = sample(inactiveStakes); + + if (stake === undefined) { + throw new Error("no inactive stakes found"); + } + + if (stake.delegation === undefined) { + throw new Error("unexpected undefined delegation"); + } + + const transaction = bridge.createTransaction(account); + + return { + transaction, + updates: [ + { + model: { + kind: "stake.delegate", + uiState: { + stakeAccAddr: stake.stakeAccAddr, + voteAccAddr: stake.delegation.voteAccAddr, + }, + }, + }, + ], + }; + }, + test: ({ account, transaction }) => { + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + if (transaction.model.kind !== "stake.delegate") { + throw new Error("wrong transaction"); + } + + const dataUsedInTx = transaction.model.uiState; + + const stake = solanaResources.stakes.find( + (s) => + s.stakeAccAddr === dataUsedInTx.stakeAccAddr && + s.delegation?.voteAccAddr === dataUsedInTx.voteAccAddr + ); + + if (stake === undefined) { + throw new Error("expected stake not found in account resources"); + } + + expect(stake.activation.state).toBe("activating"); + }, + }, + { + name: "Withdraw Delegation", + maxRun: 1, + deviceAction: acceptStakeWithdrawTransaction, + transaction: ({ account, bridge }) => { + invariant(account.balance.gt(0), "not enough balance"); + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + const withdrawableStakes = solanaResources.stakes.filter( + (s) => s.withdrawable > 0 + ); + + const stake = sample(withdrawableStakes); + + if (stake === undefined) { + throw new Error("no withdrawable stakes found"); + } + + const transaction = bridge.createTransaction(account); + + return { + transaction, + updates: [ + { + model: { + kind: "stake.withdraw", + uiState: { + stakeAccAddr: stake.stakeAccAddr, + }, + }, + }, + ], + }; + }, + test: ({ account, transaction }) => { + const { solanaResources } = account; + + if (solanaResources === undefined) { + throw new Error("solana resources required"); + } + + if (transaction.model.kind !== "stake.withdraw") { + throw new Error("wrong transaction"); + } + + const stakeAccAddrUsedInTx = transaction.model.uiState.stakeAccAddr; + + const delegationExists = solanaResources.stakes.some( + (s) => s.stakeAccAddr === stakeAccAddrUsedInTx + ); + + expect(delegationExists).toBe(false); + }, + }, ], }; @@ -82,6 +487,11 @@ function expectCorrectMemo(input: TransactionTestInput) { expect(operation.extra.memo).toBe(transaction.model.uiState.memo); break; case "token.createATA": + case "stake.createAccount": + case "stake.delegate": + case "stake.undelegate": + case "stake.withdraw": + case "stake.split": break; default: return assertUnreachable(transaction.model); diff --git a/src/families/solana/speculos-deviceActions.ts b/src/families/solana/speculos-deviceActions.ts index cb34ee8138..39a75ab5cd 100644 --- a/src/families/solana/speculos-deviceActions.ts +++ b/src/families/solana/speculos-deviceActions.ts @@ -3,6 +3,7 @@ import type { Transaction } from "./types"; import { formatCurrencyUnit } from "../../currencies"; import { deviceActionFlow } from "../../bot/specs"; import { CryptoCurrency, getCryptoCurrencyById } from "@ledgerhq/cryptoassets"; +import BigNumber from "bignumber.js"; function getMainCurrency(currency: CryptoCurrency) { if (currency.isTestnetFor !== undefined) { @@ -15,36 +16,135 @@ function ellipsis(str: string) { return `${str.slice(0, 7)}..${str.slice(-7)}`; } +function formatAmount(currency: CryptoCurrency, amount: number) { + const unit = getMainCurrency(currency).units[0]; + return formatCurrencyUnit(unit, new BigNumber(amount), { + disableRounding: true, + showCode: true, + }).replace(/\s/g, " "); +} + export const acceptTransferTransaction: DeviceAction = deviceActionFlow({ steps: [ { title: "Transfer", button: "Rr", - expectedValue: ({ account, status }) => - formatCurrencyUnit( - getMainCurrency(account.currency).units[0], - status.amount, - { - disableRounding: true, - showCode: true, - } - ).replace(/\s/g, " "), + expectedValue: ({ account, transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "transfer") { + return formatAmount(account.currency, command.amount); + } + throwUnexpectedTransaction(); + }, }, { - title: "Sender", + title: "Recipient", button: "Rr", - expectedValue: ({ account }) => ellipsis(account.freshAddress), + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "transfer") { + return ellipsis(command.recipient); + } + throwUnexpectedTransaction(); + }, }, { - title: "Recipient", + title: "Approve", + button: "LRlr", + final: true, + }, + ], + }); + +export const acceptStakeCreateAccountTransaction: DeviceAction< + Transaction, + any +> = deviceActionFlow({ + steps: [ + { + title: "Delegate from", + button: "Rr", + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "stake.createAccount") { + return ellipsis(command.stakeAccAddress); + } + + throwUnexpectedTransaction(); + }, + }, + { + title: "Deposit", + button: "Rr", + expectedValue: ({ account, transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "stake.createAccount") { + return formatAmount( + account.currency, + command.amount + command.stakeAccRentExemptAmount + ); + } + + throwUnexpectedTransaction(); + }, + }, + { + title: "New authority", + button: "Rr", + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "stake.createAccount") { + return ellipsis(command.fromAccAddress); + } + + throwUnexpectedTransaction(); + }, + }, + { + title: "Vote account", + button: "Rr", + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "stake.createAccount") { + return ellipsis(command.delegate.voteAccAddress); + } + + throwUnexpectedTransaction(); + }, + }, + { + title: "Approve", + button: "LRlr", + final: true, + }, + ], +}); + +export const acceptStakeDelegateTransaction: DeviceAction = + deviceActionFlow({ + steps: [ + { + title: "Delegate from", button: "Rr", - expectedValue: ({ transaction }) => ellipsis(transaction.recipient), + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "stake.delegate") { + return ellipsis(command.stakeAccAddr); + } + throwUnexpectedTransaction(); + }, }, { - title: "Fee payer", + title: "Vote account", button: "Rr", - expectedValue: () => "sender", + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "stake.delegate") { + return ellipsis(command.voteAccAddr); + } + throwUnexpectedTransaction(); + }, }, { title: "Approve", @@ -54,6 +154,83 @@ export const acceptTransferTransaction: DeviceAction = ], }); +export const acceptStakeUndelegateTransaction: DeviceAction = + deviceActionFlow({ + steps: [ + { + title: "Deactivate stake", + button: "Rr", + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + if (command?.kind === "stake.undelegate") { + return ellipsis(command.stakeAccAddr); + } + throwUnexpectedTransaction(); + }, + }, + { + title: "Approve", + button: "LRlr", + final: true, + }, + ], + }); + +export const acceptStakeWithdrawTransaction: DeviceAction = + deviceActionFlow({ + steps: [ + { + title: "Stake withdraw", + button: "Rr", + expectedValue: ({ account, transaction }) => { + const command = transaction.model.commandDescriptor?.command; + + if (command?.kind === "stake.withdraw") { + return formatAmount(account.currency, command.amount); + } + + throwUnexpectedTransaction(); + }, + }, + { + title: "From", + button: "Rr", + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + + if (command?.kind === "stake.withdraw") { + return ellipsis(command.stakeAccAddr); + } + + throwUnexpectedTransaction(); + }, + }, + { + title: "To", + button: "Rr", + expectedValue: ({ transaction }) => { + const command = transaction.model.commandDescriptor?.command; + + if (command?.kind === "stake.withdraw") { + return ellipsis(command.toAccAddr); + } + + throwUnexpectedTransaction(); + }, + }, + { + title: "Approve", + button: "LRlr", + final: true, + }, + ], + }); + +function throwUnexpectedTransaction(): never { + throw new Error("unexpected or unprepared transaction"); +} + export default { acceptTransferTransaction, + acceptStakeCreateAccountTransaction, }; diff --git a/src/families/solana/test-dataset.ts b/src/families/solana/test-dataset.ts index 0623d5f570..77ebcc6110 100644 --- a/src/families/solana/test-dataset.ts +++ b/src/families/solana/test-dataset.ts @@ -14,11 +14,15 @@ import { import { SolanaAccountNotFunded, SolanaAddressOffEd25519, + SolanaInvalidValidator, SolanaMemoIsTooLong, /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ SolanaRecipientAssociatedTokenAccountWillBeFunded, + SolanaStakeAccountNotFound, + SolanaStakeAccountRequired, /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ SolanaTokenAccountHoldsAnotherToken, + SolanaValidatorRequired, } from "./errors"; import { encodeAccountIdWithTokenAccountAddress, @@ -30,7 +34,7 @@ import { assertUnreachable } from "./utils"; import { getEnv } from "../../env"; // do not change real properties or the test will break -const testOnChainData = { +export const testOnChainData = { // --- real props --- unfundedAddress: "7b6Q3ap8qRzfyvDw1Qce3fUV8C7WgFNzJQwYNTJm3KQo", // 0/0 @@ -46,12 +50,14 @@ const testOnChainData = { "Ax69sAxqBSdT3gMAUqXb8pUvgxSLCiXfTitMALEnFZTS", // 0/0 notWSolTokenAccAddress: "Hsm3S2rhX4HwxYBaCyqgJ1cCtFyFSBu6HLy1bdvh7fKs", + validatorAddress: "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF", + fees: { + stakeAccountRentExempt: 2282880, + lamportsPerSignature: 5000, + }, // --- maybe outdated or not real, fine for tests --- offEd25519Address: "6D8GtWkKJgToM5UoiByHqjQCCC9Dq1Hh7iNmU4jKSs14", offEd25519Address2: "12rqwuEgBYiGhBrDJStCiqEtzQpTTiZbh7teNVLuYcFA", - feeCalculator: { - lamportsPerSignature: 5000, - }, }; const mainAccId = encodeAccountId({ @@ -69,9 +75,7 @@ const wSolSubAccId = encodeAccountIdWithTokenAccountAddress( ); const fees = (signatureCount: number) => - new BigNumber( - signatureCount * testOnChainData.feeCalculator.lamportsPerSignature - ); + new BigNumber(signatureCount * testOnChainData.fees.lamportsPerSignature); const zero = new BigNumber(0); @@ -110,325 +114,9 @@ const solana: CurrenciesData = { totalSpent: fees(1), }, }, - { - name: "transfer :: status is success: not all amount", - transaction: { - model: { - kind: "transfer", - uiState: {}, - }, - amount: testOnChainData.fundedSenderBalance.dividedBy(2), - recipient: testOnChainData.fundedAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: {}, - warnings: {}, - estimatedFees: fees(1), - amount: testOnChainData.fundedSenderBalance.dividedBy(2), - totalSpent: testOnChainData.fundedSenderBalance - .dividedBy(2) - .plus(fees(1)), - }, - }, - { - name: "transfer :: status is success: all amount", - transaction: { - model: { - kind: "transfer", - uiState: {}, - }, - useAllAmount: true, - amount: zero, - recipient: testOnChainData.fundedAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: {}, - warnings: {}, - estimatedFees: fees(1), - amount: testOnChainData.fundedSenderBalance.minus(fees(1)), - totalSpent: testOnChainData.fundedSenderBalance, - }, - }, - { - name: "transfer :: status is error: not enough balance, not all amount", - transaction: { - model: { - kind: "transfer", - uiState: {}, - }, - amount: testOnChainData.fundedSenderBalance, - recipient: testOnChainData.fundedAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: { - amount: new NotEnoughBalance(), - }, - warnings: {}, - estimatedFees: fees(1), - amount: testOnChainData.fundedSenderBalance, - totalSpent: testOnChainData.fundedSenderBalance.plus(fees(1)), - }, - }, - { - name: "transfer :: status is error: not enough balance, all amount", - transaction: { - model: { - kind: "transfer", - uiState: { - memo: "a memo", - }, - }, - useAllAmount: true, - amount: zero, - recipient: testOnChainData.fundedAddress, - feeCalculator: { - lamportsPerSignature: testOnChainData.fundedSenderBalance - .plus(1) - .toNumber(), - }, - family: "solana", - }, - expectedStatus: { - errors: { - amount: new NotEnoughBalance(), - }, - warnings: {}, - estimatedFees: testOnChainData.fundedSenderBalance.plus(1), - amount: zero, - totalSpent: testOnChainData.fundedSenderBalance.plus(1), - }, - }, - { - name: "transfer :: status is error: amount is 0", - transaction: { - model: { - kind: "transfer", - uiState: {}, - }, - amount: zero, - recipient: testOnChainData.fundedAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: { - amount: new AmountRequired(), - }, - warnings: {}, - estimatedFees: fees(1), - amount: zero, - totalSpent: fees(1), - }, - }, - { - name: "transfer :: status is error: amount is negative", - transaction: { - model: { - kind: "transfer", - uiState: {}, - }, - amount: new BigNumber(-1), - recipient: testOnChainData.fundedAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: { - amount: new AmountRequired(), - }, - warnings: {}, - estimatedFees: fees(1), - amount: new BigNumber(-1), - totalSpent: new BigNumber(-1).plus(fees(1)), - }, - }, - { - name: "transfer :: status is warning: recipient wallet not funded", - transaction: { - model: { - kind: "transfer", - uiState: {}, - }, - amount: new BigNumber(1), - recipient: testOnChainData.unfundedAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: {}, - warnings: { - recipient: new SolanaAccountNotFunded(), - }, - estimatedFees: fees(1), - amount: new BigNumber(1), - totalSpent: fees(1).plus(1), - }, - }, - { - name: "transfer :: status is warning: recipient address is off ed25519", - transaction: { - model: { - kind: "transfer", - uiState: {}, - }, - amount: new BigNumber(1), - recipient: testOnChainData.offEd25519Address, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: {}, - warnings: { - recipient: new SolanaAccountNotFunded(), - recipientOffCurve: new SolanaAddressOffEd25519(), - }, - estimatedFees: fees(1), - amount: new BigNumber(1), - totalSpent: fees(1).plus(1), - }, - }, - // no tokens for first release - /* - { - name: "token.transfer :: status is success: recipient is funded wallet, assoc token acc exists", - transaction: { - model: { - kind: "token.transfer", - uiState: { - subAccountId: wSolSubAccId, - }, - }, - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - recipient: testOnChainData.fundedAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: {}, - warnings: {}, - estimatedFees: fees(1), - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - totalSpent: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - }, - }, - { - name: "token.transfer :: status is success: recipient is correct mint token acc", - transaction: { - model: { - kind: "token.transfer", - uiState: { - subAccountId: wSolSubAccId, - }, - }, - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - recipient: - testOnChainData.wSolFundedAccountAssocTokenAccAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: {}, - warnings: {}, - estimatedFees: fees(1), - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - totalSpent: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - }, - }, - { - name: "token.transfer :: status is error: recipient is another mint token acc", - transaction: { - model: { - kind: "token.transfer", - uiState: { - subAccountId: wSolSubAccId, - }, - }, - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - recipient: testOnChainData.notWSolTokenAccAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: { - recipient: new SolanaTokenAccountHoldsAnotherToken(), - }, - warnings: {}, - estimatedFees: fees(1), - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - totalSpent: zero, - }, - }, - { - name: "token.transfer :: status is warning: recipient is off curve", - transaction: { - model: { - kind: "token.transfer", - uiState: { - subAccountId: wSolSubAccId, - }, - }, - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - recipient: testOnChainData.offEd25519Address, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: { - recipient: new SolanaAddressOffEd25519(), - }, - warnings: {}, - estimatedFees: fees(1), - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - totalSpent: zero, - }, - }, - { - name: "token.transfer :: status is success: recipient is wallet and no assoc token acc exists (will be created)", - transaction: { - model: { - kind: "token.transfer", - uiState: { - subAccountId: wSolSubAccId, - }, - }, - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - recipient: testOnChainData.unfundedAddress, - feeCalculator: testOnChainData.feeCalculator, - family: "solana", - }, - expectedStatus: { - errors: {}, - warnings: { - recipient: new SolanaAccountNotFunded(), - recipientAssociatedTokenAccount: - new SolanaRecipientAssociatedTokenAccountWillBeFunded(), - }, - // this fee is dynamic, skip - //estimatedFees: new BigNumber(2044280), - amount: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - totalSpent: - testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), - }, - }, - */ + ...transferTests(), + ...stakingTests(), + //...tokenTests() ], }, ], @@ -471,12 +159,15 @@ type TransactionTestSpec = Exclude< function recipientRequired(): TransactionTestSpec[] { const models: TransactionModel[] = [ + // uncomment when tokens are supported + /* { kind: "token.transfer", uiState: { subAccountId: "", }, }, + */ { kind: "transfer", uiState: {}, @@ -489,7 +180,6 @@ function recipientRequired(): TransactionTestSpec[] { model, amount: zero, recipient: "", - feeCalculator: testOnChainData.feeCalculator, family: "solana", }, expectedStatus: { @@ -563,6 +253,11 @@ function memoIsTooLong(): TransactionTestSpec[] { }, }; case "token.createATA": + case "stake.createAccount": + case "stake.delegate": + case "stake.undelegate": + case "stake.withdraw": + case "stake.split": return undefined; default: return assertUnreachable(tx.model); @@ -571,4 +266,686 @@ function memoIsTooLong(): TransactionTestSpec[] { ); } +function transferTests(): TransactionTestSpec[] { + return [ + { + name: "transfer :: status is success: not all amount", + transaction: { + model: { + kind: "transfer", + uiState: {}, + }, + amount: testOnChainData.fundedSenderBalance.dividedBy(2), + recipient: testOnChainData.fundedAddress, + family: "solana", + }, + expectedStatus: { + errors: {}, + warnings: {}, + estimatedFees: fees(1), + amount: testOnChainData.fundedSenderBalance.dividedBy(2), + totalSpent: testOnChainData.fundedSenderBalance + .dividedBy(2) + .plus(fees(1)), + }, + }, + { + name: "transfer :: status is success: all amount", + transaction: { + model: { + kind: "transfer", + uiState: {}, + }, + useAllAmount: true, + amount: zero, + recipient: testOnChainData.fundedAddress, + family: "solana", + }, + expectedStatus: { + errors: {}, + warnings: {}, + estimatedFees: fees(1), + amount: testOnChainData.fundedSenderBalance.minus(fees(1)), + totalSpent: testOnChainData.fundedSenderBalance, + }, + }, + { + name: "transfer :: status is error: not enough balance, not all amount", + transaction: { + model: { + kind: "transfer", + uiState: {}, + }, + amount: testOnChainData.fundedSenderBalance, + recipient: testOnChainData.fundedAddress, + family: "solana", + }, + expectedStatus: { + errors: { + amount: new NotEnoughBalance(), + }, + warnings: {}, + estimatedFees: fees(1), + amount: testOnChainData.fundedSenderBalance, + totalSpent: testOnChainData.fundedSenderBalance.plus(fees(1)), + }, + }, + { + name: "transfer :: status is error: amount is 0", + transaction: { + model: { + kind: "transfer", + uiState: {}, + }, + amount: zero, + recipient: testOnChainData.fundedAddress, + family: "solana", + }, + expectedStatus: { + errors: { + amount: new AmountRequired(), + }, + warnings: {}, + estimatedFees: fees(1), + amount: zero, + totalSpent: fees(1), + }, + }, + { + name: "transfer :: status is error: amount is negative", + transaction: { + model: { + kind: "transfer", + uiState: {}, + }, + amount: new BigNumber(-1), + recipient: testOnChainData.fundedAddress, + family: "solana", + }, + expectedStatus: { + errors: { + amount: new AmountRequired(), + }, + warnings: {}, + estimatedFees: fees(1), + amount: new BigNumber(-1), + totalSpent: zero, + }, + }, + { + name: "transfer :: status is warning: recipient wallet not funded", + transaction: { + model: { + kind: "transfer", + uiState: {}, + }, + amount: new BigNumber(1), + recipient: testOnChainData.unfundedAddress, + family: "solana", + }, + expectedStatus: { + errors: {}, + warnings: { + recipient: new SolanaAccountNotFunded(), + }, + estimatedFees: fees(1), + amount: new BigNumber(1), + totalSpent: fees(1).plus(1), + }, + }, + { + name: "transfer :: status is warning: recipient address is off ed25519", + transaction: { + model: { + kind: "transfer", + uiState: {}, + }, + amount: new BigNumber(1), + recipient: testOnChainData.offEd25519Address, + family: "solana", + }, + expectedStatus: { + errors: {}, + warnings: { + recipient: new SolanaAccountNotFunded(), + recipientOffCurve: new SolanaAddressOffEd25519(), + }, + estimatedFees: fees(1), + amount: new BigNumber(1), + totalSpent: fees(1).plus(1), + }, + }, + ]; +} + +// uncomment when tokens are supported +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +function tokenTests(): TransactionTestSpec[] { + return [ + { + name: "token.transfer :: status is success: recipient is funded wallet, assoc token acc exists", + transaction: { + model: { + kind: "token.transfer", + uiState: { + subAccountId: wSolSubAccId, + }, + }, + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + recipient: testOnChainData.fundedAddress, + family: "solana", + }, + expectedStatus: { + errors: {}, + warnings: {}, + estimatedFees: fees(1), + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + totalSpent: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + }, + }, + { + name: "token.transfer :: status is success: recipient is correct mint token acc", + transaction: { + model: { + kind: "token.transfer", + uiState: { + subAccountId: wSolSubAccId, + }, + }, + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + recipient: testOnChainData.wSolFundedAccountAssocTokenAccAddress, + family: "solana", + }, + expectedStatus: { + errors: {}, + warnings: {}, + estimatedFees: fees(1), + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + totalSpent: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + }, + }, + { + name: "token.transfer :: status is error: recipient is another mint token acc", + transaction: { + model: { + kind: "token.transfer", + uiState: { + subAccountId: wSolSubAccId, + }, + }, + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + recipient: testOnChainData.notWSolTokenAccAddress, + family: "solana", + }, + expectedStatus: { + errors: { + recipient: new SolanaTokenAccountHoldsAnotherToken(), + }, + warnings: {}, + estimatedFees: fees(1), + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + totalSpent: zero, + }, + }, + { + name: "token.transfer :: status is warning: recipient is off curve", + transaction: { + model: { + kind: "token.transfer", + uiState: { + subAccountId: wSolSubAccId, + }, + }, + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + recipient: testOnChainData.offEd25519Address, + family: "solana", + }, + expectedStatus: { + errors: { + recipient: new SolanaAddressOffEd25519(), + }, + warnings: {}, + estimatedFees: fees(1), + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + totalSpent: zero, + }, + }, + { + name: "token.transfer :: status is success: recipient is wallet and no assoc token acc exists (will be created)", + transaction: { + model: { + kind: "token.transfer", + uiState: { + subAccountId: wSolSubAccId, + }, + }, + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + recipient: testOnChainData.unfundedAddress, + family: "solana", + }, + expectedStatus: { + errors: {}, + warnings: { + recipient: new SolanaAccountNotFunded(), + recipientAssociatedTokenAccount: + new SolanaRecipientAssociatedTokenAccountWillBeFunded(), + }, + // this fee is dynamic, skip + //estimatedFees: new BigNumber(2044280), + amount: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + totalSpent: testOnChainData.wSolSenderAssocTokenAccBalance.dividedBy(2), + }, + }, + ]; +} + +function stakingTests(): TransactionTestSpec[] { + return [ + { + name: "stake.createAccount :: status is error: amount is negative", + transaction: { + family: "solana", + model: { + kind: "stake.createAccount", + uiState: { + delegate: { voteAccAddress: testOnChainData.validatorAddress }, + }, + }, + recipient: "", + amount: new BigNumber(-1), + }, + expectedStatus: { + amount: new BigNumber(-1), + estimatedFees: fees(1).plus( + testOnChainData.fees.stakeAccountRentExempt + ), + totalSpent: zero, + errors: { + amount: new AmountRequired(), + }, + }, + }, + { + name: "stake.createAccount :: status is error: amount is zero", + transaction: { + family: "solana", + model: { + kind: "stake.createAccount", + uiState: { + delegate: { voteAccAddress: testOnChainData.validatorAddress }, + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1).plus( + testOnChainData.fees.stakeAccountRentExempt + ), + totalSpent: fees(1).plus(testOnChainData.fees.stakeAccountRentExempt), + errors: { + amount: new AmountRequired(), + }, + }, + }, + { + name: "stake.createAccount :: status is error: not enough balance, not all amount", + transaction: { + family: "solana", + model: { + kind: "stake.createAccount", + uiState: { + delegate: { voteAccAddress: testOnChainData.validatorAddress }, + }, + }, + recipient: "", + amount: testOnChainData.fundedSenderBalance, + }, + expectedStatus: { + amount: testOnChainData.fundedSenderBalance, + estimatedFees: fees(1).plus( + testOnChainData.fees.stakeAccountRentExempt + ), + totalSpent: fees(1) + .plus(testOnChainData.fees.stakeAccountRentExempt) + .plus(testOnChainData.fundedSenderBalance), + errors: { + amount: new NotEnoughBalance(), + }, + }, + }, + { + name: "stake.createAccount :: status is error: validator required", + transaction: { + family: "solana", + model: { + kind: "stake.createAccount", + uiState: { + delegate: { voteAccAddress: "" }, + }, + }, + recipient: "", + amount: new BigNumber(1), + }, + expectedStatus: { + amount: new BigNumber(1), + estimatedFees: fees(1).plus( + testOnChainData.fees.stakeAccountRentExempt + ), + totalSpent: fees(1) + .plus(testOnChainData.fees.stakeAccountRentExempt) + .plus(1), + errors: { + voteAccAddr: new SolanaValidatorRequired(), + }, + }, + }, + { + name: "stake.createAccount :: status is error: validator has invalid address", + transaction: { + family: "solana", + model: { + kind: "stake.createAccount", + uiState: { + delegate: { voteAccAddress: "invalid address" }, + }, + }, + recipient: "", + amount: new BigNumber(1), + }, + expectedStatus: { + amount: new BigNumber(1), + estimatedFees: fees(1).plus( + testOnChainData.fees.stakeAccountRentExempt + ), + totalSpent: fees(1) + .plus(testOnChainData.fees.stakeAccountRentExempt) + .plus(1), + errors: { + voteAccAddr: new InvalidAddress(), + }, + }, + }, + { + name: "stake.createAccount :: status is error: validator invalid", + transaction: { + family: "solana", + model: { + kind: "stake.createAccount", + uiState: { + delegate: { voteAccAddress: testOnChainData.fundedSenderAddress }, + }, + }, + recipient: "", + amount: new BigNumber(1), + }, + expectedStatus: { + amount: new BigNumber(1), + estimatedFees: fees(1).plus( + testOnChainData.fees.stakeAccountRentExempt + ), + totalSpent: fees(1) + .plus(testOnChainData.fees.stakeAccountRentExempt) + .plus(1), + errors: { + voteAccAddr: new SolanaInvalidValidator(), + }, + }, + }, + { + name: "stake.createAccount :: status is success, not all amount", + transaction: { + family: "solana", + model: { + kind: "stake.createAccount", + uiState: { + delegate: { voteAccAddress: testOnChainData.validatorAddress }, + }, + }, + recipient: "", + amount: new BigNumber(1), + }, + expectedStatus: { + amount: new BigNumber(1), + estimatedFees: fees(1).plus( + testOnChainData.fees.stakeAccountRentExempt + ), + totalSpent: fees(1) + .plus(testOnChainData.fees.stakeAccountRentExempt) + .plus(1), + errors: {}, + }, + }, + { + name: "stake.createAccount :: status is success, all amount", + transaction: { + family: "solana", + model: { + kind: "stake.createAccount", + uiState: { + delegate: { voteAccAddress: testOnChainData.validatorAddress }, + }, + }, + recipient: "", + useAllAmount: true, + amount: zero, + }, + expectedStatus: { + amount: testOnChainData.fundedSenderBalance + .minus(fees(1)) + .minus(testOnChainData.fees.stakeAccountRentExempt), + estimatedFees: fees(1).plus( + testOnChainData.fees.stakeAccountRentExempt + ), + totalSpent: testOnChainData.fundedSenderBalance, + errors: {}, + }, + }, + { + name: "stake.delegate :: status is error: stake account address and validator address required", + transaction: { + family: "solana", + model: { + kind: "stake.delegate", + uiState: { + stakeAccAddr: "", + voteAccAddr: "", + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1), + totalSpent: fees(1), + errors: { + stakeAccAddr: new SolanaStakeAccountRequired(), + voteAccAddr: new SolanaValidatorRequired(), + }, + }, + }, + { + name: "stake.delegate :: status is error: stake account address and validator address are invalid", + transaction: { + family: "solana", + model: { + kind: "stake.delegate", + uiState: { + stakeAccAddr: "invalid address", + voteAccAddr: "invalid address", + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1), + totalSpent: fees(1), + errors: { + stakeAccAddr: new InvalidAddress(), + voteAccAddr: new InvalidAddress(), + }, + }, + }, + { + name: "stake.delegate :: status is error: stake account not found", + transaction: { + family: "solana", + model: { + kind: "stake.delegate", + uiState: { + stakeAccAddr: testOnChainData.unfundedAddress, + voteAccAddr: testOnChainData.validatorAddress, + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1), + totalSpent: fees(1), + errors: { + stakeAccAddr: new SolanaStakeAccountNotFound(), + }, + }, + }, + { + name: "stake.undelegate :: status is error: stake account required", + transaction: { + family: "solana", + model: { + kind: "stake.undelegate", + uiState: { + stakeAccAddr: "", + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1), + totalSpent: fees(1), + errors: { + stakeAccAddr: new SolanaStakeAccountRequired(), + }, + }, + }, + { + name: "stake.undelegate :: status is error: stake account invalid", + transaction: { + family: "solana", + model: { + kind: "stake.undelegate", + uiState: { + stakeAccAddr: "invalid address", + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1), + totalSpent: fees(1), + errors: { + stakeAccAddr: new InvalidAddress(), + }, + }, + }, + { + name: "stake.undelegate :: status is error: stake account not found", + transaction: { + family: "solana", + model: { + kind: "stake.undelegate", + uiState: { + stakeAccAddr: testOnChainData.unfundedAddress, + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1), + totalSpent: fees(1), + errors: { + stakeAccAddr: new SolanaStakeAccountNotFound(), + }, + }, + }, + { + name: "stake.withdraw :: status is error: stake account required", + transaction: { + family: "solana", + model: { + kind: "stake.withdraw", + uiState: { + stakeAccAddr: "", + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1), + totalSpent: fees(1), + errors: { + stakeAccAddr: new SolanaStakeAccountRequired(), + }, + }, + }, + { + name: "stake.withdraw :: status is error: stake account address invalid", + transaction: { + family: "solana", + model: { + kind: "stake.withdraw", + uiState: { + stakeAccAddr: "invalid address", + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1), + totalSpent: fees(1), + errors: { + stakeAccAddr: new InvalidAddress(), + }, + }, + }, + { + name: "stake.withdraw :: status is error: stake account not found", + transaction: { + family: "solana", + model: { + kind: "stake.withdraw", + uiState: { + stakeAccAddr: testOnChainData.unfundedAddress, + }, + }, + recipient: "", + amount: zero, + }, + expectedStatus: { + amount: zero, + estimatedFees: fees(1), + totalSpent: fees(1), + errors: { + stakeAccAddr: new SolanaStakeAccountNotFound(), + }, + }, + }, + ]; +} + export default dataset; diff --git a/src/families/solana/test-specifics.ts b/src/families/solana/test-specifics.ts new file mode 100644 index 0000000000..53e594a149 --- /dev/null +++ b/src/families/solana/test-specifics.ts @@ -0,0 +1,171 @@ +import { NotEnoughBalance } from "@ledgerhq/errors"; +import BigNumber from "bignumber.js"; +import { Account, TransactionStatus } from "../../types"; +import { ChainAPI } from "./api"; +import { + SolanaStakeAccountIsNotDelegatable, + SolanaStakeAccountValidatorIsUnchangeable, +} from "./errors"; +import getTransactionStatus from "./js-getTransactionStatus"; +import { prepareTransaction } from "./js-prepareTransaction"; +import { testOnChainData } from "./test-dataset"; +import { SolanaStake, Transaction } from "./types"; + +const baseAccount = { + balance: new BigNumber(0), +} as Account; + +const baseTx = { + family: "solana", + recipient: "", + amount: new BigNumber(0), +} as Transaction; + +const baseAPI = { + getTxFeeCalculator: () => + Promise.resolve({ + lamportsPerSignature: testOnChainData.fees.lamportsPerSignature, + }), +} as ChainAPI; + +type StakeTestSpec = { + activationState: SolanaStake["activation"]["state"]; + txModel: Transaction["model"]; + expectedErrors: Record; +}; + +/** + * Some business logic can not be described in terms of transactions and expected status + * in the test-dataset, like stake activation/deactivation, because stake activation is + * not determenistic and changes with time. Hence the tests here to mock data + * to be determenistic. + */ +export default () => { + describe("solana staking", () => { + test("stake.delegate :: status is error: stake account is not delegatable", async () => { + const stakeDelegateModel: Transaction["model"] & { + kind: "stake.delegate"; + } = { + kind: "stake.delegate", + uiState: { + stakeAccAddr: testOnChainData.unfundedAddress, + voteAccAddr: testOnChainData.validatorAddress, + }, + }; + + const stakeTests: StakeTestSpec[] = [ + { + activationState: "activating", + txModel: stakeDelegateModel, + expectedErrors: { + fee: new NotEnoughBalance(), + stakeAccAddr: new SolanaStakeAccountIsNotDelegatable(), + }, + }, + { + activationState: "active", + txModel: stakeDelegateModel, + expectedErrors: { + fee: new NotEnoughBalance(), + stakeAccAddr: new SolanaStakeAccountIsNotDelegatable(), + }, + }, + { + activationState: "deactivating", + txModel: { + ...stakeDelegateModel, + uiState: { + ...stakeDelegateModel.uiState, + voteAccAddr: testOnChainData.unfundedAddress, + }, + }, + expectedErrors: { + fee: new NotEnoughBalance(), + stakeAccAddr: new SolanaStakeAccountValidatorIsUnchangeable(), + }, + }, + ]; + + for (const stakeTest of stakeTests) { + await runStakeTest(stakeTest); + } + }); + }); +}; + +async function runStakeTest(stakeTestSpec: StakeTestSpec) { + const api = { + ...baseAPI, + getMinimumBalanceForRentExemption: () => + Promise.resolve(testOnChainData.fees.stakeAccountRentExempt), + getAccountInfo: () => { + return Promise.resolve({ data: mockedVoteAccount } as any); + }, + } as ChainAPI; + + const account: Account = { + ...baseAccount, + solanaResources: { + stakes: [ + { + stakeAccAddr: testOnChainData.unfundedAddress, + delegation: { + stake: 1, + voteAccAddr: testOnChainData.validatorAddress, + }, + activation: { + state: stakeTestSpec.activationState, + }, + } as SolanaStake, + ], + }, + }; + + const tx: Transaction = { + ...baseTx, + model: stakeTestSpec.txModel, + }; + + const preparedTx = await prepareTransaction(account, tx, api); + const status = await getTransactionStatus(account, preparedTx); + + const expectedStatus: TransactionStatus = { + amount: new BigNumber(0), + estimatedFees: new BigNumber(testOnChainData.fees.lamportsPerSignature), + totalSpent: new BigNumber(testOnChainData.fees.lamportsPerSignature), + errors: stakeTestSpec.expectedErrors, + warnings: {}, + }; + + expect(status).toEqual(expectedStatus); +} + +const mockedVoteAccount = { + parsed: { + info: { + authorizedVoters: [ + { + authorizedVoter: "EvnRmnMrd69kFdbLMxWkTn1icZ7DCceRhvmb2SJXqDo4", + epoch: 283, + }, + ], + authorizedWithdrawer: "EvnRmnMrd69kFdbLMxWkTn1icZ7DCceRhvmb2SJXqDo4", + commission: 7, + epochCredits: [ + { + credits: "98854605", + epoch: 283, + previousCredits: "98728105", + }, + ], + lastTimestamp: { slot: 122422797, timestamp: 1645796249 }, + nodePubkey: "EvnRmnMrd69kFdbLMxWkTn1icZ7DCceRhvmb2SJXqDo4", + priorVoters: [], + rootSlot: 122422766, + votes: [{ confirmationCount: 1, slot: 122422797 }], + }, + type: "vote", + }, + program: "vote", + space: 3731, +}; diff --git a/src/families/solana/transaction.ts b/src/families/solana/transaction.ts index 3ea4fe0f5e..d1a636d982 100644 --- a/src/families/solana/transaction.ts +++ b/src/families/solana/transaction.ts @@ -1,6 +1,11 @@ import { BigNumber } from "bignumber.js"; import type { Command, + StakeCreateAccountCommand, + StakeDelegateCommand, + StakeSplitCommand, + StakeUndelegateCommand, + StakeWithdrawCommand, TokenCreateATACommand, TokenTransferCommand, Transaction, @@ -19,23 +24,21 @@ import { toTokenId } from "./logic"; export const fromTransactionRaw = (tr: TransactionRaw): Transaction => { const common = fromTransactionCommonRaw(tr); - const { family, model, feeCalculator } = tr; + const { family, model } = tr; return { ...common, family, model: JSON.parse(model), - feeCalculator, }; }; export const toTransactionRaw = (t: Transaction): TransactionRaw => { const common = toTransactionCommonRaw(t); - const { family, model, feeCalculator } = t; + const { family, model } = t; return { ...common, family, model: JSON.stringify(model), - feeCalculator, }; }; @@ -54,14 +57,11 @@ export const formatTransaction = ( throw new Error("can not format unprepared transaction"); } const { commandDescriptor } = tx.model; - switch (commandDescriptor.status) { - case "valid": - return formatCommand(mainAccount, tx, commandDescriptor.command); - case "invalid": - throw new Error("can not format invalid transaction"); - default: - return assertUnreachable(commandDescriptor); + + if (Object.keys(commandDescriptor.errors).length > 0) { + throw new Error("can not format invalid transaction"); } + return formatCommand(mainAccount, tx, commandDescriptor.command); }; function formatCommand( @@ -75,12 +75,44 @@ function formatCommand( case "token.transfer": return formatTokenTransfer(mainAccount, tx, command); case "token.createATA": - return formatCreateATA(mainAccount, tx, command); + return formatCreateATA(command); + case "stake.createAccount": + return formatStakeCreateAccount(mainAccount, tx, command); + case "stake.delegate": + return formatStakeDelegate(command); + case "stake.undelegate": + return formatStakeUndelegate(command); + case "stake.withdraw": + return formatStakeWithdraw(mainAccount, tx, command); + case "stake.split": + return formatStakeSplit(mainAccount, tx, command); default: return assertUnreachable(command); } } +function formatStakeCreateAccount( + mainAccount: Account, + tx: Transaction, + command: StakeCreateAccountCommand +) { + const amount = lamportsToSOL( + mainAccount, + command.amount + command.stakeAccRentExemptAmount + ); + const str = [ + ` CREATE STAKE ACCOUNT: ${command.stakeAccAddress}`, + ` FROM: ${command.fromAccAddress}`, + ` AMOUNT: ${amount}${tx.useAllAmount ? " (ALL)" : ""}`, + ` SEED: ${command.seed}`, + ` VALIDATOR: ${command.delegate.voteAccAddress}`, + ] + .filter(Boolean) + .join("\n"); + + return "\n" + str; +} + function formatTransfer( mainAccount: Account, tx: Transaction, @@ -129,13 +161,58 @@ function formatTokenTransfer( return "\n" + str; } -function formatCreateATA( +function formatCreateATA(command: TokenCreateATACommand) { + const token = getTokenById(toTokenId(command.mint)); + const str = [` OPT IN TOKEN: ${token.ticker}`].filter(Boolean).join("\n"); + return "\n" + str; +} + +function formatStakeDelegate(command: StakeDelegateCommand) { + const str = [ + ` DELEGATE: ${command.stakeAccAddr}`, + ` TO: ${command.voteAccAddr}`, + ] + .filter(Boolean) + .join("\n"); + return "\n" + str; +} + +function formatStakeUndelegate(command: StakeUndelegateCommand) { + const str = [` UNDELEGATE: ${command.stakeAccAddr}`] + .filter(Boolean) + .join("\n"); + return "\n" + str; +} + +function formatStakeWithdraw( mainAccount: Account, tx: Transaction, - command: TokenCreateATACommand + command: StakeWithdrawCommand ) { - const token = getTokenById(toTokenId(command.mint)); - const str = [` OPT IN TOKEN: ${token.ticker}`].filter(Boolean).join("\n"); + const amount = lamportsToSOL(mainAccount, command.amount); + const str = [ + ` WITHDRAW FROM: ${command.stakeAccAddr}`, + ` AMOUNT: ${amount}${tx.useAllAmount ? " (ALL)" : ""}`, + ` TO: ${command.toAccAddr}`, + ] + .filter(Boolean) + .join("\n"); + return "\n" + str; +} + +function formatStakeSplit( + mainAccount: Account, + tx: Transaction, + command: StakeSplitCommand +) { + const amount = lamportsToSOL(mainAccount, command.amount); + const str = [ + ` SPLIT: ${command.stakeAccAddr}`, + ` AMOUNT: ${amount}${tx.useAllAmount ? " (ALL)" : ""}`, + ` TO: ${command.splitStakeAccAddr}`, + ] + .filter(Boolean) + .join("\n"); return "\n" + str; } diff --git a/src/families/solana/types.ts b/src/families/solana/types.ts index 9d9f22027c..b4d2141d76 100644 --- a/src/families/solana/types.ts +++ b/src/families/solana/types.ts @@ -2,6 +2,7 @@ import type { TransactionCommon, TransactionCommonRaw, } from "../../types/transaction"; +import { ValidatorsAppValidator } from "./validator-app"; export type TransferCommand = { kind: "transfer"; @@ -18,6 +19,48 @@ export type TokenCreateATACommand = { associatedTokenAccountAddress: string; }; +export type StakeCreateAccountCommand = { + kind: "stake.createAccount"; + fromAccAddress: string; + stakeAccAddress: string; + seed: string; + amount: number; + stakeAccRentExemptAmount: number; + delegate: { + voteAccAddress: string; + }; +}; + +export type StakeDelegateCommand = { + kind: "stake.delegate"; + authorizedAccAddr: string; + stakeAccAddr: string; + voteAccAddr: string; +}; + +export type StakeUndelegateCommand = { + kind: "stake.undelegate"; + authorizedAccAddr: string; + stakeAccAddr: string; +}; + +export type StakeWithdrawCommand = { + kind: "stake.withdraw"; + authorizedAccAddr: string; + stakeAccAddr: string; + toAccAddr: string; + amount: number; +}; + +export type StakeSplitCommand = { + kind: "stake.split"; + authorizedAccAddr: string; + stakeAccAddr: string; + amount: number; + seed: string; + splitStakeAccAddr: string; +}; + export type TokenRecipientDescriptor = { walletAddress: string; tokenAccAddress: string; @@ -38,25 +81,20 @@ export type TokenTransferCommand = { export type Command = | TransferCommand | TokenTransferCommand - | TokenCreateATACommand; + | TokenCreateATACommand + | StakeCreateAccountCommand + | StakeDelegateCommand + | StakeUndelegateCommand + | StakeWithdrawCommand + | StakeSplitCommand; -export type ValidCommandDescriptor = { - status: "valid"; +export type CommandDescriptor = { command: Command; - fees?: number; - warnings?: Record; -}; - -export type InvalidCommandDescriptor = { - status: "invalid"; + fee: number; + warnings: Record; errors: Record; - warnings?: Record; }; -export type CommandDescriptor<> = - | ValidCommandDescriptor - | InvalidCommandDescriptor; - export type TransferTransaction = { kind: "transfer"; uiState: { @@ -79,24 +117,125 @@ export type TokenCreateATATransaction = { }; }; +export type StakeCreateAccountTransaction = { + kind: "stake.createAccount"; + uiState: { + delegate: { + voteAccAddress: string; + }; + }; +}; + +export type StakeDelegateTransaction = { + kind: "stake.delegate"; + uiState: { + stakeAccAddr: string; + voteAccAddr: string; + }; +}; + +export type StakeUndelegateTransaction = { + kind: "stake.undelegate"; + uiState: { + stakeAccAddr: string; + }; +}; + +export type StakeWithdrawTransaction = { + kind: "stake.withdraw"; + uiState: { + stakeAccAddr: string; + }; +}; + +export type StakeSplitTransaction = { + kind: "stake.split"; + uiState: { + stakeAccAddr: string; + }; +}; + export type TransactionModel = { commandDescriptor?: CommandDescriptor } & ( | TransferTransaction | TokenTransferTransaction | TokenCreateATATransaction + | StakeCreateAccountTransaction + | StakeDelegateTransaction + | StakeUndelegateTransaction + | StakeWithdrawTransaction + | StakeSplitTransaction ); export type Transaction = TransactionCommon & { family: "solana"; model: TransactionModel; - feeCalculator?: { - lamportsPerSignature: number; - }; }; export type TransactionRaw = TransactionCommonRaw & { family: "solana"; model: string; - feeCalculator?: { - lamportsPerSignature: number; +}; + +export type SolanaStake = { + stakeAccAddr: string; + hasStakeAuth: boolean; + hasWithdrawAuth: boolean; + delegation?: { + stake: number; + voteAccAddr: string; + }; + stakeAccBalance: number; + rentExemptReserve: number; + withdrawable: number; + activation: { + state: "active" | "inactive" | "activating" | "deactivating"; + active: number; + inactive: number; + }; +}; + +export type SolanaStakeWithMeta = { + stake: SolanaStake; + meta: { + validator?: { + name?: string; + img?: string; + url?: string; + }; + }; +}; + +export type SolanaResources = { + stakes: SolanaStake[]; +}; + +export type SolanaResourcesRaw = { + stakes: string; +}; + +export type SolanaValidator = { + voteAccAddr: string; + commission: number; + activatedStake: number; +}; + +export type SolanaPreloadDataV1 = { + version: "1"; + validatorsWithMeta: SolanaValidatorWithMeta[]; + validators: ValidatorsAppValidator[]; +}; + +// exists for discriminated union to work +export type SolanaPreloadDataV2 = { + version: "2"; +}; + +export type SolanaPreloadData = SolanaPreloadDataV1 | SolanaPreloadDataV2; + +export type SolanaValidatorWithMeta = { + validator: SolanaValidator; + meta: { + name?: string; + img?: string; }; }; diff --git a/src/families/solana/utils.ts b/src/families/solana/utils.ts index 9c2cbf9202..98916223f9 100644 --- a/src/families/solana/utils.ts +++ b/src/families/solana/utils.ts @@ -1,6 +1,9 @@ -import { clusterApiUrl } from "@solana/web3.js"; +import { Cluster, clusterApiUrl } from "@solana/web3.js"; import { getEnv } from "../../env"; +export const LEDGER_VALIDATOR_ADDRESS = + "26pV97Ce83ZQ6Kz9XT4td8tdoUFPTng8Fb8gPyc53dJx"; + export const assertUnreachable = (_: never): never => { throw new Error("unreachable assertion failed"); }; @@ -17,6 +20,14 @@ export async function drainSeqAsyncGen( return items; } +export async function drainSeq(jobs: (() => Promise)[]) { + const items: T[] = []; + for (const job of jobs) { + items.push(await job()); + } + return items; +} + export function endpointByCurrencyId(currencyId: string): string { const endpoints: Record = { solana: getEnv("API_SOLANA_PROXY"), @@ -33,6 +44,40 @@ export function endpointByCurrencyId(currencyId: string): string { ); } +export function clusterByCurrencyId(currencyId: string): Cluster { + const clusters: Record = { + solana: "mainnet-beta", + solana_devnet: "devnet", + solana_testnet: "testnet", + }; + + if (currencyId in clusters) { + return clusters[currencyId]; + } + + throw Error( + `unexpected currency id format <${currencyId}>, should be like solana[_(testnet | devnet)]` + ); +} + +export function defaultVoteAccAddrByCurrencyId( + currencyId: string +): string | undefined { + const voteAccAddrs: Record = { + solana: LEDGER_VALIDATOR_ADDRESS, + solana_devnet: undefined, + solana_testnet: undefined, + }; + + if (currencyId in voteAccAddrs) { + return voteAccAddrs[currencyId]; + } + + throw new Error( + `unexpected currency id format <${currencyId}>, should be like solana[_(testnet | devnet)]` + ); +} + type AsyncQueueEntry = { lazyPromise: () => Promise; resolve: (value: T) => void; @@ -79,3 +124,9 @@ export function asyncQueue(config: { delayBetweenRuns: number }): { submit, }; } + +export function swap(arr: any[], i: number, j: number) { + const tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; +} diff --git a/src/families/solana/validator-app/index.ts b/src/families/solana/validator-app/index.ts new file mode 100644 index 0000000000..a4120c4535 --- /dev/null +++ b/src/families/solana/validator-app/index.ts @@ -0,0 +1,104 @@ +import { Cluster } from "@solana/web3.js"; +import { AxiosRequestConfig, AxiosResponse } from "axios"; +import { compact } from "lodash/fp"; +import { getEnv } from "../../../env"; +import network from "../../../network"; + +export type ValidatorsAppValidatorRaw = { + active_stake?: number | null; + commission?: number | null; + total_score?: number | null; + vote_account?: string | null; + name?: string | null; + avatar_url?: string | null; + delinquent?: boolean | null; + www_url?: string | null; + + // not used data + /* + network: string; + account: string; + keybase_id: string | null; + www_url: string | null; + details: string | null; + created_at: Date; + updated_at: Date; + admin_warning: string | null; + root_distance_score: number; + vote_distance_score: number; + skipped_slot_score: number; + software_version: string; + software_version_score: number; + stake_concentration_score: number; + data_center_concentration_score: number; + published_information_score: number; + security_report_score: number; + data_center_key: string; + data_center_host: string | null; + authorized_withdrawer_score: number; + autonomous_system_number: number; + latitude: string; + longitude: string; + url: string; + */ +}; + +export type ValidatorsAppValidator = { + activeStake: number; + commission: number; + totalScore: number; + voteAccount: string; + name?: string; + avatarUrl?: string; + wwwUrl?: string; +}; + +const URLS = { + validatorList: (cluster: Extract) => { + const clusterSlug = cluster === "mainnet-beta" ? "mainnet" : cluster; + const baseUrl = getEnv("SOLANA_VALIDATORS_APP_BASE_URL"); + return `${baseUrl}/${clusterSlug}.json`; + }, +}; + +export async function getValidators( + cluster: Extract +): Promise { + const config: AxiosRequestConfig = { + method: "GET", + url: URLS.validatorList(cluster), + }; + + const response: AxiosResponse = await network( + config + ); + + const allRawValidators = response.status === 200 ? response.data : []; + + // validators app data is not clean: random properties can randomly contain + // data, null, undefined + const tryFromRawValidator = ( + v: ValidatorsAppValidatorRaw + ): ValidatorsAppValidator | undefined => { + if ( + typeof v.active_stake === "number" && + typeof v.commission === "number" && + typeof v.total_score === "number" && + typeof v.vote_account === "string" && + v.delinquent !== true + ) { + return { + activeStake: v.active_stake, + commission: v.commission, + totalScore: v.total_score, + voteAccount: v.vote_account, + name: v.name ?? undefined, + avatarUrl: v.avatar_url ?? undefined, + wwwUrl: v.www_url ?? undefined, + }; + } + return undefined; + }; + + return compact(allRawValidators.map(tryFromRawValidator)); +} diff --git a/src/reconciliation.ts b/src/reconciliation.ts index 9a5de9f2aa..5dc1105cc9 100644 --- a/src/reconciliation.ts +++ b/src/reconciliation.ts @@ -26,6 +26,7 @@ import { fromTezosResourcesRaw, fromElrondResourcesRaw, fromCryptoOrgResourcesRaw, + fromSolanaResourcesRaw, fromNFTRaw, } from "./account"; import consoleWarnExpectToEqual from "./consoleWarnExpectToEqual"; @@ -382,6 +383,11 @@ export function patchAccount( changed = true; } + if (updatedRaw.solanaResources) { + next.solanaResources = fromSolanaResourcesRaw(updatedRaw.solanaResources); + changed = true; + } + const nfts = updatedRaw?.nfts?.map(fromNFTRaw); if (!updatedRaw.nfts && account.nfts) { delete next.nfts; diff --git a/src/types/account.ts b/src/types/account.ts index 70094a8e4f..9559669596 100644 --- a/src/types/account.ts +++ b/src/types/account.ts @@ -31,6 +31,7 @@ import type { CryptoOrgResources, CryptoOrgResourcesRaw, } from "../families/crypto_org/types"; +import { SolanaResources, SolanaResourcesRaw } from "../families/solana/types"; import type { BalanceHistory, BalanceHistoryRaw, @@ -212,6 +213,7 @@ export type Account = { tezosResources?: TezosResources; elrondResources?: ElrondResources; cryptoOrgResources?: CryptoOrgResources; + solanaResources?: SolanaResources; // Swap operations linked to this account swapHistory: SwapOperation[]; // Hash used to discard tx history on sync @@ -301,6 +303,7 @@ export type AccountRaw = { elrondResources?: ElrondResourcesRaw; tezosResources?: TezosResourcesRaw; cryptoOrgResources?: CryptoOrgResourcesRaw; + solanaResources?: SolanaResourcesRaw; swapHistory?: SwapOperationRaw[]; syncHash?: string; nfts?: ProtoNFTRaw[];