diff --git a/.github/workflows/bot-xrp-mere-denis.yml b/.github/workflows/bot-xrp-mere-denis.yml new file mode 100644 index 0000000000..0f0ba64cd9 --- /dev/null +++ b/.github/workflows/bot-xrp-mere-denis.yml @@ -0,0 +1,79 @@ +name: Bot 'XRP on Mère Denis' +on: + push: + branches: + - family/ripple + +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.SEED1 }} + VERBOSE_FILE: bot-tests.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-xrp-ll + BOT_FILTER_FAMILY: ripple + EXPERIMENTAL_CURRENCIES_JS_BRIDGE: ripple + run: 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: bot-tests.txt + path: bot-tests.txt \ No newline at end of file diff --git a/package.json b/package.json index 469c1cb04d..efdaf74229 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,6 @@ "ripemd160": "^2.0.2", "ripple-binary-codec": "^1.3.0", "ripple-bs58check": "^2.0.2", - "ripple-lib": "1.10.0", "rlp": "^3.0.0", "rxjs": "6", "rxjs-compat": "^6.6.7", diff --git a/src/api/Ripple.ts b/src/api/Ripple.ts index 4ded77b3ba..80e8455c46 100644 --- a/src/api/Ripple.ts +++ b/src/api/Ripple.ts @@ -1,185 +1,120 @@ import { BigNumber } from "bignumber.js"; -import { - parseCurrencyUnit, - getCryptoCurrencyById, - formatCurrencyUnit, -} from "../currencies"; import { getEnv } from "../env"; -import { RippleAPI } from "ripple-lib"; -import { Payment } from "ripple-lib/dist/npm/transaction/payment"; -import { TransactionsOptions } from "ripple-lib/dist/npm/ledger/transactions"; +import network from "../network"; +import { parseCurrencyUnit, getCryptoCurrencyById } from "../currencies"; -type AsyncApiFunction = (api: RippleAPI) => Promise; - -type XRPInstruction = { - fee: string; - maxLedgerVersionOffset: number; -}; - -const rippleUnit = getCryptoCurrencyById("ripple").units[0]; - -const defaultEndpoint = () => getEnv("API_RIPPLE_WS"); +const defaultEndpoint = () => getEnv("API_RIPPLE_RPC"); export const connectionTimeout = 30 * 1000; // default connectionTimeout is 2s and make the specs bot failed -const WEBSOCKET_DEBOUNCE_DELAY = 30000; -let api; -let pendingQueries: Promise[] = []; -let apiDisconnectTimeout; - -/** - * Connects to Substrate Node, executes calls then disconnects - * - * @param {*} execute - the calls to execute on api - */ -async function withApi( - execute: AsyncApiFunction, - endpointConfig: string | null | undefined = null -): Promise { - const server = endpointConfig || defaultEndpoint(); - - // If client is instanciated already, ensure it is connected & ready - if (api) { - try { - if (!(await api.isConnected)) { - throw new Error("XRP WS is not connected"); - } - } catch (err) { - // definitely not connected... - api = null; - pendingQueries = []; - } - } - - if (!api) { - api = new RippleAPI({ - server, - }); - // https://github.com/ripple/ripple-lib/issues/1196#issuecomment-583156895 - // We can't add connectionTimeout to the constructor - // We need to add this config to allow the bot to not timeout on github action - // but it will throw a 'additionalProperty "connectionTimeout" exists' - // during the preparePayment - api.connection._config.connectionTimeout = connectionTimeout; - api.on("error", (errorCode, errorMessage) => { - console.warn(`Ripple API error: ${errorCode}: ${errorMessage}`); - }); - await api.connect(); - } - - cancelDebouncedDisconnect(); - - try { - const query = execute(api); - pendingQueries.push(query.catch((err) => err)); - const res = await query; - return res; - } finally { - debouncedDisconnect(); - } -} - -/** - * Disconnects Websocket API client after all pending queries are flushed. - */ -export const disconnect = async (): Promise => { - cancelDebouncedDisconnect(); - - if (api) { - const disconnecting = api; - const pending = pendingQueries; - api = undefined; - pendingQueries = []; - await Promise.all(pending); - await disconnecting.disconnect(); - } -}; - -const cancelDebouncedDisconnect = () => { - if (apiDisconnectTimeout) { - clearTimeout(apiDisconnectTimeout); - apiDisconnectTimeout = null; - } -}; - -/** - * Disconnects Websocket client after a delay. - */ -const debouncedDisconnect = () => { - cancelDebouncedDisconnect(); - apiDisconnectTimeout = setTimeout(disconnect, WEBSOCKET_DEBOUNCE_DELAY); -}; +const rippleUnit = getCryptoCurrencyById("ripple").units[0]; export const parseAPIValue = (value: string): BigNumber => parseCurrencyUnit(rippleUnit, value); -export const parseAPICurrencyObject = ({ - currency, - value, -}: { - currency: string; - value: string; -}): BigNumber => { - if (currency !== "XRP") { - console.warn(`RippleJS: attempt to parse unknown currency ${currency}`); - return new BigNumber(0); - } - - return parseAPIValue(value); +export const submit = async (signature: string): Promise => { + const res = await network({ + method: "POST", + url: `${defaultEndpoint()}`, + data: { + method: "submit", + params: [ + { + tx_blob: signature, + }, + ], + }, + }); + return res.data.result; }; -export const formatAPICurrencyXRP = ( - amount: BigNumber -): { currency: "XRP"; value: string } => { - const value = formatCurrencyUnit(rippleUnit, amount, { - showAllDigits: true, - disableRounding: true, - useGrouping: false, - }); - return { - currency: "XRP", - value, +type AccountInfo = { + account_data: { + Account: string; + Balance: string; + Flags: number; + LedgerEntryType: string; + OwnerCount: number; + PreviousTxnID: string; + PreviousTxnLgrSeq: number; + Sequence: number; + index: string; }; + error: string; }; -export const preparePayment = async ( - address: string, - payment: Payment, - instruction: XRPInstruction -): Promise => - withApi(async (api: RippleAPI) => { - return api.preparePayment(address, payment, instruction); - }); - -export const submit = async (signature: string): Promise => - withApi(async (api: RippleAPI) => { - return api.request("submit", { - tx_blob: signature, - }); - }); -// endpointConfig does not seem to be undestood by linter - export const getAccountInfo = async ( recipient: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - endpointConfig?: string | null | undefined -): Promise => - withApi(async (api: RippleAPI) => { - return api.getAccountInfo(recipient); + current?: boolean +): Promise => { + const res = await network({ + method: "POST", + url: `${defaultEndpoint()}`, + data: { + method: "account_info", + params: [ + { + account: recipient, + ledger_index: current ? "current" : "validated", + }, + ], + }, }); + return res.data.result; +}; + export const getServerInfo = async ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars endpointConfig?: string | null | undefined -): Promise => - withApi(async (api: RippleAPI) => { - return api.getServerInfo(); +): Promise => { + const res = await network({ + method: "POST", + url: endpointConfig ?? `${defaultEndpoint()}`, + data: { + method: "server_info", + params: [ + { + ledger_index: "validated", + }, + ], + }, }); -/* eslint-enable no-unused-vars */ + return res.data.result; +}; + export const getTransactions = async ( address: string, - options: TransactionsOptions | undefined -): Promise => - withApi(async (api: RippleAPI) => { - return api.getTransactions(address, options); + options: any | undefined +): Promise => { + const res = await network({ + method: "POST", + url: `${defaultEndpoint()}`, + data: { + method: "account_tx", + params: [ + { + account: address, + ledger_index: "validated", + ...options, + }, + ], + }, }); + return res.data.result.transactions; +}; + +export default async function getLedgerIndex(): Promise { + const ledgerResponse = await network({ + method: "POST", + url: `${defaultEndpoint()}`, + data: { + method: "ledger", + params: [ + { + ledger_index: "validated", + }, + ], + }, + }); + return ledgerResponse.data.result.ledger_index; +} diff --git a/src/api/index.ts b/src/api/index.ts index 53f908c83d..1031da3a6c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,5 @@ -import { disconnect as rippleApiDisconnect } from "./Ripple"; import { disconnect as polkadotApiDisconnect } from "../families/polkadot/api"; export async function disconnectAll(): Promise { - await rippleApiDisconnect(); await polkadotApiDisconnect(); } diff --git a/src/env.ts b/src/env.ts index 249ce97565..5ed20036d1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -70,10 +70,10 @@ const envDefinitions = { parser: stringParser, desc: "Node API to use for cosmos_testnet (COSMOS_NODE or STARGATE_NODE are known)", }, - API_RIPPLE_WS: { + API_RIPPLE_RPC: { parser: stringParser, - def: "wss://xrplcluster.com/ledgerlive", - desc: "XRP Ledger full history open WebSocket endpoint", + def: "https://xrplcluster.com/ledgerlive", + desc: "XRP Ledger full history open JSON-RPC endpoint", }, API_FILECOIN_ENDPOINT: { parser: stringParser, diff --git a/src/families/ripple/bridge.test.ts b/src/families/ripple/bridge.test.ts index 49a84d7914..7205f9914e 100644 --- a/src/families/ripple/bridge.test.ts +++ b/src/families/ripple/bridge.test.ts @@ -1,12 +1,6 @@ import { setup } from "../../__tests__/test-helpers/libcore-setup"; import { testBridge } from "../../__tests__/test-helpers/bridge"; import dataset from "./test-dataset"; -import { disconnect } from "../../api/Ripple"; - -// Disconnect all api clients that could be open. -afterAll(async () => { - await disconnect(); -}); setup("ripple"); testBridge("ripple", dataset); diff --git a/src/families/ripple/bridge/js.ts b/src/families/ripple/bridge/js.ts index 65a5faf389..9731aa22fb 100644 --- a/src/families/ripple/bridge/js.ts +++ b/src/families/ripple/bridge/js.ts @@ -1,77 +1,42 @@ /* eslint-disable no-param-reassign */ -import invariant from "invariant"; -import { BigNumber } from "bignumber.js"; -import { Observable } from "rxjs"; -import bs58check from "ripple-bs58check"; import { AmountRequired, - NotEnoughBalanceBecauseDestinationNotCreated, - NotEnoughSpendableBalance, - InvalidAddress, FeeNotLoaded, + FeeRequired, FeeTooHigh, - NetworkDown, + InvalidAddress, InvalidAddressBecauseDestinationIsAlsoSource, - FeeRequired, + NetworkDown, + NotEnoughBalanceBecauseDestinationNotCreated, + NotEnoughSpendableBalance, RecipientRequired, } from "@ledgerhq/errors"; -import type { Account, Operation, SignOperationEvent } from "../../../types"; -import { - getDerivationModesForCurrency, - getDerivationScheme, - runDerivationScheme, - isIterableDerivationMode, - derivationModeSupportsIndex, -} from "../../../derivation"; -import { formatCurrencyUnit } from "../../../currencies"; -import { patchOperationWithHash } from "../../../operation"; +import { BigNumber } from "bignumber.js"; +import invariant from "invariant"; +import bs58check from "ripple-bs58check"; +import { Observable } from "rxjs"; import { getMainAccount } from "../../../account"; -import { - getAccountPlaceholderName, - getNewAccountPlaceholderName, - emptyHistoryCache, -} from "../../../account"; -import getAddress from "../../../hw/getAddress"; -import { withDevice } from "../../../hw/deviceAccess"; -import { - parseAPIValue, - parseAPICurrencyObject, - formatAPICurrencyXRP, -} from "../../../api/Ripple"; -import type { CurrencyBridge, AccountBridge } from "../../../types/bridge"; -import signTransaction from "../../../hw/signTransaction"; -import type { Transaction, NetworkInfo } from "../types"; -import { makeAccountBridgeReceive, mergeOps } from "../../../bridge/jsHelpers"; -import { - preparePayment, - submit, +import getLedgerIndex, { getAccountInfo, getServerInfo, - getTransactions, + parseAPIValue, + submit, } from "../../../api/Ripple"; +import { makeAccountBridgeReceive } from "../../../bridge/jsHelpers"; +import { formatCurrencyUnit } from "../../../currencies"; +import signTransaction from "../../../hw/signTransaction"; +import { withDevice } from "../../../hw/deviceAccess"; +import { patchOperationWithHash } from "../../../operation"; +import type { Account, Operation, SignOperationEvent } from "../../../types"; +import type { AccountBridge, CurrencyBridge } from "../../../types/bridge"; +import { scanAccounts, sync } from "../js-synchronization"; +import type { NetworkInfo, Transaction } from "../types"; -// true if the error should be forwarded and is not a "not found" case -const checkAccountNotFound = (e) => { - return ( - !e.data || (e.message !== "actNotFound" && e.data.error !== "actNotFound") - ); -}; +export const NEW_ACCOUNT_ERROR_MESSAGE = "actNotFound"; +const LEDGER_OFFSET = 20; const receive = makeAccountBridgeReceive(); -const getSequenceNumber = async (account) => { - const lastOp = account.operations.find((op) => op.type === "OUT"); - - if (lastOp && lastOp.transactionSequenceNumber) { - return ( - lastOp.transactionSequenceNumber + account.pendingOperations.length + 1 - ); - } - - const info = await getAccountInfo(account.freshAddress); - return info.sequence + account.pendingOperations.length; -}; - const uint32maxPlus1 = new BigNumber(2).pow(32); const validateTag = (tag) => { @@ -84,6 +49,11 @@ const validateTag = (tag) => { ); }; +const getNextValidSequence = async (account: Account) => { + const accInfo = await getAccountInfo(account.freshAddress, true); + return accInfo.account_data.Sequence; +}; + const signOperation = ({ account, transaction, @@ -97,46 +67,36 @@ const signOperation = ({ if (!fee) throw new FeeNotLoaded(); async function main() { - const amount = formatAPICurrencyXRP(transaction.amount); - const tag = transaction.tag ? transaction.tag : undefined; - const payment = { - source: { - address: account.freshAddress, - amount, - }, - destination: { - address: transaction.recipient, - minAmount: amount, - tag, - }, - }; - const instruction = { - fee: formatAPICurrencyXRP(fee).value, - maxLedgerVersionOffset: 12, - }; - if (tag) - invariant( - validateTag(new BigNumber(tag)), - `tag is set but is not in a valid format, should be between [0 - ${uint32maxPlus1 - .minus(1) - .toString()}]` - ); - const prepared = await preparePayment( - account.freshAddress, - payment, - instruction - ); - let signature; - try { + const tag = transaction.tag ? transaction.tag : undefined; + const nextSequenceNumber = await getNextValidSequence(account); + const payment = { + TransactionType: "Payment", + Account: account.freshAddress, + Amount: transaction.amount.toString(), + Destination: transaction.recipient, + DestinationTag: tag, + Fee: fee.toString(), + Flags: 2147483648, + Sequence: nextSequenceNumber, + LastLedgerSequence: (await getLedgerIndex()) + LEDGER_OFFSET, + }; + if (tag) + invariant( + validateTag(new BigNumber(tag)), + `tag is set but is not in a valid format, should be between [0 - ${uint32maxPlus1 + .minus(1) + .toString()}]` + ); + o.next({ type: "device-signature-requested", }); - signature = await signTransaction( + const signature = await signTransaction( account.currency, transport, account.freshAddressPath, - JSON.parse(prepared.txJSON) + payment ); o.next({ type: "device-signature-granted", @@ -155,15 +115,10 @@ const signOperation = ({ senders: [account.freshAddress], recipients: [transaction.recipient], date: new Date(), - // we probably can't get it so it's a predictive value - transactionSequenceNumber: await getSequenceNumber(account), + transactionSequenceNumber: nextSequenceNumber, extra: {} as any, }; - if (transaction.tag) { - operation.extra.tag = transaction.tag; - } - o.next({ type: "signed", signedOperation: { @@ -188,7 +143,9 @@ const signOperation = ({ }) ); -const broadcast = async ({ signedOperation: { signature, operation } }) => { +const broadcast = async ({ + signedOperation: { signature, operation }, +}): Promise => { const submittedPayment = await submit(signature); if ( @@ -202,7 +159,7 @@ const broadcast = async ({ signedOperation: { signature, operation } }) => { return patchOperationWithHash(operation, hash); }; -function isRecipientValid(recipient) { +function isRecipientValid(recipient: string): boolean { try { bs58check.decode(recipient); return true; @@ -211,125 +168,14 @@ function isRecipientValid(recipient) { } } -type Tx = { - type: string; - address: string; - sequence: number; - id: string; - specification: { - source: { - address: string; - maxAmount: { - currency: string; - value: string; - }; - }; - destination: { - address: string; - amount: { - currency: string; - value: string; - }; - tag?: string; - }; - paths: string; - }; - outcome: { - result: string; - fee: string; - timestamp: string; - deliveredAmount?: { - currency: string; - value: string; - counterparty: string; - }; - balanceChanges: Record< - string, - Array<{ - counterparty: string; - currency: string; - value: string; - }> - >; - orderbookChanges: Record< - string, - Array<{ - direction: string; - quantity: { - currency: string; - value: string; - }; - totalPrice: { - currency: string; - counterparty: string; - value: string; - }; - makeExchangeRate: string; - sequence: number; - status: string; - }> - >; - ledgerVersion: number; - indexInLedger: number; - }; -}; - -const txToOperation = - (account: Account) => - ({ - id, - sequence, - outcome: { fee, deliveredAmount, ledgerVersion, timestamp }, - specification: { source, destination }, - }: Tx): Operation | null | undefined => { - const type = source.address === account.freshAddress ? "OUT" : "IN"; - let value = deliveredAmount - ? parseAPICurrencyObject(deliveredAmount) - : new BigNumber(0); - const feeValue = parseAPIValue(fee); - - if (type === "OUT") { - if (!Number.isNaN(feeValue)) { - value = value.plus(feeValue); - } - } - - const op: Operation = { - id: `${account.id}-${id}-${type}`, - hash: id, - accountId: account.id, - type, - value, - fee: feeValue, - blockHash: null, - blockHeight: ledgerVersion, - senders: [source.address], - recipients: [destination.address], - date: new Date(timestamp), - transactionSequenceNumber: sequence, - extra: {}, - }; - - if (destination.tag) { - op.extra.tag = destination.tag; - } - - return op; - }; - -const recipientIsNew = async (endpointConfig, recipient) => { +const recipientIsNew = async (recipient: string): Promise => { if (!isRecipientValid(recipient)) return false; - try { - await getAccountInfo(recipient, endpointConfig); - return false; - } catch (e) { - if (checkAccountNotFound(e)) { - throw e; - } - + const info = await getAccountInfo(recipient); + if (info.error === NEW_ACCOUNT_ERROR_MESSAGE) { return true; } + return false; }; // FIXME this could be cleaner @@ -348,284 +194,18 @@ const remapError = (error) => { const cacheRecipientsNew = {}; -const cachedRecipientIsNew = (endpointConfig, recipient) => { +const cachedRecipientIsNew = (recipient: string) => { if (recipient in cacheRecipientsNew) return cacheRecipientsNew[recipient]; - cacheRecipientsNew[recipient] = recipientIsNew(endpointConfig, recipient); + cacheRecipientsNew[recipient] = recipientIsNew(recipient); return cacheRecipientsNew[recipient]; }; const currencyBridge: CurrencyBridge = { preload: () => Promise.resolve({}), hydrate: () => {}, - scanAccounts: ({ currency, deviceId }) => - withDevice(deviceId)( - (transport) => - new Observable((o) => { - let finished = false; - - const unsubscribe = () => { - finished = true; - }; - - async function main() { - try { - const serverInfo = await getServerInfo(); - const ledgers = serverInfo.completeLedgers.split("-"); - const minLedgerVersion = Number(ledgers[0]); - const maxLedgerVersion = Number(ledgers[1]); - const derivationModes = getDerivationModesForCurrency(currency); - - for (const derivationMode of derivationModes) { - const derivationScheme = getDerivationScheme({ - derivationMode, - currency, - }); - const stopAt = isIterableDerivationMode(derivationMode) - ? 255 - : 1; - - for (let index = 0; index < stopAt; index++) { - if (!derivationModeSupportsIndex(derivationMode, index)) - continue; - const freshAddressPath = runDerivationScheme( - derivationScheme, - currency, - { - account: index, - } - ); - const { address } = await getAddress(transport, { - currency, - path: freshAddressPath, - derivationMode, - }); - if (finished) return; - const accountId = `ripplejs:2:${currency.id}:${address}:${derivationMode}`; - let info; - - try { - info = await getAccountInfo(address); - } catch (e) { - if (checkAccountNotFound(e)) { - throw e; - } - } - - // fresh address is address. ripple never changes. - const freshAddress = address; - - if (!info) { - // account does not exist in Ripple server - // we are generating a new account locally - if (derivationMode === "") { - o.next({ - type: "discovered", - account: { - type: "Account", - id: accountId, - seedIdentifier: freshAddress, - derivationMode, - name: getNewAccountPlaceholderName({ - currency, - index, - derivationMode, - }), - starred: false, - used: false, - freshAddress, - freshAddressPath, - freshAddresses: [ - { - address: freshAddress, - derivationPath: freshAddressPath, - }, - ], - balance: new BigNumber(0), - spendableBalance: new BigNumber(0), - blockHeight: maxLedgerVersion, - index, - currency, - operationsCount: 0, - operations: [], - pendingOperations: [], - unit: currency.units[0], - // @ts-expect-error archived does not exists on type Account - archived: false, - lastSyncDate: new Date(), - creationDate: new Date(), - swapHistory: [], - balanceHistoryCache: emptyHistoryCache, // calculated in the jsHelpers - }, - }); - } - - break; - } - - if (finished) return; - const balance = parseAPIValue(info.xrpBalance); - invariant( - !balance.isNaN() && balance.isFinite(), - `Ripple: invalid balance=${balance.toString()} for address ${address}` - ); - const transactions = await getTransactions(address, { - minLedgerVersion, - maxLedgerVersion, - types: ["payment"], - }); - if (finished) return; - const account: Account = { - type: "Account", - id: accountId, - seedIdentifier: freshAddress, - derivationMode, - name: getAccountPlaceholderName({ - currency, - index, - derivationMode, - }), - starred: false, - used: true, - freshAddress, - freshAddressPath, - freshAddresses: [ - { - address: freshAddress, - derivationPath: freshAddressPath, - }, - ], - balance, - spendableBalance: balance, - // TODO calc with base reserve - blockHeight: maxLedgerVersion, - index, - currency, - operationsCount: 0, - operations: [], - pendingOperations: [], - unit: currency.units[0], - lastSyncDate: new Date(), - creationDate: new Date(), - swapHistory: [], - balanceHistoryCache: emptyHistoryCache, // calculated in the jsHelpers - }; - account.operations = transactions - .map(txToOperation(account)) - .filter(Boolean); - account.operationsCount = account.operations.length; - - if (account.operations.length > 0) { - account.creationDate = - account.operations[account.operations.length - 1].date; - } - - o.next({ - type: "discovered", - account, - }); - } - } - - o.complete(); - } catch (e) { - o.error(e); - } - } - - main(); - return unsubscribe; - }) - ), + scanAccounts, }; -const sync = ({ - endpointConfig, - freshAddress, - blockHeight, - operations, -}: any): Observable<(arg0: Account) => Account> => - new Observable((o) => { - let finished = false; - const currentOpsLength = operations ? operations.length : 0; - - const unsubscribe = () => { - finished = true; - }; - - async function main() { - try { - if (finished) return; - const serverInfo = await getServerInfo(endpointConfig); - if (finished) return; - const ledgers = serverInfo.completeLedgers.split("-"); - const minLedgerVersion = Number(ledgers[0]); - const maxLedgerVersion = Number(ledgers[1]); - let info; - - try { - info = await getAccountInfo(freshAddress); - } catch (e) { - if (checkAccountNotFound(e)) { - throw e; - } - } - - if (finished) return; - - if (!info) { - // account does not exist, we have nothing to sync but to update the last sync date - o.next((a) => ({ ...a, lastSyncDate: new Date() })); - o.complete(); - return; - } - - const balance = parseAPIValue(info.xrpBalance); - invariant( - !balance.isNaN() && balance.isFinite(), - `Ripple: invalid balance=${balance.toString()} for address ${freshAddress}` - ); - const transactions = await getTransactions(freshAddress, { - minLedgerVersion: Math.max( - currentOpsLength === 0 ? 0 : blockHeight, // if there is no ops, it might be after a clear and we prefer to pull from the oldest possible history - minLedgerVersion - ), - maxLedgerVersion, - types: ["payment"], - }); - if (finished) return; - o.next((a) => { - const newOps = transactions.map(txToOperation(a)); - const operations = mergeOps(a.operations, newOps); - const [last] = operations; - const pendingOperations = a.pendingOperations.filter( - (oo) => - !operations.some((op) => oo.hash === op.hash) && - last && - last.transactionSequenceNumber && - oo.transactionSequenceNumber && - oo.transactionSequenceNumber > last.transactionSequenceNumber - ); - return { - ...a, - balance, - spendableBalance: balance, - // TODO use reserve - operations, - pendingOperations, - blockHeight: maxLedgerVersion, - lastSyncDate: new Date(), - }; - }); - o.complete(); - } catch (e) { - o.error(remapError(e)); - } - } - - main(); - return unsubscribe; - }); - const createTransaction = (): Transaction => ({ family: "ripple", amount: new BigNumber(0), @@ -636,15 +216,23 @@ const createTransaction = (): Transaction => ({ feeCustomUnit: null, }); -const updateTransaction = (t, patch) => ({ ...t, ...patch }); +const updateTransaction = ( + t: Transaction, + patch: Transaction +): Transaction => ({ ...t, ...patch }); -const prepareTransaction = async (a: Account, t: Transaction) => { +const prepareTransaction = async ( + a: Account, + t: Transaction +): Promise => { let networkInfo: NetworkInfo | null | undefined = t.networkInfo; if (!networkInfo) { try { const info = await getServerInfo(a.endpointConfig); - const serverFee = parseAPIValue(info.validatedLedger.baseFeeXRP); + const serverFee = parseAPIValue( + info.info.validated_ledger.base_fee_xrp.toString() + ); networkInfo = { family: "ripple", serverFee, @@ -664,7 +252,7 @@ const prepareTransaction = async (a: Account, t: Transaction) => { return t; }; -const getTransactionStatus = async (a, t) => { +const getTransactionStatus = async (a: Account, t: Transaction) => { const errors: { fee?: Error; amount?: Error; @@ -674,7 +262,9 @@ const getTransactionStatus = async (a, t) => { feeTooHigh?: Error; } = {}; const r = await getServerInfo(a.endpointConfig); - const reserveBaseXRP = parseAPIValue(r.validatedLedger.reserveBaseXRP); + const reserveBaseXRP = parseAPIValue( + r.info.validated_ledger.reserve_base_xrp.toString() + ); const estimatedFees = new BigNumber(t.fee || 0); const totalSpent = new BigNumber(t.amount).plus(estimatedFees); const amount = new BigNumber(t.amount); @@ -697,12 +287,15 @@ const getTransactionStatus = async (a, t) => { }); } else if ( t.recipient && - (await cachedRecipientIsNew(a.endpointConfig, t.recipient)) && + (await cachedRecipientIsNew(t.recipient)) && t.amount.lt(reserveBaseXRP) ) { - const f = formatAPICurrencyXRP(reserveBaseXRP); errors.amount = new NotEnoughBalanceBecauseDestinationNotCreated("", { - minimalAmount: `${f.currency} ${new BigNumber(f.value).toFixed()}`, + minimalAmount: formatCurrencyUnit(a.currency.units[0], reserveBaseXRP, { + disableRounding: true, + useGrouping: false, + showCode: true, + }), }); } @@ -737,10 +330,12 @@ const estimateMaxSpendable = async ({ account, parentAccount, transaction, -}) => { +}): Promise => { const mainAccount = getMainAccount(account, parentAccount); const r = await getServerInfo(mainAccount.endpointConfig); - const reserveBaseXRP = parseAPIValue(r.validatedLedger.reserveBaseXRP); + const reserveBaseXRP = parseAPIValue( + r.info.validated_ledger.reserve_base_xrp.toString() + ); const t = await prepareTransaction(mainAccount, { ...createTransaction(), ...transaction, @@ -761,7 +356,7 @@ const accountBridge: AccountBridge = { prepareTransaction, getTransactionStatus, estimateMaxSpendable, - sync, + sync: sync, receive, signOperation, broadcast, diff --git a/src/families/ripple/js-synchronization.ts b/src/families/ripple/js-synchronization.ts new file mode 100644 index 0000000000..3e24ca7a5f --- /dev/null +++ b/src/families/ripple/js-synchronization.ts @@ -0,0 +1,153 @@ +import BigNumber from "bignumber.js"; +import { + getAccountInfo, + getServerInfo, + getTransactions, +} from "../../api/Ripple"; +import { + GetAccountShape, + makeScanAccounts, + makeSync, + mergeOps, +} from "../../bridge/jsHelpers"; +import { encodeOperationId } from "../../operation"; +import { Account, Operation } from "../../types"; +import { encodeAccountId } from "../../account"; +import { NEW_ACCOUNT_ERROR_MESSAGE } from "./bridge/js"; +import { TxXRPL } from "./types.api"; + +const txToOperation = + (accountId: string, address: string) => + ({ + meta: { delivered_amount }, + tx: { + DestinationTag, + Fee, + hash, + inLedger, + date, + Account, + Destination, + Sequence, + }, + }: TxXRPL): Operation | null | undefined => { + const type = Account === address ? "OUT" : "IN"; + let value = + delivered_amount && typeof delivered_amount === "string" + ? new BigNumber(delivered_amount) + : new BigNumber(0); + const feeValue = new BigNumber(Fee); + + if (type === "OUT") { + if (!Number.isNaN(feeValue)) { + value = value.plus(feeValue); + } + } + + // https://xrpl.org/basic-data-types.html#specifying-time + const toEpochDate = (946684800 + date) * 1000; + + const op: Operation = { + id: encodeOperationId(accountId, hash, type), + hash: hash, + accountId: accountId, + type, + value, + fee: feeValue, + blockHash: null, + blockHeight: inLedger, + senders: [Account], + recipients: [Destination], + date: new Date(toEpochDate), + transactionSequenceNumber: Sequence, + extra: {}, + }; + + if (DestinationTag) { + op.extra.tag = DestinationTag; + } + + return op; + }; + +const filterOperations: any = ( + transactions: TxXRPL[], + accountId: string, + address: string +) => { + return transactions + .filter( + (tx: TxXRPL) => + tx.tx.TransactionType === "Payment" && + typeof tx.meta.delivered_amount === "string" + ) + .map(txToOperation(accountId, address)) + .filter(Boolean); +}; + +const getAccountShape: GetAccountShape = async ( + info +): Promise> => { + const { address, initialAccount, currency, derivationMode } = info; + const accountId = encodeAccountId({ + type: "js", + version: "2", + currencyId: currency.id, + xpubOrAddress: address, + derivationMode, + }); + const accountInfo = await getAccountInfo(address); + + if (!accountInfo || accountInfo.error === NEW_ACCOUNT_ERROR_MESSAGE) { + return { + id: accountId, + xpub: address, + blockHeight: 0, + balance: new BigNumber(0), + spendableBalance: new BigNumber(0), + operations: [], + operationsCount: 0, + }; + } + + const serverInfo = await getServerInfo(); + + const oldOperations = initialAccount?.operations || []; + const startAt = oldOperations.length + ? (oldOperations[0].blockHeight || 0) + 1 + : 0; + + const ledgers = serverInfo.info.complete_ledgers.split("-"); + const minLedgerVersion = Number(ledgers[0]); + const maxLedgerVersion = Number(ledgers[1]); + + const balance = new BigNumber(accountInfo.account_data.Balance); + + const newTransactions = + (await getTransactions(address, { + ledger_index_min: Math.max( + startAt, // if there is no ops, it might be after a clear and we prefer to pull from the oldest possible history + minLedgerVersion + ), + ledger_index_max: maxLedgerVersion, + })) || []; + + const newOperations = filterOperations(newTransactions, accountId, address); + + const operations = mergeOps(oldOperations, newOperations as Operation[]); + + const shape = { + id: accountId, + xpub: address, + blockHeight: maxLedgerVersion, + balance, + spendableBalance: balance, + operations, + operationsCount: operations.length, + }; + + return shape; +}; + +export const scanAccounts = makeScanAccounts({ getAccountShape }); +export const sync = makeSync({ getAccountShape }); diff --git a/src/families/ripple/test-dataset.ts b/src/families/ripple/test-dataset.ts index ff3d35f805..1c3fb5cdbf 100644 --- a/src/families/ripple/test-dataset.ts +++ b/src/families/ripple/test-dataset.ts @@ -23,6 +23,8 @@ const dataset: DatasetTest = { unstableAccounts: true, // our account is getting spammed... apdus: ` + => e00200400d038000002c8000009080000000 + <= 2103c73f64083463fa923e1530af6f558204853873c6a45cbfb1f2f1e2ac2a5d989c2272734a4675764165634c333153513750594864504b6b3335625a456f78446d5231789000 => e002004015058000002c80000090800000000000000000000000 <= 2103d1adcff3e0cf1232b1416a75cd6f23b49dd6a25c69bc291a1f6783ec6825ec062272616765584842365134566276765764547a4b414e776a65435434485846434b58379000 => e002004015058000002c80000090800000010000000000000000 diff --git a/src/families/ripple/types.api.ts b/src/families/ripple/types.api.ts new file mode 100644 index 0000000000..34a359d16f --- /dev/null +++ b/src/families/ripple/types.api.ts @@ -0,0 +1,23 @@ +interface Currency { + currency: string; + amount: string; +} + +export interface TxXRPL { + meta: { + TransactionResult: string; + delivered_amount: Currency | string; + }; + tx: { + TransactionType: string; + Fee: string; + Account: string; + Destination: string; + DestinationTag?: number; + Amount: string; + Sequence: number; + date: number; + inLedger: number; + hash: string; + }; +} diff --git a/yarn.lock b/yarn.lock index 737b276ce1..512f5f3d53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2368,11 +2368,16 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= -"@types/lodash@^4.14.136", "@types/lodash@^4.14.159", "@types/lodash@^4.14.170", "@types/lodash@^4.14.178": +"@types/lodash@^4.14.159", "@types/lodash@^4.14.178": version "4.14.178" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== +"@types/lodash@^4.14.170": + version "4.14.177" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.177.tgz#f70c0d19c30fab101cad46b52be60363c43c4578" + integrity sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw== + "@types/long@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" @@ -2510,7 +2515,7 @@ resolved "https://registry.yarnpkg.com/@types/utf8/-/utf8-2.1.6.tgz#430cabb71a42d0a3613cce5621324fe4f5a25753" integrity sha512-pRs2gYF5yoKYrgSaira0DJqVg2tFuF+Qjp838xS7K+mJyY2jJzjsrl6y17GbIa4uMRogMbxs+ghNCvKg6XyNrA== -"@types/ws@^7.2.0", "@types/ws@^7.4.4": +"@types/ws@^7.4.4": version "7.4.7" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.4.7.tgz#f7c390a36f7a0679aa69de2d501319f4f8d9b702" integrity sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww== @@ -3416,7 +3421,7 @@ bn.js@4.11.8: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.0, bn.js@^4.11.6, bn.js@^4.11.8, bn.js@^4.11.9: +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.0, bn.js@^4.11.1, bn.js@^4.11.6, bn.js@^4.11.8, bn.js@^4.11.9: version "4.12.0" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== @@ -3467,7 +3472,7 @@ braces@^3.0.1: dependencies: fill-range "^7.0.1" -brorand@^1.0.1, brorand@^1.0.5, brorand@^1.1.0: +brorand@^1.0.1, brorand@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= @@ -6738,11 +6743,6 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= -jsonschema@1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.2.2.tgz#83ab9c63d65bf4d596f91d81195e78772f6452bc" - integrity sha512-iX5OFQ6yx9NgbHCwse51ohhKgLuLL7Z5cNOeZOPIlDUtAMrxlruHLzVZxbltdHE5mEDXN+75oFOwq6Gn0MZwsA== - jsprim@^1.2.2: version "1.4.2" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" @@ -8250,7 +8250,7 @@ ripemd160@2, ripemd160@^2.0.0, ripemd160@^2.0.1, ripemd160@^2.0.2: hash-base "^3.0.0" inherits "^2.0.1" -ripple-address-codec@^4.1.1, ripple-address-codec@^4.2.3: +ripple-address-codec@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/ripple-address-codec/-/ripple-address-codec-4.2.3.tgz#516675715cd43b71d2fd76c59bd92d0f623c152d" integrity sha512-9Nd0hQmKoJEhSTzYR9kYjKmSWlH6HaVosNVAM7mIIVlzcNlQCPfKXj7CfvXcRiHl3C6XUZj7RFLqzVaPjq2ufA== @@ -8258,10 +8258,10 @@ ripple-address-codec@^4.1.1, ripple-address-codec@^4.2.3: base-x "3.0.9" create-hash "^1.1.2" -ripple-binary-codec@^1.1.3, ripple-binary-codec@^1.3.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/ripple-binary-codec/-/ripple-binary-codec-1.3.2.tgz#dfea9daea2a2b9efc871dfcb56eeacc606135ba8" - integrity sha512-8VG1vfb3EM1J7ZdPXo9E57Zv2hF4cxT64gP6rGSQzODVgMjiBCWozhN3729qNTGtHItz0e82Oix8v95vWYBQ3A== +ripple-binary-codec@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ripple-binary-codec/-/ripple-binary-codec-1.3.0.tgz#0c6cf503fb0e12948d538abd198a740bd9d2143a" + integrity sha512-hz4nhiekqHbUwIdBOg1PQKsbi+/GwOccHmTTfkIJTTp/p5mlifS+U3Zfz4dVzKhftrXCPympYvLb5QgoIP1AKw== dependencies: assert "^2.0.0" big-integer "^1.6.48" @@ -8285,43 +8285,14 @@ ripple-bs58check@^2.0.2: create-hash "^1.1.0" ripple-bs58 "^4.0.0" -ripple-keypairs@^1.0.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/ripple-keypairs/-/ripple-keypairs-1.1.3.tgz#3af825ffe85c1777b0aa78d832e9fc5750d4529d" - integrity sha512-y74Y3c0g652BgpDhWsf0x98GnUyY2D9eO2ay2exienUfbIe00TeIiFhYXQhCGVnliGsxeV9WTpU+YuEWuIxuhw== - dependencies: - bn.js "^5.1.1" - brorand "^1.0.5" - elliptic "^6.5.4" - hash.js "^1.0.3" - ripple-address-codec "^4.2.3" - -ripple-lib-transactionparser@0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/ripple-lib-transactionparser/-/ripple-lib-transactionparser-0.8.2.tgz#7aaad3ba1e1aeee1d5bcff32334a7a838f834dce" - integrity sha512-1teosQLjYHLyOQrKUQfYyMjDR3MAq/Ga+MJuLUfpBMypl4LZB4bEoMcmG99/+WVTEiZOezJmH9iCSvm/MyxD+g== - dependencies: - bignumber.js "^9.0.0" - lodash "^4.17.15" - -ripple-lib@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/ripple-lib/-/ripple-lib-1.10.0.tgz#e41aaf17d5c6e6f8bcc8116736ac108ff3d6b810" - integrity sha512-Cg2u73UybfM1PnzcuLt5flvLKZn35ovdIp+1eLrReVB4swuRuUF/SskJG9hf5wMosbvh+E+jZu8A6IbYJoyFIA== +rlp@^2.0.0, rlp@^2.2.3: + version "2.2.6" + resolved "https://registry.npmjs.org/rlp/-/rlp-2.2.6.tgz" + integrity sha512-HAfAmL6SDYNWPUOJNrM500x4Thn4PZsEy5pijPh40U9WfNk0z15hUYzO9xVIMAdIHdFtD8CBDHd75Td1g36Mjg== dependencies: - "@types/lodash" "^4.14.136" - "@types/ws" "^7.2.0" - bignumber.js "^9.0.0" - https-proxy-agent "^5.0.0" - jsonschema "1.2.2" - lodash "^4.17.4" - ripple-address-codec "^4.1.1" - ripple-binary-codec "^1.1.3" - ripple-keypairs "^1.0.3" - ripple-lib-transactionparser "0.8.2" - ws "^7.2.0" + bn.js "^4.11.1" -rlp@^2.0.0, rlp@^2.2.3, rlp@^2.2.4: +rlp@^2.2.4: version "2.2.7" resolved "https://registry.yarnpkg.com/rlp/-/rlp-2.2.7.tgz#33f31c4afac81124ac4b283e2bd4d9720b30beaf" integrity sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ== @@ -9958,7 +9929,7 @@ ws@^3.0.0: safe-buffer "~5.1.0" ultron "~1.1.0" -ws@^7, ws@^7.2.0, ws@^7.4.5, ws@^7.4.6: +ws@^7, ws@^7.4.5, ws@^7.4.6: version "7.5.7" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.7.tgz#9e0ac77ee50af70d58326ecff7e85eb3fa375e67" integrity sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==