UNPKG

@bajetech/digitalbits-wallet-sdk

Version:

A library to make it easier to write wallets that interact with the DigitalBits blockchain

643 lines (563 loc) 17.6 kB
import debounce from "lodash/debounce"; import DigitalBitsSdk, { Account as DigitalBitsAccount, Asset, Keypair, Operation, Server, ServerApi, StrKey, TransactionBuilder, } from "xdb-digitalbits-sdk"; import { NATIVE_ASSET_IDENTIFIER } from "../constants/digitalbits"; import { Account, AccountDetails, AssetBalance, Collection, CollectionParams, FetchAccountError, NativeBalance, Offer, Payment, Trade, WatcherParams, WatcherResponse, } from "../types"; import { getDigitalBitsSdkAsset } from "./index"; import { makeDisplayableBalances } from "./makeDisplayableBalances"; import { makeDisplayableOffers } from "./makeDisplayableOffers"; import { makeDisplayablePayments } from "./makeDisplayablePayments"; import { makeDisplayableTrades } from "./makeDisplayableTrades"; export interface DataProviderParams { serverUrl: string; accountOrKey: Account | string; networkPassphrase: string; // these are passed to `new Server` metadata?: { allowHttp?: boolean; appName?: string; appVersion?: string; }; } function isAccount(obj: any): obj is Account { return obj && obj.publicKey !== undefined; } interface CallbacksObject { accountDetails?: () => void; payments?: () => void; } interface ErrorHandlersObject { accountDetails?: (error: any) => void; payments?: (error: any) => void; } interface WatcherTimeoutsObject { [name: string]: ReturnType<typeof setTimeout>; } export class DataProvider { private accountKey: string; private serverUrl: string; private server: Server; private networkPassphrase: string; private _watcherTimeouts: WatcherTimeoutsObject; private effectStreamEnder?: () => void; private callbacks: CallbacksObject; private errorHandlers: ErrorHandlersObject; constructor(params: DataProviderParams) { const accountKey = isAccount(params.accountOrKey) ? params.accountOrKey.publicKey : params.accountOrKey; if (!accountKey) { throw new Error("No account key provided."); } if (!params.serverUrl) { throw new Error("No server url provided."); } if (!params.networkPassphrase) { throw new Error("No network passphrase provided."); } // make sure the account key is a real account try { Keypair.fromPublicKey(accountKey); } catch (e) { throw new Error(`The provided key was not valid: ${accountKey}`); } const metadata = params.metadata || {}; this.callbacks = {}; this.errorHandlers = {}; this.effectStreamEnder = undefined; this.networkPassphrase = params.networkPassphrase; this.serverUrl = params.serverUrl; this.server = new Server(this.serverUrl, metadata); this.accountKey = accountKey; this._watcherTimeouts = {}; } /** * Return true if the key is valid. (It doesn't comment on whether the * key is a funded account.) */ public isValidKey(): boolean { return StrKey.isValidEd25519PublicKey(this.accountKey); } /** * Return the current key. */ public getAccountKey(): string { return this.accountKey; } /** * Return the server object, in case the consumer wants to call an * unsupported function. */ public getServer(): Server { return this.server; } /** * Check if the current account is funded or not. */ public async isAccountFunded(): Promise<boolean> { try { await this.fetchAccountDetails(); return true; } catch (e: any) { return !e.isUnfunded; } } /** * Fetch outstanding offers. */ public async fetchOpenOffers( params: CollectionParams = {} ): Promise<Collection<Offer>> { // first, fetch all offers const offers = await this.server .offers() .forAccount(this.accountKey) .limit(params.limit || 10) .order(params.order || "desc") .cursor(params.cursor || "") .call(); return this._processOpenOffers(offers); } /** * Fetch recent trades. */ public async fetchTrades( params: CollectionParams = {} ): Promise<Collection<Trade>> { const trades = await this.server .trades() .forAccount(this.accountKey) .limit(params.limit || 10) .order(params.order || "desc") .cursor(params.cursor || "") .call(); return this._processTrades(trades); } /** * Fetch payments (also includes path payments account creation). */ public async fetchPayments( params: CollectionParams = {} ): Promise<Collection<Payment>> { const payments = await this.server .payments() .forAccount(this.accountKey) .limit(params.limit || 10) .order(params.order || "desc") .cursor(params.cursor || "") .join("transactions") .call(); return this._processPayments(payments); } /** * Fetch account details (balances, signers, etc.). */ public async fetchAccountDetails(): Promise<AccountDetails> { try { const accountSummary = await this.server .accounts() .accountId(this.accountKey) .call(); const balances = makeDisplayableBalances(accountSummary); const sponsor = accountSummary.sponsor ? { sponsor: accountSummary.sponsor } : {}; return { ...sponsor, id: accountSummary.id, subentryCount: accountSummary.subentry_count, sponsoredCount: accountSummary.num_sponsored, sponsoringCount: accountSummary.num_sponsoring, inflationDestination: accountSummary.inflation_destination, thresholds: accountSummary.thresholds, signers: accountSummary.signers, flags: accountSummary.flags, sequenceNumber: accountSummary.sequence, balances, }; } catch (err: any) { err.isUnfunded = err.response && err.response.status === 404; throw err as FetchAccountError; } } /** * Fetch account details, then re-fetch whenever the details update. * If the account doesn't exist yet, it will re-check it every 2 seconds. * Returns a function you can execute to stop the watcher. */ public watchAccountDetails( params: WatcherParams<AccountDetails> ): WatcherResponse { const { onMessage, onError } = params; this.fetchAccountDetails() // if the account is funded, watch for effects. .then(res => { onMessage(res); this.callbacks.accountDetails = debounce(() => { this.fetchAccountDetails().then(onMessage).catch(onError); }, 2000); this.errorHandlers.accountDetails = onError; this._startEffectWatcher().catch(err => { onError(err); }); }) // otherwise, if it's a 404, try again in a bit. .catch(err => { if (err.isUnfunded) { this._watcherTimeouts.watchAccountDetails = setTimeout(() => { this.watchAccountDetails(params); }, 2000); } onError(err); }); return { refresh: () => { this.stopWatchAccountDetails(); this.watchAccountDetails(params); }, stop: () => { this.stopWatchAccountDetails(); }, }; } /** * Fetch payments, then re-fetch whenever the details update. * Returns a function you can execute to stop the watcher. */ public watchPayments(params: WatcherParams<Payment>): WatcherResponse { const { onMessage, onError } = params; let getNextPayments: () => Promise<Collection<Payment>>; this.fetchPayments() // if the account is funded, watch for effects. .then(res => { // for the first page load, "prev" is the people we want to get next! getNextPayments = res.prev; // onMessage each payment separately res.records.forEach(onMessage); this.callbacks.payments = debounce(() => { getNextPayments() .then(nextRes => { // afterwards, "next" will be the next person! getNextPayments = nextRes.next; // get new things if (nextRes.records.length) { nextRes.records.forEach(onMessage); } }) .catch(onError); }, 2000); this.errorHandlers.payments = onError; this._startEffectWatcher().catch(err => { onError(err); }); }) // otherwise, if it's a 404, try again in a bit. .catch(err => { if (err.isUnfunded) { this._watcherTimeouts.watchPayments = setTimeout(() => { this.watchPayments(params); }, 2000); } onError(err); }); return { refresh: () => { this.stopWatchPayments(); this.watchPayments(params); }, stop: () => { this.stopWatchPayments(); }, }; } /** * Given a destination key, return a transaction that removes all trustlines * and offers on the tracked account and merges the account into a given one. * * @throws Throws if the account has balances. * @throws Throws if the destination account is invalid. */ public async getStripAndMergeAccountTransaction(destinationKey: string) { // make sure the destination is a funded account if (!StrKey.isValidEd25519PublicKey(destinationKey)) { throw new Error("The destination is not a valid DigitalBits address."); } try { const destinationProvider = new DataProvider({ serverUrl: this.serverUrl, accountOrKey: destinationKey, networkPassphrase: this.networkPassphrase, }); destinationProvider.fetchAccountDetails(); } catch (e: any) { if (e.isUnfunded) { throw new Error("The destination account is not funded yet."); } throw new Error( `Couldn't fetch the destination account, error: ${e.toString()}` ); } let account: AccountDetails; // fetch the current account try { account = await this.fetchAccountDetails(); } catch (e: any) { throw new Error(`Couldn't fetch account details, error: ${e.toString()}`); } // make sure all non-native balances are zero const hasNonZeroBalance = Object.keys(account.balances).reduce( (memo, identifier) => { const balance = account.balances[identifier]; if (identifier !== NATIVE_ASSET_IDENTIFIER && balance?.total.gt(0)) { return true; } return memo; }, false ); if (hasNonZeroBalance) { throw new Error( "This account can't be closed until all non-XDB balances are 0." ); } // get ALL offers for the account // (we don't need trade details, so skip those) let offers: ServerApi.OfferRecord[] = []; try { let additionalOffers: ServerApi.OfferRecord[] | undefined; let next: () => Promise<Collection<ServerApi.OfferRecord>> = () => this.server .offers() .forAccount(this.accountKey) .limit(25) .order("desc") .call(); while (additionalOffers === undefined || additionalOffers.length) { const res = await next(); additionalOffers = res.records; next = res.next; offers = [...offers, ...additionalOffers]; } } catch (e: any) { throw new Error(`Couldn't fetch open offers, error: ${e.stack}`); } const accountObject = new DigitalBitsAccount( this.accountKey, account.sequenceNumber ); let fee = DigitalBitsSdk.BASE_FEE; try { const feeStats = await this.server.feeStats(); fee = feeStats.max_fee.p70; } catch (e) { // do nothing } const transaction = new TransactionBuilder(accountObject, { fee, networkPassphrase: this.networkPassphrase, timebounds: await this.server.fetchTimebounds(10 * 60 * 1000), }); // strip offers offers.forEach(offer => { const { seller, selling, buying, id } = offer; let operation; // check if we're the seller if (seller === this.accountKey) { operation = Operation.manageSellOffer({ selling: selling.asset_code && selling.asset_issuer ? new Asset(selling.asset_code, selling.asset_issuer) : Asset.native(), buying: buying.asset_code && buying.asset_issuer ? new Asset(buying.asset_code, buying.asset_issuer) : Asset.native(), amount: "0", price: "0", offerId: id, }); } else { operation = Operation.manageBuyOffer({ selling: selling.asset_code && selling.asset_issuer ? new Asset(selling.asset_code, selling.asset_issuer) : Asset.native(), buying: buying.asset_code && buying.asset_issuer ? new Asset(buying.asset_code, buying.asset_issuer) : Asset.native(), buyAmount: "0", price: "0", offerId: id, }); } transaction.addOperation(operation); }); // strip trustlines Object.keys(account.balances).forEach(identifier => { if (identifier === NATIVE_ASSET_IDENTIFIER) { return; } const balance = account.balances[identifier]; transaction.addOperation( Operation.changeTrust({ asset: getDigitalBitsSdkAsset( (balance as AssetBalance | NativeBalance).token ), limit: "0", }) ); }); transaction.addOperation( Operation.accountMerge({ destination: destinationKey, }) ); return transaction.build(); } /** * Stop acount details watcher. */ private stopWatchAccountDetails() { if (this._watcherTimeouts.watchAccountDetails) { clearTimeout(this._watcherTimeouts.watchAccountDetails); } if (this.effectStreamEnder) { this.effectStreamEnder(); this.effectStreamEnder = undefined; } delete this.callbacks.accountDetails; delete this.errorHandlers.accountDetails; } /** * Stop payments watcher. */ private stopWatchPayments() { if (this._watcherTimeouts.watchPayments) { clearTimeout(this._watcherTimeouts.watchPayments); } if (this.effectStreamEnder) { this.effectStreamEnder(); this.effectStreamEnder = undefined; } delete this.callbacks.payments; delete this.errorHandlers.payments; } private async _processOpenOffers( offers: ServerApi.CollectionPage<ServerApi.OfferRecord> ): Promise<Collection<Offer>> { // find all offerids and check for trades of each const tradeRequests: Array< Promise<ServerApi.CollectionPage<ServerApi.TradeRecord>> > = offers.records.map(({ id }: { id: number | string }) => this.server.trades().forOffer(`${id}`).call() ); const tradeResponses = await Promise.all(tradeRequests); return { next: () => offers.next().then(res => this._processOpenOffers(res)), prev: () => offers.prev().then(res => this._processOpenOffers(res)), records: makeDisplayableOffers( { publicKey: this.accountKey }, { offers: offers.records, tradeResponses: tradeResponses.map( (res: ServerApi.CollectionPage<ServerApi.TradeRecord>) => res.records ), } ), }; } private async _processTrades( trades: ServerApi.CollectionPage<ServerApi.TradeRecord> ): Promise<Collection<Trade>> { return { next: () => trades.next().then(res => this._processTrades(res)), prev: () => trades.prev().then(res => this._processTrades(res)), records: makeDisplayableTrades( { publicKey: this.accountKey }, trades.records ), }; } private async _processPayments( payments: ServerApi.CollectionPage< | ServerApi.PaymentOperationRecord | ServerApi.CreateAccountOperationRecord | ServerApi.PathPaymentOperationRecord > ): Promise<Collection<Payment>> { return { next: () => payments.next().then(res => this._processPayments(res)), prev: () => payments.prev().then(res => this._processPayments(res)), records: await makeDisplayablePayments( { publicKey: this.accountKey }, payments.records ), }; } // Account details and payments use the same stream watcher private async _startEffectWatcher(): Promise<{}> { if (this.effectStreamEnder) { return Promise.resolve({}); } // get the latest cursor const recentEffect = await this.server .effects() .forAccount(this.accountKey) .limit(1) .order("desc") .call(); const cursor: string | undefined = recentEffect.records[0]?.paging_token; this.effectStreamEnder = this.server .effects() .forAccount(this.accountKey) .cursor(cursor || "") .stream({ onmessage: () => { // run all callbacks const callbacks = Object.values(this.callbacks).filter( callback => !!callback ); if (callbacks.length) { callbacks.forEach(callback => { callback(); }); } }, onerror: e => { // run error handlers const errorHandlers = Object.values(this.errorHandlers).filter( errorHandler => !!errorHandler ); if (errorHandlers.length) { errorHandlers.forEach(errorHandler => { errorHandler(e); }); } }, }); return Promise.resolve({}); } }