diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index cd22ac2..8d460fb 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/airgap-it/tezos-metamask-snap.git" }, "source": { - "shasum": "ntvGpQQ6EK6wxpx6eXq4lEsYqrocwuPqR9sCK2RixY0=", + "shasum": "QSzx20eIc9oYUmnwcuhNl3z8pT9fTpCsfaCqsWaqit0=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/rpc-methods/send-operation.test.ts b/packages/snap/src/rpc-methods/send-operation.test.ts index 8336027..0b6fe25 100644 --- a/packages/snap/src/rpc-methods/send-operation.test.ts +++ b/packages/snap/src/rpc-methods/send-operation.test.ts @@ -5,7 +5,7 @@ import chaiBytes from 'chai-bytes'; import * as sinon from 'sinon'; import * as getWalletMethods from '../utils/get-wallet'; import * as getRpcMethods from '../utils/get-rpc'; -import * as prepareAndSignMethods from '../utils/prepare-and-sign'; +import * as prepareMethods from '../utils/prepare'; import { SnapMock } from '../../test/snap.mock.test'; import { bip32Entropy } from '../../test/constants.test'; import { tezosSendOperation } from './send-operation'; @@ -38,7 +38,7 @@ describe('Test function: sendOperation', function () { ); const prepareStub = sinon - .stub(prepareAndSignMethods, 'prepareAndSign') + .stub(prepareMethods, 'prepare') .returns(Promise.resolve('op...')); const walletMethodStub = sinon @@ -68,7 +68,7 @@ describe('Test function: sendOperation', function () { ); const prepareStub = sinon - .stub(prepareAndSignMethods, 'prepareAndSign') + .stub(prepareMethods, 'prepare') .returns(Promise.resolve('op...')); const walletMethodStub = sinon diff --git a/packages/snap/src/rpc-methods/send-operation.ts b/packages/snap/src/rpc-methods/send-operation.ts index 4122814..08a3d96 100644 --- a/packages/snap/src/rpc-methods/send-operation.ts +++ b/packages/snap/src/rpc-methods/send-operation.ts @@ -1,33 +1,90 @@ import { panel, heading, text, copyable, divider } from '@metamask/snaps-ui'; import BigNumber from 'bignumber.js'; import { getWallet } from '../utils/get-wallet'; -import { prepareAndSign } from '../utils/prepare-and-sign'; +import { prepare } from '../utils/prepare'; import { getRpc } from '../utils/get-rpc'; import { to } from '../utils/to'; -import { USER_REJECTED_ERROR } from '../utils/errors'; +import { NO_OPERATION_ERROR, USER_REJECTED_ERROR } from '../utils/errors'; import { TezosTransactionOperation } from '../tezos/types'; import { createOriginElement } from '../ui/origin-element'; +import { sign } from '../utils/sign'; +import { injectTransaction } from '../tezos/inject-transaction'; const mutezToTez = (mutez: string): string => { return BigNumber(mutez).shiftedBy(-6).toString(10); }; +const aggregate = (array: any[], field: string) => { + return array + .reduce((pv, cv): BigNumber => { + return pv.plus(cv[field]); + }, new BigNumber(0)) + .toString(); +}; + export const tezosSendOperation = async (origin: string, params: any) => { const { payload } = params; const wallet = await getWallet(); const rpc = await getRpc(); - const typedPayload: TezosTransactionOperation[] = payload; + const prepareResult = await prepare(payload, wallet, rpc.nodeUrl); + const typedPayload: TezosTransactionOperation[] = prepareResult.estimated + .contents as any; + + if (typedPayload.length === 0) { + throw NO_OPERATION_ERROR(); + } const humanReadable = []; - if (typedPayload[0] && typedPayload[0].kind === 'transaction') { + if (typedPayload.length > 1) { + if (typedPayload.every((el) => el.kind === 'transaction')) { + // If we have more than one operation and all of them are "transaction" operations, we aggregate + humanReadable.push( + text(`This operation group contains multiple the aggregated fees.`), + text( + `**Amount:** ${mutezToTez(aggregate(typedPayload, 'amount'))} tez`, + ), + text(`**Fee:** ${mutezToTez(aggregate(typedPayload, 'fee'))} tez`), + text( + `**Gas Limit:** ${mutezToTez( + aggregate(typedPayload, 'gas_limit'), + )} tez`, + ), + text( + `**Storage Limit:** ${mutezToTez( + aggregate(typedPayload, 'storage_limit'), + )} tez`, + ), + ); + } else if (!typedPayload.every((el) => el.kind === 'transaction')) { + // If we have more than one operation and some are not of kind "transaction", we show a note + humanReadable.push( + text(`**This operation group contains multiple operations.**`), + ); + } + } else if (typedPayload[0].kind === 'transaction') { + // If transaction operation, we show additional information humanReadable.push( text(`**To:** ${typedPayload[0].destination}`), text(`**Amount:** ${mutezToTez(typedPayload[0].amount)} tez`), text(`**Fee:** ${mutezToTez(typedPayload[0].fee)} tez`), - text(`**Gas Limit:** ${mutezToTez(typedPayload[0].fee)} tez`), - text(`**Storage Limit:** ${mutezToTez(typedPayload[0].fee)} tez`), + text(`**Gas Limit:** ${mutezToTez(typedPayload[0].gas_limit)} tez`), + text( + `**Storage Limit:** ${mutezToTez(typedPayload[0].storage_limit)} tez`, + ), + ); + } else { + // If one operation, we show kind + humanReadable.push(text(`**Kind:** ${typedPayload[0].kind}`)); + } + + // If "parameter" is present (contract call), we show a note + if (typedPayload.some((el) => el.kind === 'transaction' && el.parameters)) { + humanReadable.push( + text( + `**Note:** This is a contract call. Please only sign this operation if you trust the origin.`, + ), ); } @@ -59,5 +116,17 @@ export const tezosSendOperation = async (origin: string, params: any) => { throw USER_REJECTED_ERROR(); } - return { opHash: await prepareAndSign(payload, wallet, rpc.nodeUrl) }; + const operationWatermark = new Uint8Array([3]); + const signResult = await sign( + prepareResult.forged, + operationWatermark, + wallet, + ); + + const opHash = await injectTransaction( + signResult.signature.sbytes, + rpc.nodeUrl, + ); + + return { opHash }; }; diff --git a/packages/snap/src/rpc-methods/set-rpc.ts b/packages/snap/src/rpc-methods/set-rpc.ts index 2873095..8a719b1 100644 --- a/packages/snap/src/rpc-methods/set-rpc.ts +++ b/packages/snap/src/rpc-methods/set-rpc.ts @@ -5,12 +5,17 @@ import { RPC_INVALID_URL_ERROR, RPC_INVALID_RESPONSE_ERROR, USER_REJECTED_ERROR, + RPC_NO_URL_ERROR, } from '../utils/errors'; import { createOriginElement } from '../ui/origin-element'; export const tezosSetRpc = async (origin: string, params: any) => { const { network, nodeUrl }: { network: string; nodeUrl: string } = params; + if (!nodeUrl) { + throw RPC_NO_URL_ERROR(); + } + if (!nodeUrl.startsWith('https://')) { throw RPC_NO_HTTPS_ERROR(); } diff --git a/packages/snap/src/tezos/prepare-operations.ts b/packages/snap/src/tezos/prepare-operations.ts index 139c36c..9a9e8a9 100644 --- a/packages/snap/src/tezos/prepare-operations.ts +++ b/packages/snap/src/tezos/prepare-operations.ts @@ -14,6 +14,7 @@ import { TezosDelegationOperation, TezosOriginationOperation, TezosOperation, + TezosWrappedOperation, } from './types'; import { ALLOCATION_STORAGE_LIMIT, @@ -141,7 +142,7 @@ export const prepareOperations = async ( operationRequests: TezosOperation[], nodeUrl: string, overrideParameters = true, -): Promise => { +): Promise<{ estimated: TezosWrappedOperation; forged: string }> => { let counter: BigNumber = new BigNumber(1); const operations: TezosOperation[] = []; @@ -267,5 +268,5 @@ export const prepareOperations = async ( overrideParameters, ); - return await localForger.forge(estimated as any); + return { estimated, forged: await localForger.forge(estimated as any) }; }; diff --git a/packages/snap/src/utils/errors.ts b/packages/snap/src/utils/errors.ts index 533a4b4..55d98e2 100644 --- a/packages/snap/src/utils/errors.ts +++ b/packages/snap/src/utils/errors.ts @@ -1,5 +1,6 @@ export const USER_REJECTED_ERROR = () => new Error('User rejected'); -export const METHOD_NOT_FOUND_ERROR = () => new Error('Method not found.'); +export const METHOD_NOT_FOUND_ERROR = () => new Error('Method not found'); +export const RPC_NO_URL_ERROR = () => new Error('RPC URL not set'); export const RPC_NO_HTTPS_ERROR = () => new Error('RPC URL needs to start with https://'); export const RPC_INVALID_URL_ERROR = () => new Error('Invalid RPC URL'); @@ -32,3 +33,4 @@ export const HEX_LENGTH_INVALID_ERROR = () => new Error('Hex String has invalid length'); export const HEX_CHARACTER_INVALID_ERROR = () => new Error('Hex String has invalid character'); +export const NO_OPERATION_ERROR = () => new Error('Empty operations array'); diff --git a/packages/snap/src/utils/prepare-and-sign.ts b/packages/snap/src/utils/prepare-and-sign.ts deleted file mode 100644 index 10639df..0000000 --- a/packages/snap/src/utils/prepare-and-sign.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { injectTransaction } from '../tezos/inject-transaction'; -import { prepareOperations } from '../tezos/prepare-operations'; -import { getSigner } from './get-signer'; -import { sign } from './sign'; - -export const prepareAndSign = async ( - operations: any[], - node: { ed25519: any }, - nodeUrl: string, -) => { - const operationWatermark = new Uint8Array([3]); - - const signer = await getSigner(node); - - const forged = await prepareOperations( - await signer.publicKeyHash(), - await signer.publicKey(), - operations, - nodeUrl, - ); - - const signed = await sign(forged, operationWatermark, node); - - return injectTransaction(signed.signature.sbytes, nodeUrl); -}; diff --git a/packages/snap/src/utils/prepare-and-sign.test.ts b/packages/snap/src/utils/prepare.test.ts similarity index 92% rename from packages/snap/src/utils/prepare-and-sign.test.ts rename to packages/snap/src/utils/prepare.test.ts index 7e2e0c2..cd8a35d 100644 --- a/packages/snap/src/utils/prepare-and-sign.test.ts +++ b/packages/snap/src/utils/prepare.test.ts @@ -7,7 +7,7 @@ import { bip32Entropy } from '../../test/constants.test'; import { DEFAULT_NODE_URL } from '../constants'; import * as prepareOperationMethods from '../tezos/prepare-operations'; import * as injectTransactionMethods from '../tezos/inject-transaction'; -import { prepareAndSign } from './prepare-and-sign'; +import { prepare } from './prepare'; chai.use(chaiBytes); chai.use(sinonChai); @@ -26,11 +26,7 @@ describe('Test function: prepareAndSign', function () { .stub(injectTransactionMethods, 'injectTransaction') .returns(Promise.resolve('op...')); - const hash = await prepareAndSign( - [], - { ed25519: bip32Entropy }, - DEFAULT_NODE_URL, - ); + const hash = await prepare([], { ed25519: bip32Entropy }, DEFAULT_NODE_URL); expect(hash).to.equal('op...'); diff --git a/packages/snap/src/utils/prepare.ts b/packages/snap/src/utils/prepare.ts new file mode 100644 index 0000000..b1b8c95 --- /dev/null +++ b/packages/snap/src/utils/prepare.ts @@ -0,0 +1,17 @@ +import { prepareOperations } from '../tezos/prepare-operations'; +import { getSigner } from './get-signer'; + +export const prepare = async ( + operations: any[], + node: { ed25519: any }, + nodeUrl: string, +) => { + const signer = await getSigner(node); + + return prepareOperations( + await signer.publicKeyHash(), + await signer.publicKey(), + operations, + nodeUrl, + ); +};