@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
JavaScript
/**
* @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