import { ApiPromise } from "@polkadot/api"; import { SubmittableExtrinsic } from "@polkadot/api/submittable/types"; import { Option } from "@polkadot/types"; import { AccountId, H256, Hash, EventRecord } from "@polkadot/types/interfaces"; import { Network } from "bitcoinjs-lib"; import Big from "big.js"; import { BitcoinUnit, Currency, MonetaryAmount } from "@interlay/monetary-js"; import { InterbtcPrimitivesIssueIssueRequest, InterbtcPrimitivesVaultId } from "@polkadot/types/lookup"; import { VaultsAPI } from "./vaults"; import { decodeFixedPointType, getTxProof, allocateAmountsToVaults, getRequestIdsFromEvents, storageKeyToNthInner, ensureHashEncoded, addHexPrefix, parseIssueRequest, newMonetaryAmount, newVaultId, newCurrencyId, } from "../utils"; import { FeeAPI } from "./fee"; import { ElectrsAPI } from "../external"; import { TransactionAPI } from "./transaction"; import { CollateralCurrency, CollateralIdLiteral, CurrencyIdLiteral, currencyIdToMonetaryCurrency, Issue, WrappedCurrency, } from "../types"; export type IssueLimits = { singleVaultMaxIssuable: MonetaryAmount<WrappedCurrency, BitcoinUnit>; totalMaxIssuable: MonetaryAmount<WrappedCurrency, BitcoinUnit>; }; /** * @category BTC Bridge */ export interface IssueAPI { /** * Gets the threshold for issuing with a single vault, and the maximum total * issue request size. Additionally passes the list of vaults for caching. * @param vaults (optional) A list of the vaults available to issue from. If not provided, will fetch from the * parachain (incurring an extra request). * @returns An object of type {singleVault, maxTotal, vaultsCache} */ getRequestLimits( vaults?: Map<InterbtcPrimitivesVaultId, MonetaryAmount<WrappedCurrency, BitcoinUnit>> ): Promise<IssueLimits>; /** * Request issuing wrapped tokens (e.g. interBTC, kBTC). * @param amount wrapped token amount to issue. * @param vaultId (optional) Account ID of the vault to issue with. * @param collateralCurrencyIdLiteral (optional) Collateral currency for backing wrapped tokens * @param atomic (optional) Whether the issue request should be handled atomically or not. Only makes a difference * if more than one vault is needed to fulfil it. Defaults to false. * @param retries (optional) Number of times to retry issuing, if some of the requests fail. Defaults to 0. * @param availableVaults (optional) A list of all vaults usable for issue. If not provided, will fetch from the parachain. * @returns An array of type {issueId, issueRequest} if the requests succeeded. The function throws an error otherwise. */ request( amount: MonetaryAmount<WrappedCurrency, BitcoinUnit>, vaultAccountId?: AccountId, collateralCurrencyIdLiteral?: CurrencyIdLiteral, atomic?: boolean, retries?: number, availableVaults?: Map<InterbtcPrimitivesVaultId, MonetaryAmount<WrappedCurrency, BitcoinUnit>> ): Promise<Issue[]>; /** * Send a batch of aggregated issue transactions (to one or more vaults) * @param amountsPerVault A mapping of vaults to issue from, and wrapped token amounts to issue using each vault * @param atomic Whether the issue request should be handled atomically or not. Only makes a difference if more than * one vault is needed to fulfil it. * @returns An array of `Issue` objects, if the requests succeeded. * @throws Rejects the promise if none of the requests succeeded (or if at least one failed, when atomic=true). */ requestAdvanced( amountsPerVault: Map<InterbtcPrimitivesVaultId, MonetaryAmount<WrappedCurrency, BitcoinUnit>>, atomic: boolean ): Promise<Issue[]>; /** * Send an issue execution transaction * @remarks If `txId` is not set, the `merkleProof` and `rawTx` must both be set. * * @param issueId The ID returned by the issue request transaction * @param btcTxId Bitcoin transaction ID */ execute(requestId: string, btcTxId: string): Promise<void>; /** * Send an issue cancellation transaction. After the issue period has elapsed, * the issuance request can be cancelled. As a result, the griefing collateral * of the requester will be slashed and sent to the vault that had prepared to issue. * @param issueId The ID returned by the issue request transaction */ cancel(issueId: string): Promise<void>; /** * @remarks Testnet utility function * @param blocks The time difference in number of blocks between an issue request is created * and required completion time by a user. The issue period has an upper limit * to prevent griefing of vault collateral. */ setIssuePeriod(blocks: number): Promise<void>; /** * * @returns The time difference in number of blocks between an issue request is created * and required completion time by a user. The issue period has an upper limit * to prevent griefing of vault collateral. */ getIssuePeriod(): Promise<number>; /** * @returns An array containing the issue requests */ list(): Promise<Issue[]>; /** * @param issueId The ID of the issue request to fetch * @returns An issue request object */ getRequestById(issueId: H256 | string): Promise<Issue>; /** * @param issueId The IDs of the batch of issue request to fetch * @returns The issue request objects */ getRequestsByIds(issueIds: (H256 | string)[]): Promise<Issue[]>; /** * @returns The minimum amount of wrapped tokens that is accepted for issue requests; any lower values would * risk the bitcoin client to reject the payment */ getDustValue(): Promise<MonetaryAmount<WrappedCurrency, BitcoinUnit>>; /** * @returns The fee charged for issuing. For instance, "0.005" stands for 0.5% */ getFeeRate(): Promise<Big>; /** * @param amount The amount, in BTC, for which to compute the issue fees * @returns The fees, in BTC */ getFeesToPay( amount: MonetaryAmount<WrappedCurrency, BitcoinUnit> ): Promise<MonetaryAmount<WrappedCurrency, BitcoinUnit>>; /** * @param vaultAccountId The vault account ID * @param collateralCurrency The currency specification, a `Monetary.js` object * @returns The amount of wrapped tokens issuable by this vault */ getVaultIssuableAmount( vaultAccountId: AccountId, collateralCurrency: CurrencyIdLiteral ): Promise<MonetaryAmount<WrappedCurrency, BitcoinUnit>>; } export class DefaultIssueAPI implements IssueAPI { constructor( private api: ApiPromise, private btcNetwork: Network, private electrsAPI: ElectrsAPI, private wrappedCurrency: WrappedCurrency, private feeAPI: FeeAPI, private vaultsAPI: VaultsAPI, private transactionAPI: TransactionAPI ) {} async getRequestLimits( vaults?: Map<InterbtcPrimitivesVaultId, MonetaryAmount<WrappedCurrency, BitcoinUnit>> ): Promise<IssueLimits> { if (!vaults) vaults = await this.vaultsAPI.getVaultsWithIssuableTokens(); const vaultsArr = [...vaults.entries()].sort( // sort in descending order ([_id_1, amount_1], [_id_2, amount_2]) => amount_2.sub(amount_1).toBig().toNumber() ); if (vaultsArr.length === 0) { return { singleVaultMaxIssuable: newMonetaryAmount(0, this.wrappedCurrency), totalMaxIssuable: newMonetaryAmount(0, this.wrappedCurrency), }; } const singleVaultMaxIssuable = vaultsArr[0][1]; const totalMaxIssuable = vaultsArr.reduce((total, [_, vaultAvailable]) => { return total.add(vaultAvailable); }, newMonetaryAmount(0, this.wrappedCurrency)); return { singleVaultMaxIssuable, totalMaxIssuable }; } /** * @param events The EventRecord array returned after sending an issue request transaction * @returns The issueId associated with the request. If the EventRecord array does not * contain issue request events, the function throws an error. */ private getIssueIdsFromEvents(events: EventRecord[]): Hash[] { return getRequestIdsFromEvents(events, this.api.events.issue.RequestIssue, this.api); } async request( amount: MonetaryAmount<WrappedCurrency, BitcoinUnit>, vaultAccountId?: AccountId, collateralCurrencyIdLiteral?: CurrencyIdLiteral, atomic: boolean = true, retries: number = 0, cachedVaults?: Map<InterbtcPrimitivesVaultId, MonetaryAmount<WrappedCurrency, BitcoinUnit>> ): Promise<Issue[]> { try { if (vaultAccountId) { if (!collateralCurrencyIdLiteral) { return Promise.reject( new Error("A collateral currency must be specified along with the vault account ID") ); } // If a vault account id is defined, request to issue with that vault only. // Initialize the `amountsPerVault` map with a single entry, the (vaultId, amount) pair const collateralCurrencyId = newCurrencyId(this.api, collateralCurrencyIdLiteral); const vaultId = newVaultId( this.api, vaultAccountId.toString(), currencyIdToMonetaryCurrency(collateralCurrencyId) as CollateralCurrency, this.wrappedCurrency ); const amountsPerVault = new Map< InterbtcPrimitivesVaultId, MonetaryAmount<WrappedCurrency, BitcoinUnit> >([[vaultId, amount]]); return await this.requestAdvanced(amountsPerVault, atomic); } const availableVaults = cachedVaults || (await this.vaultsAPI.getVaultsWithIssuableTokens()); const amountsPerVault = allocateAmountsToVaults(availableVaults, amount); const result = await this.requestAdvanced(amountsPerVault, atomic); const successfulSum = result.reduce( (sum, req) => sum.add(req.wrappedAmount), newMonetaryAmount(0, this.wrappedCurrency) ); const remainder = amount.sub(successfulSum); if (remainder.isZero() || retries === 0) return result; else { return ( await this.request( remainder, vaultAccountId, collateralCurrencyIdLiteral, atomic, retries - 1, availableVaults ) ).concat(result); } } catch (e) { return Promise.reject(e); } } async craftRequestTx( vaultId: InterbtcPrimitivesVaultId, amount: MonetaryAmount<WrappedCurrency, BitcoinUnit> ): Promise<SubmittableExtrinsic<"promise">> { return this.api.tx.issue.requestIssue(amount.toString(amount.currency.rawBase), vaultId); } async requestAdvanced( amountsPerVault: Map<InterbtcPrimitivesVaultId, MonetaryAmount<WrappedCurrency, BitcoinUnit>>, atomic: boolean ): Promise<Issue[]> { const txs = await Promise.all( Array.from(amountsPerVault.entries()).map( ([vaultId, amount]) => new Promise<SubmittableExtrinsic<"promise">>((resolve) => { this.craftRequestTx(vaultId, amount).then(resolve); }) ) ); // batchAll fails atomically, batch allows partial successes const batch = (atomic ? this.api.tx.utility.batchAll : this.api.tx.utility.batch)(txs); try { const result = await this.transactionAPI.sendLogged(batch, this.api.events.issue.RequestIssue); const ids = this.getIssueIdsFromEvents(result.events); const issueRequests = await this.getRequestsByIds(ids); return issueRequests; } catch (e) { return Promise.reject(e); } } async execute(requestId: string, btcTxId: string): Promise<void> { const parsedRequestId = ensureHashEncoded(this.api, requestId); const txInclusionDetails = await getTxProof(this.electrsAPI, btcTxId); const tx = this.api.tx.issue.executeIssue( parsedRequestId, txInclusionDetails.merkleProof, txInclusionDetails.rawTx ); await this.transactionAPI.sendLogged(tx, this.api.events.issue.ExecuteIssue, true); } async cancel(requestId: string): Promise<void> { const parsedRequestId = this.api.createType("H256", addHexPrefix(requestId)); const cancelIssueTx = this.api.tx.issue.cancelIssue(parsedRequestId); await this.transactionAPI.sendLogged(cancelIssueTx, this.api.events.issue.CancelIssue, true); } async setIssuePeriod(blocks: number): Promise<void> { const period = this.api.createType("BlockNumber", blocks); const tx = this.api.tx.sudo.sudo(this.api.tx.issue.setIssuePeriod(period)); await this.transactionAPI.sendLogged(tx, undefined, true); } async getIssuePeriod(): Promise<number> { const blockNumber = await this.api.query.issue.issuePeriod(); return blockNumber.toNumber(); } async list(): Promise<Issue[]> { const issueRequests = await this.api.query.issue.issueRequests.entries(); return await Promise.all( issueRequests .filter(([_, req]) => req.isSome.valueOf()) // Can be unwrapped because the filter removes `None` values .map(([id, req]) => parseIssueRequest(this.vaultsAPI, req.unwrap(), this.btcNetwork, storageKeyToNthInner(id)) ) ); } async getFeesToPay( amount: MonetaryAmount<WrappedCurrency, BitcoinUnit> ): Promise<MonetaryAmount<WrappedCurrency, BitcoinUnit>> { const feePercentage = await this.getFeeRate(); return amount.mul(feePercentage); } async getDustValue(): Promise<MonetaryAmount<WrappedCurrency, BitcoinUnit>> { const dustValueSat = await this.api.query.issue.issueBtcDustValue(); return newMonetaryAmount(dustValueSat.toString(), this.wrappedCurrency); } async getFeeRate(): Promise<Big> { const issueFee = await this.api.query.fee.issueFee(); return decodeFixedPointType(issueFee); } async getRequestById(issueId: H256 | string): Promise<Issue> { return (await this.getRequestsByIds([issueId]))[0]; } async getRequestsByIds(issueIds: (H256 | string)[]): Promise<Issue[]> { const head = await this.api.rpc.chain.getFinalizedHead(); const issueRequestData = await Promise.all( issueIds.map( async (issueId): Promise<[Option<InterbtcPrimitivesIssueIssueRequest>, H256 | string]> => new Promise((resolve, reject) => { this.api.query.issue.issueRequests .at(head, ensureHashEncoded(this.api, issueId)) .then((request) => resolve([request, issueId])) .catch(reject); }) ) ); return Promise.all( issueRequestData .filter(([option, _]) => option.isSome) .map(([issueRequest, issueId]) => parseIssueRequest(this.vaultsAPI, issueRequest.unwrap(), this.btcNetwork, issueId) ) ); } async getVaultIssuableAmount( vaultAccountId: AccountId, collateralCurrency: CollateralIdLiteral ): Promise<MonetaryAmount<Currency<BitcoinUnit>, BitcoinUnit>> { const vault = await this.vaultsAPI.get(vaultAccountId, collateralCurrency); const wrappedTokenCapacity = await this.vaultsAPI.calculateCapacity(vault.backingCollateral); const issuedAmount = vault.issuedTokens.add(vault.toBeIssuedTokens); const issuableAmountExcludingFees = wrappedTokenCapacity.sub(issuedAmount); const fees = await this.getFeesToPay(issuableAmountExcludingFees); return issuableAmountExcludingFees.sub(fees); } }