UNPKG

@wharfkit/transact-plugin-resource-provider

Version:

Plugin to automatically provide network resources for transactions using the Resource Provider implementation standard.

474 lines (465 loc) 22.9 kB
/** * @wharfkit/transact-plugin-resource-provider v1.1.2 * https://github.com/wharfkit/transact-plugin-resource-provider * * @license * Copyright (c) 2021 FFF00 Agents AB & Greymass Inc. All Rights Reserved. * * Redistribution and use in source and binary forms, with or without modification, * are permitted provided that the following conditions are met: * * 1. Redistribution of source code must retain the above copyright notice, this * list of conditions and the following disclaimer. * * 2. Redistribution in binary form must reproduce the above copyright notice, * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. * * 3. Neither the name of the copyright holder nor the names of its contributors * may be used to endorse or promote products derived from this software without * specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED * OF THE POSSIBILITY OF SUCH DAMAGE. * * YOU ACKNOWLEDGE THAT THIS SOFTWARE IS NOT DESIGNED, LICENSED OR INTENDED FOR USE * IN THE DESIGN, CONSTRUCTION, OPERATION OR MAINTENANCE OF ANY MILITARY FACILITY. */ import { __decorate } from 'tslib'; import { Struct, Name, Asset, AbstractTransactPlugin, TransactHookTypes, SigningRequest, Serializer, Signature, Canceled, Transaction } from '@wharfkit/session'; var timeout$3 = "The offer from the resource provider has expired."; var fee$3 = { title: "Accept Transaction Fee?", body: "Additional resources ({{resource}}) are required for your account to perform this transaction. Would you like to automatically purchase these resources and proceed?", cost: "Cost of {{resource}}" }; var rejected$3 = { "no-fees": "A resource provider offered to cover this transaction for a fee, but fee-based transactions are disabled by the configuration using `allowFees = false`.", "original-modified": "The original transaction returned by the resource provider has been modified too much. Continuing without the resource provider", "max-fee": "The fee requested by the resource provider is unusually high and has been rejected." }; var en = { timeout: timeout$3, "will-continue": "The transaction will continue without the resource provider.", fee: fee$3, rejected: rejected$3 }; var timeout$2 = "리소스 공급자의 제안이 만료되었습니다."; var fee$2 = { title: "거래 수수료를 수락하시겠습니까?", body: "계정에서 이 거래를 수행하려면 추가 리소스({{resource}})가 필요합니다. 이러한 리소스를 자동으로 구매하고 계속 진행하시겠습니까?", cost: "{{resource}}의 비용" }; var rejected$2 = { "no-fees": "리소스 공급자가 이 트랜잭션을 유료로 처리하도록 제안했지만 수수료 기반 트랜잭션은 'allowFees = false'를 사용하는 구성에 의해 비활성화됩니다.", "original-modified": "리소스 공급자가 반환한 원래 트랜잭션이 너무 많이 수정되었습니다. 리소스 공급자 없이 계속", "max-fee": "리소스 공급자가 요청한 수수료가 비정상적으로 높아 거부되었습니다." }; var ko = { timeout: timeout$2, "will-continue": "트랜잭션은 리소스 공급자 없이 계속됩니다.", fee: fee$2, rejected: rejected$2 }; var timeout$1 = "来自资源提供商的产品/服务已过期。"; var fee$1 = { title: "接受交易费用?", body: "您的账户需要其他资源 ({{resource}}) 才能执行此交易。是否要自动购买这些资源并继续?", cost: "{{resource}} 的成本" }; var rejected$1 = { "no-fees": "资源提供商提议以一定费用为条件提供此交易所需的资源,但配置项“allowFee = false”禁止了接受这种提议。", "original-modified": "资源提供商返回的原始交易修改过多。将在不使用资源提供商的情况下继续", "max-fee": "资源提供商提议的费用过高,已拒绝。" }; var zh_hans = { timeout: timeout$1, "will-continue": "交易将在不使用资源提供商的情况下继续。", fee: fee$1, rejected: rejected$1 }; var timeout = "來自資源提供商的產品/服務已過期。"; var fee = { title: "接受交易費用?", body: "您的賬戶需要其他資源 ({{resource}}) 才能執行此交易。是否要自動購買這些資源並繼續?", cost: "{{resource}} 的成本" }; var rejected = { "no-fees": "資源提供商提議以一定費用為條件提供此交易所需的資源,但配置項“allowFee = false”禁止了接受這種提議。", "original-modified": "資源提供商返回的原始交易修改過多。將在不使用資源提供商的情況下繼續", "max-fee": "資源提供商提議的費用過高,已拒絕。" }; var zh_hant = { timeout: timeout, "will-continue": "交易將在不使用資源提供商的情況下繼續。", fee: fee, rejected: rejected }; var defaultTranslations = { en, ko, 'zh-Hans': zh_hans, 'zh-Hant': zh_hant, }; function hasOriginalActions(original, modified) { return original.actions.every((originalAction) => { return modified.actions.some((modifiedAction) => { // Ensure the original contract account matches const matchesOriginalContractAccount = originalAction.account.equals(modifiedAction.account); // Ensure the original contract action matches const matchesOriginalContractAction = originalAction.name.equals(modifiedAction.name); // Ensure the original authorization is in tact const matchesOriginalAuthorization = originalAction.authorization.length === modifiedAction.authorization.length && originalAction.authorization[0].actor.equals(modifiedAction.authorization[0].actor); // Ensure the original action data matches const matchesOriginalActionData = originalAction.data.equals(modifiedAction.data); // Return any action that does not match the original return (matchesOriginalContractAccount && matchesOriginalContractAction && matchesOriginalAuthorization && matchesOriginalActionData); }); }); } function getNewActions(original, modified) { return modified.actions.filter((modifiedAction) => { return original.actions.some((originalAction) => { // Ensure the original contract account matches const matchesOriginalContractAccount = originalAction.account.equals(modifiedAction.account); // Ensure the original contract action matches const matchesOriginalContractAction = originalAction.name.equals(modifiedAction.name); // Ensure the original authorization is in tact const matchesOriginalAuthorization = originalAction.authorization.length === modifiedAction.authorization.length && originalAction.authorization[0].actor.equals(modifiedAction.authorization[0].actor); // Ensure the original action data matches const matchesOriginalActionData = originalAction.data.equals(modifiedAction.data); // Return any action that does not match the original return !(matchesOriginalContractAccount && matchesOriginalContractAction && matchesOriginalAuthorization && matchesOriginalActionData); }); }); } let Transfer = class Transfer extends Struct { }; __decorate([ Struct.field(Name) ], Transfer.prototype, "from", void 0); __decorate([ Struct.field(Name) ], Transfer.prototype, "to", void 0); __decorate([ Struct.field(Asset) ], Transfer.prototype, "quantity", void 0); __decorate([ Struct.field('string') ], Transfer.prototype, "memo", void 0); Transfer = __decorate([ Struct.type('transfer') ], Transfer); const defaultOptions = { endpoints: { aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906: 'https://eos.greymass.com', '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d': 'https://jungle4.greymass.com', '4667b205c6838ef70ff7988f6e8257e8be0e1284a2f59699054a018f743b1d11': 'https://telos.greymass.com', '1064487b3cd1a897ce03ae5b6a865651747e2e152090f99c1d19d44e01aea5a4': 'https://wax.greymass.com', }, }; class TransactPluginResourceProvider extends AbstractTransactPlugin { constructor(options) { super(); this.id = 'transact-plugin-resource-provider'; this.translations = defaultTranslations; this.allowFees = true; this.endpoints = defaultOptions.endpoints; if (options) { // Set the endpoints and chains available if (options.endpoints) { this.endpoints = options.endpoints; } if (typeof options.allowFees !== 'undefined') { this.allowFees = options.allowFees; } if (typeof options.maxFee !== 'undefined') { this.maxFee = Asset.from(options.maxFee); } // TODO: Allow contact/action combos to be passed in and checked against to ensure no rogue actions were appended. // if (typeof options.allowActions !== 'undefined') { // this.allowActions = options.allowActions.map((action) => Name.from(action)) // } } } register(context) { context.addHook(TransactHookTypes.beforeSign, async (request, context) => { return this.request(request, context); }); } getEndpoint(chain) { return this.endpoints[String(chain.id)]; } async request(request, context) { // Mock the translation function if no UI is available let t = (key, options) => options.default; if (context.ui) { // Use the translate function if available t = context.ui.getTranslate(this.id); } // Determine appropriate URL for this request const endpoint = this.getEndpoint(context.chain); // If no endpoint was found, gracefully fail and return the original request. if (!endpoint) { return { request, }; } // Resolve the request as a transaction for placeholders + tapos let modifiedRequest; const abis = await request.fetchAbis(context.abiCache); if (request.requiresTapos()) { const info = await context.client.v1.chain.get_info(); const header = info.getTransactionHeader(120); const modifiedArgs = { transaction: request.resolveTransaction(abis, context.permissionLevel, header), }; modifiedArgs.chainId = request.getChainId(); if (request.isMultiChain()) { const ids = request.getChainIds(); if (ids) { modifiedArgs.chainIds = ids; } } modifiedRequest = await SigningRequest.create(modifiedArgs, context.esrOptions); } else { modifiedRequest = await SigningRequest.create({ transaction: request.resolveTransaction(abis, context.permissionLevel), }, context.esrOptions); } // Validate that this request is valid for the resource provider this.validateRequest(modifiedRequest, context); // Assemble the request to the resource provider. const url = `${endpoint}/v1/resource_provider/request_transaction`; // Perform the request to the resource provider. const response = await context.fetch(url, { method: 'POST', body: JSON.stringify({ request: modifiedRequest, signer: context.permissionLevel, }), }); const json = await response.json(); // If the resource provider refused to process this request, return the original request without modification. if (response.status === 400) { return { request, }; } const requiresPayment = response.status === 402; if (requiresPayment) { // If the resource provider offered transaction with a fee, but plugin doesn't allow fees, return the original transaction. if (!this.allowFees) { // Notify that a fee was required but not allowed via allowFees: false. if (context.ui) { context.ui.status(`${t('rejected.no-fees', { default: 'A resource provider offered to cover this transaction for a fee, but fee-based transactions are disabled by the configuration using `allowFees = false`.', })} ${t('will-continue', { default: 'The transaction will continue without the resource provider.', })}`); } return { request, }; } } // Retrieve the transaction from the response const modifiedTransaction = this.getModifiedTransaction(json); // Ensure the new transaction has an unmodified version of the original action(s) const originalActionsIntact = hasOriginalActions(modifiedRequest.getRawTransaction(), modifiedTransaction); if (!originalActionsIntact) { // Notify that the original actions requested were modified somehow, and reject the modification. if (context.ui) { context.ui.status(`${t('rejected.original-modified', { default: 'The original transaction returned by the resource provider has been modified too much. Continuing without the resource provider', })} ${t('will-continue', { default: 'The transaction will continue without the resource provider.', })}`); } return { request, }; } // Retrieve all newly appended actions from the modified transaction const addedActions = getNewActions(modifiedRequest.getRawTransaction(), modifiedTransaction); // TODO: Check that all the addedActions are allowed via this.allowActions let token = '4,TOKEN'; // Find any transfer actions that were added to the transaction, which we assume are fees const addedFees = addedActions .filter((action) => (action.account.equals('eosio.token') && action.name.equals('transfer')) || (action.account.equals('core.vaulta') && action.name.equals('transfer'))) .map((action) => { const transfer = Serializer.decode({ data: action.data, type: Transfer, }); token = `${transfer.quantity.symbol.precision},${transfer.quantity.symbol.code}`; return transfer.quantity; }) .reduce((total, fee) => { total.units.add(fee.units); return total; }, Asset.fromUnits(0, token)); // If the resource provider offered transaction with a fee, but the fee was higher than allowed, return the original transaction. if (this.maxFee) { if (addedFees.units > this.maxFee.units) { // Notify that a fee was required but higher than allowed via maxFee. if (context.ui) { context.ui.status(`${t('rejected.max-fee', { default: 'The fee requested by the resource provider is unusually high and has been rejected.', })} ${t('will-continue', { default: 'The transaction will continue without the resource provider.', })}`); } return { request, }; } } // Validate that the response is valid for the session. await this.validateResponseData(json); // Create a new signing request based on the response to return to the session's transact flow. const modified = await this.createRequest(json, context); if (context.ui && addedFees.value > 0) { // Determine which resources are being covered by this fee const resourceTypes = []; if (json.data.costs) { const { cpu, net, ram } = json.data.costs; if (Asset.from(cpu).value > 0) resourceTypes.push('CPU'); if (Asset.from(net).value > 0) resourceTypes.push('NET'); if (Asset.from(ram).value > 0) resourceTypes.push('RAM'); } else { resourceTypes.push('Unknown'); } // Initiate a new cancelable prompt to inform the user of the fee required const prompt = context.ui.prompt({ title: t('fee.title', { default: 'Accept Transaction Fee?' }), body: t('fee.body', { default: 'Additional resources ({{resource}}) are required for your account to perform this transaction. Would you like to automatically purchase these resources and proceed?', resource: String(resourceTypes.join('/')), }), elements: [ { type: 'asset', data: { label: t('fee.cost', { default: 'Cost of {{resource}}', resource: String(resourceTypes.join('/')), }), value: addedFees, }, }, { type: 'accept', }, ], }); // TODO: Set the timer to match the expiration of the transaction const timer = setTimeout(() => { prompt.cancel(t('timeout', { default: 'The offer from the resource provider has expired.' })); }, 120000); // Return the promise from the prompt return prompt .then(async () => { // Return the modified transaction and additional signatures return new Promise((r) => r({ request: modified, signatures: json.data.signatures.map((sig) => Signature.from(sig)), })); }) .catch((e) => { // Throw if what we caught was a cancelation if (e instanceof Canceled) { throw e; } // Otherwise if it wasn't a cancel, it was a reject, and continue without modification return new Promise((r) => r({ request })); }) .finally(() => { clearTimeout(timer); // TODO: Remove this, it's just here for testing }); } // Return the modified transaction and additional signatures return new Promise((r) => r({ request: modified, signatures: json.data.signatures.map((sig) => Signature.from(sig)), })); } getModifiedTransaction(json) { switch (json.data.request[0]) { case 'action': throw new Error('A resource provider providing an "action" is not supported.'); case 'actions': throw new Error('A resource provider providing "actions" is not supported.'); case 'transaction': return Transaction.from(json.data.request[1]); } throw new Error('Invalid request type provided by resource provider.'); } async createRequest(response, context) { // Create a new signing request based on the response to return to the session's transact flow. const request = await context.createRequest(response.data.request[1]); // Set the required fee onto the request itself for wallets to process. if (response.code === 402 && response.data.fee) { request.setInfoKey('txfee', Asset.from(response.data.fee)); } // If the fee costs exist, set them on the request for the signature provider to consume if (response.data.costs) { request.setInfoKey('txfeecpu', response.data.costs.cpu); request.setInfoKey('txfeenet', response.data.costs.net); request.setInfoKey('txfeeram', response.data.costs.ram); } return request; } /** * Perform validation against the request to ensure it is valid for the resource provider. */ validateRequest(request, context) { // Retrieve first authorizer and ensure it matches session context. const firstAction = request.getRawActions()[0]; const firstAuthorizer = firstAction.authorization[0]; if (!firstAuthorizer.actor.equals(context.permissionLevel.actor)) { throw new Error('The first authorizer of the transaction does not match this session.'); } } /** * Perform validation against the response to ensure it is valid for the session. */ async validateResponseData(response) { // If the data wasn't provided in the response, throw an error. if (!response) { throw new Error('Resource provider did not respond to the request.'); } // If a malformed response with a fee was provided, throw an error. if (response.code === 402 && !response.data.fee) { throw new Error('Resource provider returned a response indicating required payment, but provided no fee amount.'); } // If no signatures were provided, throw an error. if (!response.data.signatures || !response.data.signatures[0]) { throw new Error('Resource provider did not return a signature.'); } } } export { TransactPluginResourceProvider, Transfer, defaultOptions }; //# sourceMappingURL=transact-plugin-resource-provider.m.js.map