diff --git a/packages/snap/package.json b/packages/snap/package.json index 6b0ef7e1..42fd5ede 100644 --- a/packages/snap/package.json +++ b/packages/snap/package.json @@ -1,6 +1,6 @@ { "name": "@gobob/bob-snap", - "version": "2.2.1", + "version": "2.2.2", "description": "BOB: Metamask snap to manage your BTC, ordinals, and more", "contributors": [ { diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index eefa4f0e..2858fe27 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -1,5 +1,5 @@ { - "version": "2.2.1", + "version": "2.2.2", "description": "BOB: Metamask snap to manage your Bitcoin", "proposedName": "BOB", "repository": { @@ -7,7 +7,7 @@ "url": "https://github.com/bob-collective/bob-snap" }, "source": { - "shasum": "HHxMGzE7qqYdjyYNB6L+2droaS0ElKFS1CIyDl4pIo0=", + "shasum": "tUeqZ/Wk3Ot1VfrXoGa8QVw1dtLO3Sm1kQ3/3+UVjIk=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snap/src/bitcoin/PsbtHelper.ts b/packages/snap/src/bitcoin/PsbtHelper.ts index 4072f659..dbb9b489 100644 --- a/packages/snap/src/bitcoin/PsbtHelper.ts +++ b/packages/snap/src/bitcoin/PsbtHelper.ts @@ -1,50 +1,61 @@ -import { address, Network, Psbt, Transaction } from 'bitcoinjs-lib'; +import { address, Network, opcodes, Psbt, script, Transaction } from 'bitcoinjs-lib'; import { getNetwork } from './getNetwork'; import { BitcoinNetwork } from '../interface'; export class PsbtHelper { - private tx: Psbt; + private psbt: Psbt; private network: Network; constructor(psbt: Psbt, network: BitcoinNetwork) { this.network = getNetwork(network); - this.tx = psbt; + this.psbt = psbt; } get inputAmount() { - return this.tx.data.inputs.reduce((total, input, index) => { - const vout = this.tx.txInputs[index].index; + return this.psbt.data.inputs.reduce((total, input, index) => { + const vout = this.psbt.txInputs[index].index; const prevTx = Transaction.fromHex(input.nonWitnessUtxo.toString('hex')); return total + prevTx.outs[vout].value; }, 0); } get sendAmount() { - return this.tx.txOutputs + return this.psbt.txOutputs .filter(output => !this.changeAddresses.includes(output.address)) .reduce((amount, output) => amount + output.value, 0); } get fee() { - const outputAmount = this.tx.txOutputs.reduce((amount, output) => amount + output.value, 0); + const outputAmount = this.psbt.txOutputs.reduce((amount, output) => amount + output.value, 0); return this.inputAmount - outputAmount; } get fromAddresses() { - return this.tx.data.inputs.map((input, index) => { + return this.psbt.data.inputs.map((input, index) => { const prevOuts = Transaction.fromHex(input.nonWitnessUtxo.toString('hex')).outs - const vout = this.tx.txInputs[index].index; + const vout = this.psbt.txInputs[index].index; return address.fromOutputScript(prevOuts[vout].script, this.network) }) } get toAddresses() { - return this.tx.txOutputs.map(output => output.address).filter(address => !this.changeAddresses.includes(address)); + return this.psbt.txOutputs.map(output => { + if (output.address == null) { + const scriptPubKey = script.decompile(output.script); + if (scriptPubKey.length == 2 && scriptPubKey[0] == opcodes.OP_RETURN && Buffer.isBuffer(scriptPubKey[1])) { + return `OP_RETURN 0x${scriptPubKey[1].toString("hex")}`; + } else { + return "Unknown"; + } + } else { + return output.address; + } + }).filter(address => !this.changeAddresses.includes(address)); } get changeAddresses() { - return this.tx.data.outputs - .map((output, index) => output.bip32Derivation ? this.tx.txOutputs[index].address : undefined) + return this.psbt.data.outputs + .map((output, index) => output.bip32Derivation ? this.psbt.txOutputs[index].address : undefined) .filter(address => !!address) } } diff --git a/packages/snap/src/bitcoin/PsbtValidator.ts b/packages/snap/src/bitcoin/PsbtValidator.ts index 3ec331e1..d86ad0f9 100644 --- a/packages/snap/src/bitcoin/PsbtValidator.ts +++ b/packages/snap/src/bitcoin/PsbtValidator.ts @@ -1,4 +1,4 @@ -import { Psbt } from 'bitcoinjs-lib'; +import { Psbt, opcodes, script } from 'bitcoinjs-lib'; import { AccountSigner } from './index'; import { BitcoinNetwork } from '../interface'; import { PsbtHelper } from '../bitcoin/PsbtHelper'; @@ -20,15 +20,15 @@ function checkForInput(inputs: PsbtInput[], inputIndex: number): Psbt export class PsbtValidator { static FEE_THRESHOLD = 10000000; - private readonly tx: Psbt; + private readonly psbt: Psbt; private readonly snapNetwork: BitcoinNetwork; private psbtHelper: PsbtHelper; private error: SnapError | null = null; constructor(psbt: Psbt, network: BitcoinNetwork) { - this.tx = psbt; + this.psbt = psbt; this.snapNetwork = network; - this.psbtHelper = new PsbtHelper(this.tx, network); + this.psbtHelper = new PsbtHelper(this.psbt, network); } get coinType() { @@ -36,7 +36,7 @@ export class PsbtValidator { } allInputsHaveRawTxHex() { - const result = this.tx.data.inputs.every((input, index) => !!input.nonWitnessUtxo); + const result = this.psbt.data.inputs.every((input, index) => !!input.nonWitnessUtxo); if (!result) { this.error = SnapError.of(PsbtValidateErrors.InputsDataInsufficient); } @@ -44,7 +44,7 @@ export class PsbtValidator { } everyInputMatchesNetwork() { - const result = this.tx.data.inputs.every(input => { + const result = this.psbt.data.inputs.every(input => { if (isTaprootInput(input)) { return input.tapBip32Derivation.every(derivation => { const { coinType } = fromHdPathToObj(derivation.path); @@ -65,7 +65,7 @@ export class PsbtValidator { everyOutputMatchesNetwork() { const addressPattern = this.snapNetwork === BitcoinNetwork.Main ? BITCOIN_MAIN_NET_ADDRESS_PATTERN : BITCOIN_TEST_NET_ADDRESS_PATTERN; - const result = this.tx.data.outputs.every((output, index) => { + const result = this.psbt.data.outputs.every((output, index) => { if (output.tapBip32Derivation) { return output.tapBip32Derivation.every(derivation => { const { coinType } = fromHdPathToObj(derivation.path) @@ -77,7 +77,16 @@ export class PsbtValidator { return Number(coinType) === this.coinType }) } else { - const address = this.tx.txOutputs[index].address; + const scriptPubKey = script.decompile(this.psbt.txOutputs[index].script); + if (scriptPubKey.length == 2 && scriptPubKey[0] == opcodes.OP_RETURN && Buffer.isBuffer(scriptPubKey[1])) { + if (scriptPubKey[1].byteLength > 80) { + // miners will reject anything over 80 bytes + this.error = SnapError.of(PsbtValidateErrors.InvalidOpReturn); + } + // as an exception we allow OP_RETURN outputs + return true; + } + const address = this.psbt.txOutputs[index].address; return addressPattern.test(address); } }) @@ -89,12 +98,12 @@ export class PsbtValidator { } allInputsBelongToCurrentAccount(accountSigner: AccountSigner) { - const result = this.tx.txInputs.every((_, index) => { - const input = checkForInput(this.tx.data.inputs, index); + const result = this.psbt.txInputs.every((_, index) => { + const input = checkForInput(this.psbt.data.inputs, index); if (isTaprootInput(input)) { return tapInputHasHDKey(input, accountSigner); } else { - return this.tx.inputHasHDKey(index, accountSigner); + return this.psbt.inputHasHDKey(index, accountSigner); } }); if (!result) { @@ -104,11 +113,11 @@ export class PsbtValidator { } changeAddressBelongsToCurrentAccount(accountSigner: AccountSigner) { - const result = this.tx.data.outputs.every((output, index) => { + const result = this.psbt.data.outputs.every((output, index) => { if (output.tapBip32Derivation) { return tapOutputHasHDKey(output, accountSigner); } else if (output.bip32Derivation) { - return this.tx.outputHasHDKey(index, accountSigner); + return this.psbt.outputHasHDKey(index, accountSigner); } return true; }); @@ -127,12 +136,12 @@ export class PsbtValidator { } witnessUtxoValueMatchesNoneWitnessOnes() { - const hasWitnessUtxo = this.tx.data.inputs.some((_, index) => this.tx.getInputType(index) === "witnesspubkeyhash"); + const hasWitnessUtxo = this.psbt.data.inputs.some((_, index) => this.psbt.getInputType(index) === "witnesspubkeyhash"); if (!hasWitnessUtxo) { return true; } - const witnessAmount = this.tx.data.inputs.reduce((total, input, index) => { + const witnessAmount = this.psbt.data.inputs.reduce((total, input, index) => { return total + input.witnessUtxo.value; }, 0); const result = this.psbtHelper.inputAmount === witnessAmount; diff --git a/packages/snap/src/bitcoin/__tests__/PsbtValidator.test.ts b/packages/snap/src/bitcoin/__tests__/PsbtValidator.test.ts index a1940501..c1c78e9f 100644 --- a/packages/snap/src/bitcoin/__tests__/PsbtValidator.test.ts +++ b/packages/snap/src/bitcoin/__tests__/PsbtValidator.test.ts @@ -1,10 +1,11 @@ import BIP32Factory from 'bip32'; -import { networks, Psbt } from 'bitcoinjs-lib'; +import { networks, opcodes, Psbt, script } from 'bitcoinjs-lib'; import { PsbtValidator } from '../PsbtValidator'; import { AccountSigner } from '../index'; import { BitcoinNetwork } from '../../interface'; import { psbtFixture } from './fixtures/psbt'; import * as ecc from "@bitcoin-js/tiny-secp256k1-asmjs"; +import { PsbtHelper } from '../PsbtHelper'; const getAccountSigner = () => { const testPrivateAccountKey = "tprv8gwYx7tEWpLxdJhEa7R8ofchqzRgme6iiuyJpegZ71XNhnAqeMjT6GV4wm3jqsUjXgXj99GB4kDminso5kxnLa6VXt3WVRzfmhbDSrfbCDv"; @@ -184,4 +185,32 @@ describe('psbtValidator', () => { expect(psbtValidator.validate(signer)).toBe(true); }); + + it('should return true given a valid psbt with OP_RETURN', function () { + const psbt = Psbt.fromBase64(psbtFixture.base64, { network: networks.testnet }) + psbt.addOutput({ + script: script.compile([opcodes.OP_RETURN, Buffer.alloc(20, 0)]), + value: 0, + }) + + const psbtHelper = new PsbtHelper(psbt, BitcoinNetwork.Test); + expect(psbtHelper.toAddresses).toEqual([ + 'tb1qqkelutyrqmxgzd9nnfws2yk3dl600yvxagfqu7', + 'OP_RETURN 0x0000000000000000000000000000000000000000' + ]); + + const psbtValidator = new PsbtValidator(psbt, BitcoinNetwork.Test); + expect(psbtValidator.validate(signer)).toBe(true); + }); + + it('should throw error when OP_RETURN is too big', function () { + const psbt = Psbt.fromBase64(psbtFixture.base64, { network: networks.testnet }) + psbt.addOutput({ + script: script.compile([opcodes.OP_RETURN, Buffer.alloc(81, 0)]), + value: 0, + }) + + const psbtValidator = new PsbtValidator(psbt, BitcoinNetwork.Test); + expect(() => { psbtValidator.validate(signer) }).toThrowError(`Transaction has an invalid OP_RETURN`); + }); }); diff --git a/packages/snap/src/bitcoin/__tests__/fixtures/psbt.ts b/packages/snap/src/bitcoin/__tests__/fixtures/psbt.ts index d83f88be..196792e4 100644 --- a/packages/snap/src/bitcoin/__tests__/fixtures/psbt.ts +++ b/packages/snap/src/bitcoin/__tests__/fixtures/psbt.ts @@ -56,5 +56,4 @@ export const psbtFixture = { ], }, base64: 'cHNidP8BAHECAAAAASpPo+Jtjb8ce88iDgUe9MdBl/N0RXzOyTt6bYRF4KxgAAAAAAD/////AkANAwAAAAAAFgAUBbP+LIMGzIE0s5pdBRLRb/T3kYaazQsAAAAAABYAFDUcL8Uq83TUCsXnr18EDVw08zQ9AAAAAAABAP1UAQIAAAAAAQIbzNzgXMu2XcHbu/VK6Tv2aYkp3WBu0PijNRnjyvh1eQEAAAAA/////wL6QmQPXjZt03YXBX2QbexW+etvDRpeUJE+cz7D5ardAAAAAAD/////Afz3DgAAAAAAFgAUvApnUSw4MVXYWN2ZqWf3hCAWGsoCSDBFAiEA+axvhH4bFn2mSxo6xzybYtrAjdpG0YzlqBam0UNaE3kCIA0lg97qGHi0rKC7hWQXnMSnWbaCII6nGpHErzpoSHNMASEDSB6PkHcBABG+ayUezMfaQN0i6+DO4DwxtF+nbuWWp+ICRzBEAiAYgnrWAOCiD55kZsBSiX/UNMnDsmhcFzKno/6T1nP92gIgPtzzZuB0FjdMrkpzWkDZurYJR7MBcRnszVgqyjX5xEsBIQMR9PpNCfA5TzCasyKhJsp1vevr907O2Kru24aJS/h08QAAAAABAR/89w4AAAAAABYAFLwKZ1EsODFV2Fjdmaln94QgFhrKIgYDSB6PkHcBABG+ayUezMfaQN0i6+DO4DwxtF+nbuWWp+IY+BLROVQAAIABAACAAAAAgAEAAAAKAAAAAAAiAgJgiLaAsqyAi3BUwv3EgxEuXiYfGOFeDgCax2fzYa/sZBj4EtE5VAAAgAEAAIAAAACAAQAAAAsAAAAA', - }; diff --git a/packages/snap/src/errors/constant/PsbtValidaeErrors.ts b/packages/snap/src/errors/constant/PsbtValidateErrors.ts similarity index 88% rename from packages/snap/src/errors/constant/PsbtValidaeErrors.ts rename to packages/snap/src/errors/constant/PsbtValidateErrors.ts index f0f40c5b..f84adf3b 100644 --- a/packages/snap/src/errors/constant/PsbtValidaeErrors.ts +++ b/packages/snap/src/errors/constant/PsbtValidateErrors.ts @@ -26,5 +26,9 @@ export const PsbtValidateErrors = { AmountNotMatch: { code: 10007, message: 'Transaction input amount not match' + }, + InvalidOpReturn: { + code: 10008, + message: 'Transaction has an invalid OP_RETURN' } } diff --git a/packages/snap/src/errors/index.ts b/packages/snap/src/errors/index.ts index 9501ae2d..ad4de1cb 100644 --- a/packages/snap/src/errors/index.ts +++ b/packages/snap/src/errors/index.ts @@ -1,4 +1,4 @@ export { SnapError } from './SnapError'; -export { PsbtValidateErrors } from './constant/PsbtValidaeErrors'; +export { PsbtValidateErrors } from './constant/PsbtValidateErrors'; export { RequestErrors } from './constant/RequestErrors'; export { InvoiceErrors } from './constant/InvoiceErrors';