├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── integration └── btcpay-integration.spec.ts ├── jest-setup.js ├── jest.config.js ├── package.json ├── src ├── client.d.ts ├── client.js ├── index.d.ts ├── index.js ├── request.d.ts ├── request.js ├── utils.d.ts ├── utils.js ├── wallet.d.ts └── wallet.js ├── test ├── client.spec.ts ├── fixtures │ └── client.fixtures.ts └── request.spec.ts ├── ts_src ├── client.ts ├── index.ts ├── request.ts ├── utils.ts └── wallet.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | package-lock.json 4 | .idea 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kukks/payjoin-client-js/88651943b77fea2bee2886e7142867f0b2f7b4aa/.prettierignore -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | if: (type = push AND branch = master) OR type = pull_request 2 | sudo: false 3 | language: node_js 4 | services: 5 | - docker 6 | before_install: 7 | - if [ $TEST_SUITE = "integration" ] ; then 8 | docker pull junderw/btcpay-client-test-server && 9 | docker run -d -p 127.0.0.1:49392:49392 -p 127.0.0.1:8080:8080 -p 127.0.0.1:18271:18271 junderw/btcpay-client-test-server && 10 | docker ps -a && 11 | sleep 10; 12 | fi 13 | node_js: 14 | - "10" 15 | - "13" 16 | - "lts/*" 17 | matrix: 18 | include: 19 | - node_js: "lts/*" 20 | env: TEST_SUITE=gitdiff:ci 21 | - node_js: "lts/*" 22 | env: TEST_SUITE=integration 23 | - node_js: "lts/*" 24 | env: TEST_SUITE=coverage 25 | env: 26 | - TEST_SUITE=test 27 | script: npm run $TEST_SUITE 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Refer to Bitcoinjs-lib 2 | 3 | [Please refer to bitcoinjs-lib CONTRIBUTING for a guide on how to contribute by clicking here.](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/CONTRIBUTING.md) 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 bitcoinjs-lib contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Payjoin Client 2 | [![Build Status](https://travis-ci.org/bitcoinjs/payjoin-client.png?branch=master)](https://travis-ci.org/bitcoinjs/payjoin-client) 3 | 4 | [![NPM](https://img.shields.io/npm/v/payjoin-client.svg)](https://www.npmjs.org/package/payjoin-client) 5 | 6 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 7 | 8 | A [BTCPay Server Payjoin client](https://docs.btcpayserver.org/features/payjoin) written in TypeScript with transpiled JavaScript committed to git. 9 | 10 | 11 | ## Example 12 | 13 | TypeScript 14 | 15 | ``` typescript 16 | 17 | // ... 18 | ``` 19 | 20 | NodeJS 21 | 22 | ``` javascript 23 | 24 | // ... 25 | ``` 26 | 27 | ## LICENSE [MIT](LICENSE) 28 | -------------------------------------------------------------------------------- /integration/btcpay-integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { IPayjoinClientWallet, PayjoinClient } from '..'; 2 | import { RegtestUtils } from 'regtest-client'; 3 | import { BTCPayClient, crypto as btcPayCrypto } from 'btcpay'; 4 | import * as fetch from 'isomorphic-fetch'; 5 | import * as bitcoin from 'bitcoinjs-lib'; 6 | import * as qs from 'querystring'; 7 | 8 | // pass the regtest network to everything 9 | const network = bitcoin.networks.regtest; 10 | 11 | const TOKENURL = 'http://127.0.0.1:18271/tokens'; 12 | 13 | const APIURL = process.env['APIURL'] || 'http://127.0.0.1:8080/1'; 14 | const APIPASS = process.env['APIPASS'] || 'satoshi'; 15 | let regtestUtils: RegtestUtils; 16 | 17 | const HOST = process.env['BTCPAY_HOST'] || 'http://127.0.0.1:49392'; 18 | let KP1: any; 19 | let KP2: any; 20 | let btcPayClientSegwit: BTCPayClient; 21 | let btcPayClientSegwitP2SH: BTCPayClient; 22 | 23 | // # run the following docker command and wait 10 seconds before running tests 24 | // docker run -p 49392:49392 -p 8080:8080 -p 18271:18271 junderw/btcpay-client-test-server 25 | 26 | describe('requestPayjoin', () => { 27 | beforeAll(async () => { 28 | jest.setTimeout(20000); 29 | regtestUtils = new RegtestUtils({ APIURL, APIPASS }); 30 | 31 | const tokens = await getTokens(); 32 | 33 | KP1 = btcPayCrypto.load_keypair( 34 | Buffer.from(tokens.privateKeys.p2wpkh, 'hex'), 35 | ); 36 | KP2 = btcPayCrypto.load_keypair( 37 | Buffer.from(tokens.privateKeys.p2shp2wpkh, 'hex'), 38 | ); 39 | 40 | btcPayClientSegwit = new BTCPayClient(HOST, KP1, tokens.p2wpkh); 41 | btcPayClientSegwitP2SH = new BTCPayClient(HOST, KP2, tokens.p2shp2wpkh); 42 | }); 43 | it('should exist', () => { 44 | expect(PayjoinClient).toBeDefined(); 45 | expect(typeof PayjoinClient).toBe('function'); // JS classes are functions 46 | }); 47 | it('should request p2sh-p2wpkh payjoin', async () => { 48 | await testPayjoin(btcPayClientSegwitP2SH, 'p2sh-p2wpkh'); 49 | }); 50 | it('should request p2wpkh payjoin', async () => { 51 | await testPayjoin(btcPayClientSegwit, 'p2wpkh'); 52 | }); 53 | }); 54 | 55 | async function testPayjoin( 56 | btcPayClient: BTCPayClient, 57 | scriptType: ScriptType, 58 | ): Promise { 59 | const invoice = await btcPayClient.create_invoice({ 60 | currency: 'USD', 61 | price: 1.12, 62 | }); 63 | const pjEndpoint = qs.decode(invoice.paymentUrls.BIP21 as string) 64 | .pj as string; 65 | 66 | const wallet = new TestWallet( 67 | invoice.bitcoinAddress, 68 | Math.round(parseFloat(invoice.btcPrice) * 1e8), 69 | bitcoin.bip32.fromSeed(bitcoin.ECPair.makeRandom().privateKey!, network), 70 | scriptType, 71 | ); 72 | const client = new PayjoinClient({ 73 | wallet, 74 | payjoinUrl: pjEndpoint, 75 | }); 76 | 77 | await client.run(); 78 | 79 | expect(wallet.tx).toBeDefined(); 80 | await regtestUtils.verify({ 81 | txId: wallet.tx!.getId(), 82 | address: bitcoin.address.fromOutputScript( 83 | wallet.tx!.outs[1].script, 84 | network, 85 | ), 86 | vout: 1, 87 | value: wallet.tx!.outs[1].value, 88 | }); 89 | } 90 | 91 | async function getTokens(): Promise<{ 92 | p2wpkh: { 93 | merchant: string; 94 | }; 95 | p2shp2wpkh: { 96 | merchant: string; 97 | }; 98 | privateKeys: { 99 | p2wpkh: string; 100 | p2shp2wpkh: string; 101 | }; 102 | }> { 103 | return fetch(TOKENURL).then((v) => v.json()); 104 | } 105 | 106 | // Use this for testing 107 | type ScriptType = 'p2wpkh' | 'p2sh-p2wpkh'; 108 | class TestWallet implements IPayjoinClientWallet { 109 | tx: bitcoin.Transaction | undefined; 110 | timeout: NodeJS.Timeout | undefined; 111 | 112 | constructor( 113 | private sendToAddress: string, 114 | private sendToAmount: number, 115 | private rootNode: bitcoin.BIP32Interface, 116 | private scriptType: ScriptType, 117 | ) {} 118 | 119 | async getPsbt() { 120 | // See BIP84 and BIP49 for the derivation logic 121 | const path = this.scriptType === 'p2wpkh' ? "m/84'/1'/0'" : "m/49'/1'/0'"; 122 | const accountNode = this.rootNode.derivePath(path); 123 | const firstKeyNode = accountNode.derivePath('0/0'); 124 | const firstKeypayment = this.getPayment( 125 | firstKeyNode.publicKey, 126 | this.scriptType, 127 | ); 128 | const firstChangeNode = accountNode.derivePath('1/0'); 129 | const firstChangepayment = this.getPayment( 130 | firstChangeNode.publicKey, 131 | this.scriptType, 132 | ); 133 | const unspent = await regtestUtils.faucet(firstKeypayment.address!, 2e7); 134 | const sendAmount = this.sendToAmount; 135 | return new bitcoin.Psbt({ network }) 136 | .addInput({ 137 | hash: unspent.txId, 138 | index: unspent.vout, 139 | witnessUtxo: { 140 | script: firstKeypayment.output!, 141 | value: unspent.value, 142 | }, 143 | bip32Derivation: [ 144 | { 145 | pubkey: firstKeyNode.publicKey, 146 | masterFingerprint: this.rootNode.fingerprint, 147 | path: path + '/0/0', 148 | }, 149 | ], 150 | ...(firstKeypayment.redeem 151 | ? { redeemScript: firstKeypayment.redeem.output! } 152 | : {}), 153 | }) 154 | .addOutput({ 155 | address: this.sendToAddress, 156 | value: sendAmount, 157 | }) 158 | .addOutput({ 159 | address: firstChangepayment.address!, 160 | value: unspent.value - sendAmount - 10000, 161 | bip32Derivation: [ 162 | { 163 | pubkey: firstChangeNode.publicKey, 164 | masterFingerprint: this.rootNode.fingerprint, 165 | path: path + '/1/0', 166 | }, 167 | ], 168 | }) 169 | .signInputHD(0, this.rootNode); 170 | } 171 | 172 | async signPsbt(psbt: bitcoin.Psbt): Promise { 173 | psbt.data.inputs.forEach((psbtInput, i) => { 174 | if ( 175 | psbtInput.finalScriptSig === undefined && 176 | psbtInput.finalScriptWitness === undefined 177 | ) { 178 | psbt.signInputHD(i, this.rootNode).finalizeInput(i); 179 | } 180 | }); 181 | return psbt; 182 | } 183 | 184 | async broadcastTx(txHex: string): Promise { 185 | try { 186 | await regtestUtils.broadcast(txHex); 187 | this.tx = bitcoin.Transaction.fromHex(txHex); 188 | } catch (e) { 189 | return e.message; 190 | } 191 | return ''; 192 | } 193 | 194 | async scheduleBroadcastTx(txHex: string, ms: number): Promise { 195 | this.timeout = setTimeout( 196 | ((txHexInner) => async () => { 197 | try { 198 | await regtestUtils.broadcast(txHexInner); 199 | } catch (err) { 200 | // failure is good 201 | return; 202 | } 203 | // Do something here to log the fact that it broadcasted successfully 204 | // broadcasting successfully is a bad thing. It means the payjoin 205 | // transaction didn't propagate OR the merchant double spent their input 206 | // to trick you into paying twice. 207 | // This is for tests so we won't do it. 208 | })(txHex), 209 | ms, 210 | ); 211 | // returns immediately after setting the timeout. 212 | 213 | // But since this is a test, and we don't want the test to wait 2 minutes 214 | // we will cancel it immediately after 215 | clearTimeout(this.timeout); 216 | } 217 | 218 | async isOwnOutputScript( 219 | script: Buffer, 220 | pathFromRoot?: string, 221 | ): Promise { 222 | if (!pathFromRoot) return false; 223 | const { publicKey } = this.rootNode.derivePath(pathFromRoot); 224 | const ourScript = this.getPayment(publicKey, this.scriptType).output!; 225 | return script.equals(ourScript); 226 | } 227 | 228 | private getPayment(pubkey: Buffer, scriptType: ScriptType): bitcoin.Payment { 229 | if (scriptType === 'p2wpkh') { 230 | return bitcoin.payments.p2wpkh({ 231 | pubkey, 232 | network, 233 | }); 234 | } else { 235 | return bitcoin.payments.p2sh({ 236 | redeem: bitcoin.payments.p2wpkh({ 237 | pubkey, 238 | network, 239 | }), 240 | network, 241 | }); 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // adds the 'fetchMock' global variable and rewires 'fetch' global to call 'fetchMock' instead of the real implementation 2 | require('jest-fetch-mock').enableMocks() 3 | // changes default behavior of fetchMock to use the real 'fetch' implementation and not mock responses 4 | fetchMock.dontMock() -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | let testRegex; 2 | switch (process.env['JEST_TYPE']) { 3 | case 'integration': 4 | testRegex = '/integration/.*\\.spec\\.ts$'; 5 | break; 6 | case 'unit': 7 | default: 8 | testRegex = '/test/.*\\.spec\\.ts$'; 9 | break; 10 | } 11 | 12 | module.exports = { 13 | moduleFileExtensions: ['ts', 'js', 'json'], 14 | transform: { 15 | '^.+\\.ts$': 'ts-jest', 16 | }, 17 | testRegex, 18 | testURL: 'http://localhost/', 19 | testEnvironment: 'node', 20 | coverageThreshold: { 21 | global: { 22 | statements: 80, 23 | branches: 65, 24 | functions: 90, 25 | lines: 80, 26 | }, 27 | }, 28 | setupFiles: [ 29 | './jest-setup.js' 30 | ], 31 | collectCoverageFrom: ['ts_src/**/*.ts', '!**/node_modules/**'], 32 | coverageReporters: ['lcov', 'text'], 33 | verbose: true, 34 | }; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payjoin-client", 3 | "version": "0.0.1", 4 | "description": "A BTCPay Payjoin compatible client", 5 | "keywords": [ 6 | "bitcoinjs", 7 | "bitcoin", 8 | "bip79", 9 | "payjoin", 10 | "btcpayserver" 11 | ], 12 | "main": "./src/index.js", 13 | "types": "./src/index.d.ts", 14 | "scripts": { 15 | "build": "npm run clean && tsc -p tsconfig.json && npm run formatjs", 16 | "clean": "rm -rf src", 17 | "coverage": "npm run unit -- --coverage", 18 | "format": "npm run prettier -- --write", 19 | "formatjs": "npm run prettierjs -- --write > /dev/null 2>&1", 20 | "format:ci": "npm run prettier -- --check", 21 | "gitdiff": "git diff --exit-code", 22 | "gitdiff:ci": "npm run build && npm run gitdiff", 23 | "integration": "JEST_TYPE=integration npm run unit", 24 | "lint": "tslint -p tsconfig.json -c tslint.json", 25 | "prepublishOnly": "npm run test && npm run gitdiff", 26 | "prettier": "prettier 'ts_src/**/*.ts' 'test/**/*.ts' 'integration/**/*.ts' --ignore-path ./.prettierignore", 27 | "prettierjs": "prettier 'src/**/*.js' --ignore-path ./.prettierignore", 28 | "test": "npm run build && npm run format:ci && npm run lint && npm run unit", 29 | "unit": "jest --config=jest.config.js --runInBand" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/Kukks/payjoin-client-js.git" 34 | }, 35 | "files": [ 36 | "src" 37 | ], 38 | "dependencies": { 39 | "bitcoinjs-lib": "git://github.com/bitcoinjs/bitcoinjs-lib.git#f87a20caa7828f6f8c29049c73efd369ff81c57b" 40 | }, 41 | "devDependencies": { 42 | "@types/isomorphic-fetch": "0.0.35", 43 | "@types/jest": "^25.2.1", 44 | "@types/node": "^13.13.0", 45 | "btcpay": "^0.2.4", 46 | "isomorphic-fetch": "^2.2.1", 47 | "jest": "^25.3.0", 48 | "jest-fetch-mock": "^3.0.3", 49 | "prettier": "^2.0.4", 50 | "regtest-client": "^0.2.0", 51 | "ts-jest": "^25.3.1", 52 | "tslint": "^6.1.1", 53 | "typescript": "^3.8.3" 54 | }, 55 | "contributors": [ 56 | "Andrew Camilleri (Kukks)", 57 | "Luke Childs ", 58 | "Jonathan Underwood " 59 | ], 60 | "license": "MIT", 61 | "bugs": { 62 | "url": "https://github.com/Kukks/payjoin-client-js/issues" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/client.d.ts: -------------------------------------------------------------------------------- 1 | import { IPayjoinRequester } from './request'; 2 | import { IPayjoinClientWallet } from './wallet'; 3 | export declare class PayjoinClient { 4 | private wallet; 5 | private payjoinRequester; 6 | constructor(opts: PayjoinClientOpts); 7 | private getSumPaidToUs; 8 | run(): Promise; 9 | } 10 | declare type PayjoinClientOpts = PayjoinClientOptsUrl | PayjoinClientOptsRequester; 11 | interface PayjoinClientOptsUrl { 12 | wallet: IPayjoinClientWallet; 13 | payjoinUrl: string; 14 | } 15 | interface PayjoinClientOptsRequester { 16 | wallet: IPayjoinClientWallet; 17 | payjoinRequester: IPayjoinRequester; 18 | } 19 | export {}; 20 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | Object.defineProperty(exports, '__esModule', { value: true }); 3 | const request_1 = require('./request'); 4 | const utils_1 = require('./utils'); 5 | const BROADCAST_ATTEMPT_TIME = 1 * 60 * 1000; // 1 minute 6 | class PayjoinClient { 7 | constructor(opts) { 8 | this.wallet = opts.wallet; 9 | if (isRequesterOpts(opts)) { 10 | this.payjoinRequester = opts.payjoinRequester; 11 | } else { 12 | this.payjoinRequester = new request_1.PayjoinRequester(opts.payjoinUrl); 13 | } 14 | } 15 | async getSumPaidToUs(psbt) { 16 | let sumPaidToUs = 0; 17 | for (const input of psbt.data.inputs) { 18 | const { bip32Derivation } = input; 19 | const pathFromRoot = bip32Derivation && bip32Derivation[0].path; 20 | if ( 21 | await this.wallet.isOwnOutputScript( 22 | input.witnessUtxo.script, 23 | pathFromRoot, 24 | ) 25 | ) { 26 | sumPaidToUs -= input.witnessUtxo.value; 27 | } 28 | } 29 | for (const [index, output] of Object.entries(psbt.txOutputs)) { 30 | const { bip32Derivation } = psbt.data.outputs[parseInt(index, 10)]; 31 | const pathFromRoot = bip32Derivation && bip32Derivation[0].path; 32 | if (await this.wallet.isOwnOutputScript(output.script, pathFromRoot)) { 33 | sumPaidToUs += output.value; 34 | } 35 | } 36 | return sumPaidToUs; 37 | } 38 | async run() { 39 | const psbt = await this.wallet.getPsbt(); 40 | const clonedPsbt = psbt.clone(); 41 | const originalType = utils_1.getInputsScriptPubKeyType(clonedPsbt); 42 | clonedPsbt.finalizeAllInputs(); 43 | const originalTxHex = clonedPsbt.extractTransaction().toHex(); 44 | const broadcastOriginalNow = () => this.wallet.broadcastTx(originalTxHex); 45 | try { 46 | if (utils_1.SUPPORTED_WALLET_FORMATS.indexOf(originalType) === -1) { 47 | throw new Error( 48 | 'Inputs used do not support payjoin, they must be segwit (p2wpkh or p2sh-p2wpkh)', 49 | ); 50 | } 51 | // We make sure we don't send unnecessary information to the receiver 52 | for (let index = 0; index < clonedPsbt.inputCount; index++) { 53 | clonedPsbt.clearFinalizedInput(index); 54 | } 55 | clonedPsbt.data.outputs.forEach((output) => { 56 | delete output.bip32Derivation; 57 | }); 58 | delete clonedPsbt.data.globalMap.globalXpub; 59 | const payjoinPsbt = await this.payjoinRequester.requestPayjoin( 60 | clonedPsbt, 61 | ); 62 | if (!payjoinPsbt) throw new Error("We did not get the receiver's PSBT"); 63 | if ( 64 | payjoinPsbt.data.globalMap.globalXpub && 65 | payjoinPsbt.data.globalMap.globalXpub.length > 0 66 | ) { 67 | throw new Error( 68 | "GlobalXPubs should not be included in the receiver's PSBT", 69 | ); 70 | } 71 | if ( 72 | utils_1.hasKeypathInformationSet(payjoinPsbt.data.outputs) || 73 | utils_1.hasKeypathInformationSet(payjoinPsbt.data.inputs) 74 | ) { 75 | throw new Error( 76 | "Keypath information should not be included in the receiver's PSBT", 77 | ); 78 | } 79 | const ourInputIndexes = []; 80 | // Add back input data from the original psbt (such as witnessUtxo) 81 | psbt.txInputs.forEach((originalInput, index) => { 82 | const payjoinIndex = utils_1.getInputIndex( 83 | payjoinPsbt, 84 | originalInput.hash, 85 | originalInput.index, 86 | ); 87 | if (payjoinIndex === -1) { 88 | throw new Error( 89 | `Receiver's PSBT is missing input #${index} from the sent PSBT`, 90 | ); 91 | } 92 | if ( 93 | originalInput.sequence !== payjoinPsbt.txInputs[payjoinIndex].sequence 94 | ) { 95 | throw new Error( 96 | `Input #${index} from original PSBT have a different sequence`, 97 | ); 98 | } 99 | payjoinPsbt.updateInput(payjoinIndex, psbt.data.inputs[index]); 100 | const payjoinPsbtInput = payjoinPsbt.data.inputs[payjoinIndex]; 101 | // In theory these shouldn't be here, but just in case, we need to 102 | // re-sign so this is throwing away the invalidated data. 103 | delete payjoinPsbtInput.partialSig; 104 | delete payjoinPsbtInput.finalScriptSig; 105 | delete payjoinPsbtInput.finalScriptWitness; 106 | ourInputIndexes.push(payjoinIndex); 107 | }); 108 | const sanityResult = utils_1.checkSanity(payjoinPsbt); 109 | if (!sanityResult.every((inputErrors) => inputErrors.length === 0)) { 110 | throw new Error( 111 | `Receiver's PSBT is insane:\n${JSON.stringify( 112 | sanityResult, 113 | null, 114 | 2, 115 | )}`, 116 | ); 117 | } 118 | // We make sure we don't sign what should not be signed 119 | for (let index = 0; index < payjoinPsbt.inputCount; index++) { 120 | // check if input is Finalized 121 | const ourInput = ourInputIndexes.indexOf(index) !== -1; 122 | if (utils_1.isFinalized(payjoinPsbt.data.inputs[index])) { 123 | if (ourInput) { 124 | throw new Error( 125 | `Receiver's PSBT included a finalized input from original PSBT `, 126 | ); 127 | } else { 128 | payjoinPsbt.clearFinalizedInput(index); 129 | } 130 | } else if (!ourInput) { 131 | throw new Error(`Receiver's PSBT included a non-finalized new input`); 132 | } 133 | } 134 | for (let index = 0; index < payjoinPsbt.data.outputs.length; index++) { 135 | const output = payjoinPsbt.data.outputs[index]; 136 | const outputLegacy = payjoinPsbt.txOutputs[index]; 137 | // Make sure only our output has any information 138 | delete output.bip32Derivation; 139 | psbt.data.outputs.forEach((originalOutput, i) => { 140 | // update the payjoin outputs 141 | const originalOutputLegacy = psbt.txOutputs[i]; 142 | if (outputLegacy.script.equals(originalOutputLegacy.script)) 143 | payjoinPsbt.updateOutput(index, originalOutput); 144 | }); 145 | } 146 | if (payjoinPsbt.version !== psbt.version) { 147 | throw new Error( 148 | 'The version field of the transaction has been modified', 149 | ); 150 | } 151 | if (payjoinPsbt.locktime !== psbt.locktime) { 152 | throw new Error( 153 | 'The LockTime field of the transaction has been modified', 154 | ); 155 | } 156 | if (payjoinPsbt.data.inputs.length <= psbt.data.inputs.length) { 157 | throw new Error( 158 | `Receiver's PSBT should have more inputs than the sent PSBT`, 159 | ); 160 | } 161 | if (utils_1.getInputsScriptPubKeyType(payjoinPsbt) !== originalType) { 162 | throw new Error( 163 | `Receiver's PSBT included inputs which were of a different format than the sent PSBT`, 164 | ); 165 | } 166 | const paidBack = await this.getSumPaidToUs(psbt); 167 | const payjoinPaidBack = await this.getSumPaidToUs(payjoinPsbt); 168 | const signedPsbt = await this.wallet.signPsbt(payjoinPsbt); 169 | const tx = signedPsbt.extractTransaction(); 170 | psbt.finalizeAllInputs(); 171 | // TODO: make sure this logic is correct 172 | if (payjoinPaidBack < paidBack) { 173 | const overPaying = paidBack - payjoinPaidBack; 174 | const originalFee = psbt.getFee(); 175 | const additionalFee = signedPsbt.getFee() - originalFee; 176 | if (overPaying > additionalFee) 177 | throw new Error( 178 | 'The payjoin receiver is sending more money to himself', 179 | ); 180 | if (overPaying > originalFee) 181 | throw new Error( 182 | 'The payjoin receiver is making us pay more than twice the original fee', 183 | ); 184 | const newVirtualSize = tx.virtualSize(); 185 | // Let's check the difference is only for the fee and that feerate 186 | // did not changed that much 187 | const originalFeeRate = psbt.getFeeRate(); 188 | let expectedFee = utils_1.getFee(originalFeeRate, newVirtualSize); 189 | // Signing precisely is hard science, give some breathing room for error. 190 | expectedFee += utils_1.getFee( 191 | originalFeeRate, 192 | payjoinPsbt.inputCount * 2, 193 | ); 194 | if (overPaying > expectedFee - originalFee) 195 | throw new Error( 196 | 'The payjoin receiver increased the fee rate we are paying too much', 197 | ); 198 | } 199 | // Now broadcast. If this fails, there's a possibility the server is 200 | // trying to leak information by double spending an input, this is why 201 | // we schedule broadcast of original BEFORE we broadcast the payjoin. 202 | // And it is why schedule broadcast is expected to fail. (why you must 203 | // not throw an error.) 204 | const response = await this.wallet.broadcastTx(tx.toHex()); 205 | if (response !== '') { 206 | throw new Error( 207 | 'payjoin tx failed to broadcast.\nReason:\n' + response, 208 | ); 209 | } else { 210 | // Schedule original tx broadcast after succeeding, just in case. 211 | await this.wallet.scheduleBroadcastTx( 212 | originalTxHex, 213 | BROADCAST_ATTEMPT_TIME, 214 | ); 215 | } 216 | } catch (e) { 217 | // If anything goes wrong, broadcast original immediately. 218 | await broadcastOriginalNow(); 219 | throw e; 220 | } 221 | } 222 | } 223 | exports.PayjoinClient = PayjoinClient; 224 | function isRequesterOpts(opts) { 225 | return opts.payjoinRequester !== undefined; 226 | } 227 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export { PayjoinClient } from './client'; 2 | export { IPayjoinClientWallet } from './wallet'; 3 | export { IPayjoinRequester } from './request'; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | Object.defineProperty(exports, '__esModule', { value: true }); 3 | var client_1 = require('./client'); 4 | exports.PayjoinClient = client_1.PayjoinClient; 5 | -------------------------------------------------------------------------------- /src/request.d.ts: -------------------------------------------------------------------------------- 1 | import { Psbt } from 'bitcoinjs-lib'; 2 | /** 3 | * Handle known errors and return a generic message for unkonw errors. 4 | * 5 | * This prevents people integrating this library introducing an accidental 6 | * phishing vulnerability in their app by displaying a server generated 7 | * messages in their UI. 8 | * 9 | * We still expose the error code so custom handling of specific or unknown 10 | * error codes can still be added in the app. 11 | */ 12 | export declare class PayjoinEndpointError extends Error { 13 | static messageMap: { 14 | [key: string]: string; 15 | }; 16 | static codeToMessage(code: string): string; 17 | code: string; 18 | constructor(code: string); 19 | } 20 | export interface IPayjoinRequester { 21 | /** 22 | * @async 23 | * This requests the payjoin from the payjoin server 24 | * 25 | * @param {Psbt} psbt - A fully signed, finalized, and valid Psbt. 26 | * @return {Promise} The payjoin proposal Psbt. 27 | */ 28 | requestPayjoin(psbt: Psbt): Promise; 29 | } 30 | export declare class PayjoinRequester implements IPayjoinRequester { 31 | private endpointUrl; 32 | private customFetch?; 33 | constructor(endpointUrl: string, customFetch?: (() => Promise) | undefined); 34 | requestPayjoin(psbt: Psbt): Promise; 35 | } 36 | -------------------------------------------------------------------------------- /src/request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | Object.defineProperty(exports, '__esModule', { value: true }); 3 | const bitcoinjs_lib_1 = require('bitcoinjs-lib'); 4 | /** 5 | * Handle known errors and return a generic message for unkonw errors. 6 | * 7 | * This prevents people integrating this library introducing an accidental 8 | * phishing vulnerability in their app by displaying a server generated 9 | * messages in their UI. 10 | * 11 | * We still expose the error code so custom handling of specific or unknown 12 | * error codes can still be added in the app. 13 | */ 14 | class PayjoinEndpointError extends Error { 15 | constructor(code) { 16 | super(PayjoinEndpointError.codeToMessage(code)); 17 | this.code = code; 18 | } 19 | static codeToMessage(code) { 20 | return ( 21 | this.messageMap[code] || 22 | 'Something went wrong when requesting the payjoin endpoint.' 23 | ); 24 | } 25 | } 26 | exports.PayjoinEndpointError = PayjoinEndpointError; 27 | PayjoinEndpointError.messageMap = { 28 | 'leaking-data': 29 | 'Key path information or GlobalXPubs should not be included in the original PSBT.', 30 | 'psbt-not-finalized': 'The original PSBT must be finalized.', 31 | unavailable: 'The payjoin endpoint is not available for now.', 32 | 'out-of-utxos': 33 | 'The receiver does not have any UTXO to contribute in a payjoin proposal.', 34 | 'not-enough-money': 35 | 'The receiver added some inputs but could not bump the fee of the payjoin proposal.', 36 | 'insane-psbt': 'Some consistency check on the PSBT failed.', 37 | 'version-unsupported': 'This version of payjoin is not supported.', 38 | 'need-utxo-information': 'The witness UTXO or non witness UTXO is missing.', 39 | 'invalid-transaction': 'The original transaction is invalid for payjoin.', 40 | }; 41 | class PayjoinRequester { 42 | constructor(endpointUrl, customFetch) { 43 | this.endpointUrl = endpointUrl; 44 | this.customFetch = customFetch; 45 | } 46 | async requestPayjoin(psbt) { 47 | if (!psbt) { 48 | throw new Error('Need to pass psbt'); 49 | } 50 | const fetchFunction = this.customFetch || fetch; 51 | const response = await fetchFunction(this.endpointUrl, { 52 | method: 'POST', 53 | headers: new Headers({ 54 | 'Content-Type': 'text/plain', 55 | }), 56 | body: psbt.toBase64(), 57 | }).catch((v) => ({ 58 | ok: false, 59 | async text() { 60 | return v.message; 61 | }, 62 | })); 63 | const responseText = await response.text(); 64 | if (!response.ok) { 65 | let errorCode = ''; 66 | try { 67 | errorCode = JSON.parse(responseText).errorCode; 68 | } catch (err) {} 69 | throw new PayjoinEndpointError(errorCode); 70 | } 71 | return bitcoinjs_lib_1.Psbt.fromBase64(responseText); 72 | } 73 | } 74 | exports.PayjoinRequester = PayjoinRequester; 75 | -------------------------------------------------------------------------------- /src/utils.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { Psbt } from 'bitcoinjs-lib'; 3 | import { Bip32Derivation, PsbtInput } from 'bip174/src/lib/interfaces'; 4 | export declare enum ScriptPubKeyType { 5 | Unsupported = 0, 6 | Legacy = 1, 7 | Segwit = 2, 8 | SegwitP2SH = 3 9 | } 10 | export declare const SUPPORTED_WALLET_FORMATS: ScriptPubKeyType[]; 11 | export declare function getFee(feeRate: number, size: number): number; 12 | export declare function checkSanity(psbt: Psbt): string[][]; 13 | export declare function getInputsScriptPubKeyType(psbt: Psbt): ScriptPubKeyType; 14 | export declare function hasKeypathInformationSet(items: { 15 | bip32Derivation?: Bip32Derivation[]; 16 | }[]): boolean; 17 | export declare function isFinalized(input: PsbtInput): boolean; 18 | export declare function getInputIndex(psbt: Psbt, prevOutHash: Buffer, prevOutIndex: number): number; 19 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | Object.defineProperty(exports, '__esModule', { value: true }); 3 | const bitcoinjs_lib_1 = require('bitcoinjs-lib'); 4 | var ScriptPubKeyType; 5 | (function (ScriptPubKeyType) { 6 | /// 7 | /// This type is reserved for scripts that are unsupported. 8 | /// 9 | ScriptPubKeyType[(ScriptPubKeyType['Unsupported'] = 0)] = 'Unsupported'; 10 | /// 11 | /// Derive P2PKH addresses (P2PKH) 12 | /// Only use this for legacy code or coins not supporting segwit. 13 | /// 14 | ScriptPubKeyType[(ScriptPubKeyType['Legacy'] = 1)] = 'Legacy'; 15 | /// 16 | /// Derive Segwit (Bech32) addresses (P2WPKH) 17 | /// This will result in the cheapest fees. This is the recommended choice. 18 | /// 19 | ScriptPubKeyType[(ScriptPubKeyType['Segwit'] = 2)] = 'Segwit'; 20 | /// 21 | /// Derive P2SH address of a Segwit address (P2WPKH-P2SH) 22 | /// Use this when you worry that your users do not support Bech address format. 23 | /// 24 | ScriptPubKeyType[(ScriptPubKeyType['SegwitP2SH'] = 3)] = 'SegwitP2SH'; 25 | })( 26 | (ScriptPubKeyType = 27 | exports.ScriptPubKeyType || (exports.ScriptPubKeyType = {})), 28 | ); 29 | exports.SUPPORTED_WALLET_FORMATS = [ 30 | ScriptPubKeyType.Segwit, 31 | ScriptPubKeyType.SegwitP2SH, 32 | ]; 33 | function getFee(feeRate, size) { 34 | return feeRate * size; 35 | } 36 | exports.getFee = getFee; 37 | function checkSanity(psbt) { 38 | const result = []; 39 | psbt.data.inputs.forEach((value, index) => { 40 | result[index] = checkInputSanity(value, psbt.txInputs[index]); 41 | }); 42 | return result; 43 | } 44 | exports.checkSanity = checkSanity; 45 | function checkInputSanity(input, txInput) { 46 | const errors = []; 47 | if (isFinalized(input)) { 48 | if (input.partialSig && input.partialSig.length > 0) { 49 | errors.push('Input finalized, but partial sigs are not empty'); 50 | } 51 | if (input.bip32Derivation && input.bip32Derivation.length > 0) { 52 | errors.push('Input finalized, but hd keypaths are not empty'); 53 | } 54 | if (input.sighashType !== undefined) { 55 | errors.push('Input finalized, but sighash type is not empty'); 56 | } 57 | if (input.redeemScript) { 58 | errors.push('Input finalized, but redeem script is not empty'); 59 | } 60 | if (input.witnessScript) { 61 | errors.push('Input finalized, but witness script is not empty'); 62 | } 63 | } 64 | if (input.witnessUtxo && input.nonWitnessUtxo) { 65 | errors.push('witness utxo and non witness utxo simultaneously present'); 66 | } 67 | if (input.witnessScript && !input.witnessUtxo) { 68 | errors.push('witness script present but no witness utxo'); 69 | } 70 | if (input.finalScriptWitness && !input.witnessUtxo) { 71 | errors.push('final witness script present but no witness utxo'); 72 | } 73 | if (input.nonWitnessUtxo) { 74 | const prevTx = bitcoinjs_lib_1.Transaction.fromBuffer(input.nonWitnessUtxo); 75 | const prevOutTxId = prevTx.getHash(); 76 | let validOutpoint = true; 77 | if (!txInput.hash.equals(prevOutTxId)) { 78 | errors.push( 79 | 'non_witness_utxo does not match the transaction id referenced by the global transaction sign', 80 | ); 81 | validOutpoint = false; 82 | } 83 | if (txInput.index >= prevTx.outs.length) { 84 | errors.push( 85 | 'Global transaction referencing an out of bound output in non_witness_utxo', 86 | ); 87 | validOutpoint = false; 88 | } 89 | if (input.redeemScript && validOutpoint) { 90 | if ( 91 | !redeemScriptToScriptPubkey(input.redeemScript).equals( 92 | prevTx.outs[txInput.index].script, 93 | ) 94 | ) 95 | errors.push( 96 | 'The redeem_script is not coherent with the scriptPubKey of the non_witness_utxo', 97 | ); 98 | } 99 | } 100 | if (input.witnessUtxo) { 101 | if (input.redeemScript) { 102 | if ( 103 | !redeemScriptToScriptPubkey(input.redeemScript).equals( 104 | input.witnessUtxo.script, 105 | ) 106 | ) 107 | errors.push( 108 | 'The redeem_script is not coherent with the scriptPubKey of the witness_utxo', 109 | ); 110 | if ( 111 | input.witnessScript && 112 | input.redeemScript && 113 | !input.redeemScript.equals( 114 | witnessScriptToScriptPubkey(input.witnessScript), 115 | ) 116 | ) 117 | errors.push( 118 | 'witnessScript with witness UTXO does not match the redeemScript', 119 | ); 120 | } 121 | } 122 | return errors; 123 | } 124 | function getInputsScriptPubKeyType(psbt) { 125 | if ( 126 | psbt.data.inputs.filter((i) => !i.witnessUtxo && !i.nonWitnessUtxo).length > 127 | 0 128 | ) 129 | throw new Error( 130 | 'The psbt should be able to be finalized with utxo information', 131 | ); 132 | const types = new Set(); 133 | for (let i = 0; i < psbt.data.inputs.length; i++) { 134 | const type = psbt.getInputType(i); 135 | switch (type) { 136 | case 'witnesspubkeyhash': 137 | types.add(ScriptPubKeyType.Segwit); 138 | break; 139 | case 'p2sh-witnesspubkeyhash': 140 | types.add(ScriptPubKeyType.SegwitP2SH); 141 | break; 142 | case 'pubkeyhash': 143 | types.add(ScriptPubKeyType.Legacy); 144 | break; 145 | default: 146 | types.add(ScriptPubKeyType.Unsupported); 147 | } 148 | } 149 | if (types.size > 1) throw new Error('Inputs must all be the same type'); 150 | return types.values().next().value; 151 | } 152 | exports.getInputsScriptPubKeyType = getInputsScriptPubKeyType; 153 | function redeemScriptToScriptPubkey(redeemScript) { 154 | return bitcoinjs_lib_1.payments.p2sh({ redeem: { output: redeemScript } }) 155 | .output; 156 | } 157 | function witnessScriptToScriptPubkey(witnessScript) { 158 | return bitcoinjs_lib_1.payments.p2wsh({ redeem: { output: witnessScript } }) 159 | .output; 160 | } 161 | function hasKeypathInformationSet(items) { 162 | return ( 163 | items.filter( 164 | (value) => !!value.bip32Derivation && value.bip32Derivation.length > 0, 165 | ).length > 0 166 | ); 167 | } 168 | exports.hasKeypathInformationSet = hasKeypathInformationSet; 169 | function isFinalized(input) { 170 | return ( 171 | input.finalScriptSig !== undefined || input.finalScriptWitness !== undefined 172 | ); 173 | } 174 | exports.isFinalized = isFinalized; 175 | function getInputIndex(psbt, prevOutHash, prevOutIndex) { 176 | for (const [index, input] of psbt.txInputs.entries()) { 177 | if ( 178 | Buffer.compare(input.hash, prevOutHash) === 0 && 179 | input.index === prevOutIndex 180 | ) { 181 | return index; 182 | } 183 | } 184 | return -1; 185 | } 186 | exports.getInputIndex = getInputIndex; 187 | -------------------------------------------------------------------------------- /src/wallet.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { Psbt } from 'bitcoinjs-lib'; 3 | export interface IPayjoinClientWallet { 4 | /** 5 | * @async 6 | * This creates a fully signed, finalized, and valid Psbt. 7 | * 8 | * @return {Promise} The Original non-payjoin Psbt for submission to 9 | * the payjoin server. 10 | */ 11 | getPsbt(): Promise; 12 | /** 13 | * @async 14 | * This takes the payjoin Psbt and signs, and finalizes any un-finalized 15 | * inputs. Any checks against the payjoin proposal Psbt should be done here. 16 | * However, this library does perform some sanity checks. 17 | * 18 | * @param {Psbt} payjoinProposal - A Psbt proposal for the payjoin. It is 19 | * assumed that all inputs added by the server are signed and finalized. All 20 | * of the PayjoinClientWallet's inputs should be unsigned and unfinalized. 21 | * @return {Psbt} The signed and finalized payjoin proposal Psbt 22 | * for submission to the payjoin server. 23 | */ 24 | signPsbt(payjoinProposal: Psbt): Promise; 25 | /** 26 | * @async 27 | * This takes the fully signed and constructed payjoin transaction hex and 28 | * broadcasts it to the network. It returns true if succeeded and false if 29 | * broadcasting returned any errors. 30 | * 31 | * @param {string} txHex - A fully valid transaction hex string. 32 | * @return {string} Empty string ('') if succeeded, RPC error 33 | * message string etc. if failed. 34 | */ 35 | broadcastTx(txHex: string): Promise; 36 | /** 37 | * @async 38 | * This takes the original transaction (submitted to the payjoin server at 39 | * the beginning) and attempts to broadcast it X milliSeconds later. 40 | * Notably, this MUST NOT throw an error if the broadcast fails, and if 41 | * the broadcast succeeds it MUST be noted that something was wrong with 42 | * the payjoin transaction. 43 | * 44 | * @param {string} txHex - A fully valid transaction hex string. 45 | * @param {number} milliSeconds - The number of milliSeconds to wait until 46 | * attempting to broadcast 47 | * @return {void} This should return once the broadcast is scheduled 48 | * via setTimeout etc. (Do not wait until the broadcast occurs to return) 49 | */ 50 | scheduleBroadcastTx(txHex: string, milliSeconds: number): Promise; 51 | /** 52 | * @async 53 | * This accepts a script and optionally a BIP32 derivation path and returns a 54 | * boolean depending on whether or not the wallet owns this output script. 55 | * 56 | * @param {script} Buffer - An output script buffer. 57 | * @param {pathFromRoot} string - A BIP32 derivation path. 58 | * @return {boolean} A boolean depending on whether or not the wallet owns 59 | * this output script. 60 | */ 61 | isOwnOutputScript(script: Buffer, pathFromRoot?: string): Promise; 62 | } 63 | -------------------------------------------------------------------------------- /src/wallet.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | Object.defineProperty(exports, '__esModule', { value: true }); 3 | -------------------------------------------------------------------------------- /test/client.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PayjoinClient, 3 | IPayjoinClientWallet, 4 | IPayjoinRequester, 5 | } from '../ts_src/index'; 6 | import * as bitcoin from 'bitcoinjs-lib'; 7 | import { default as VECTORS } from './fixtures/client.fixtures'; 8 | 9 | // pass the regtest network to everything 10 | const network = bitcoin.networks.regtest; 11 | 12 | const p2shp2wpkhOutputScript = (pubkey: Buffer) => 13 | bitcoin.payments.p2sh({ 14 | redeem: bitcoin.payments.p2wpkh({ pubkey, network }), 15 | network, 16 | }).output; 17 | const p2wpkhOutputScript = (pubkey: Buffer) => 18 | bitcoin.payments.p2wpkh({ pubkey, network }).output; 19 | 20 | describe('requestPayjoin', () => { 21 | it('should exist', () => { 22 | expect(PayjoinClient).toBeDefined(); 23 | expect(typeof PayjoinClient).toBe('function'); // JS classes are functions 24 | }); 25 | VECTORS.valid.forEach((f) => { 26 | it('should request p2sh-p2wpkh payjoin', async () => { 27 | await testPayjoin(f.p2shp2wpkh, p2shp2wpkhOutputScript); 28 | }); 29 | it('should request p2wpkh payjoin', async () => { 30 | await testPayjoin(f.p2wpkh, p2wpkhOutputScript); 31 | }); 32 | }); 33 | VECTORS.invalid.forEach((f) => { 34 | it(f.description, async () => { 35 | await expect(testPayjoin(f.vector, () => {})).rejects.toThrowError( 36 | new RegExp(f.exception), 37 | ); 38 | }); 39 | }); 40 | }); 41 | 42 | async function testPayjoin( 43 | vector: any, 44 | getOutputScript: Function, 45 | ): Promise { 46 | const rootNode = bitcoin.bip32.fromBase58(VECTORS.privateRoot, network); 47 | const wallet = new TestWallet(vector.wallet, rootNode, getOutputScript); 48 | const payjoinRequester = new DummyRequester(vector.payjoin); 49 | const client = new PayjoinClient({ 50 | wallet, 51 | payjoinRequester, 52 | }); 53 | 54 | await client.run(); 55 | 56 | expect(wallet.tx).toBeDefined(); 57 | expect(wallet.tx!.toHex()).toEqual(vector.finaltx); 58 | } 59 | 60 | // Use this for testing 61 | class TestWallet implements IPayjoinClientWallet { 62 | tx: bitcoin.Transaction | undefined; 63 | timeout: NodeJS.Timeout | undefined; 64 | 65 | constructor( 66 | private psbtString: string, 67 | private rootNode: bitcoin.BIP32Interface, 68 | private getOutputScript: Function, 69 | ) {} 70 | 71 | async getPsbt() { 72 | return bitcoin.Psbt.fromBase64(this.psbtString, { network }); 73 | } 74 | 75 | async signPsbt(psbt: bitcoin.Psbt): Promise { 76 | psbt.data.inputs.forEach((psbtInput, i) => { 77 | if ( 78 | psbtInput.finalScriptSig === undefined && 79 | psbtInput.finalScriptWitness === undefined 80 | ) { 81 | psbt.signInputHD(i, this.rootNode).finalizeInput(i); 82 | } 83 | }); 84 | return psbt; 85 | } 86 | 87 | async broadcastTx(txHex: string): Promise { 88 | this.tx = bitcoin.Transaction.fromHex(txHex); 89 | return ''; 90 | } 91 | 92 | async scheduleBroadcastTx(txHex: string, ms: number): Promise { 93 | return txHex + ms + 'x' ? undefined : undefined; 94 | } 95 | 96 | async isOwnOutputScript( 97 | script: Buffer, 98 | pathFromRoot?: string, 99 | ): Promise { 100 | if (!pathFromRoot) return false; 101 | const { publicKey } = this.rootNode.derivePath(pathFromRoot); 102 | const ourScript = this.getOutputScript(publicKey); 103 | return script.equals(ourScript); 104 | } 105 | } 106 | 107 | class DummyRequester implements IPayjoinRequester { 108 | constructor(private psbt: string) {} 109 | 110 | async requestPayjoin(psbt: bitcoin.Psbt): Promise { 111 | const myString = psbt ? this.psbt : this.psbt; 112 | // @ts-ignore 113 | if (!myString) return; 114 | return bitcoin.Psbt.fromBase64(myString, { network }); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /test/fixtures/client.fixtures.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | privateRoot: 3 | 'tprv8ZgxMBicQKsPfBD2PErVQNAqcjwLBg8fWZSX8qwx1cRyFsDrgvRDLqaT5Rf2N4VEXZDAkpWeJ9vXXREbAUY67RtoZorrfxqgDMxsb6FiBFH', 4 | valid: [ 5 | { 6 | p2shp2wpkh: { 7 | wallet: 8 | 'cHNidP8BAHMCAAAAAe3cMHpMFX6UHpGPK+xGWLDw3QGfmDRLGLlTJE4czTayAQAAAAD/////AhY3AAAAAAAAF6kUGUpoD/FYeCkzIQx9LCFO8/HGy8CH2s4wAQAAAAAXqRRX9409aWdn9NbRyKxZhrq60kTtb4cAAAAAAAEBIAAtMQEAAAAAF6kUzOrfUgwsdkXW9MwTqFVnWJYneG6HIgIDMK/+LKSLLXGbmoZDWhO+wfQNSg4EZDiysJ01XudKR1FIMEUCIQC2ueU0wAHbt0k0hKHYIy56LP5mPkYl7l7fchZxq51dXAIgCS10esmX6NEPiKhDEmpXNdMfDXuky79kO7o14ICtdBIBAQQWABTCMhP785DvZ/hbAFqf8xLvhW+i+iIGAzCv/iykiy1xm5qGQ1oTvsH0DUoOBGQ4srCdNV7nSkdRGL2ZyQMxAACAAQAAgAAAAIAAAAAAAAAAAAAAIgIDhOiaJvrnFp4/cwvgS1YRsR7ogQ1DBEGKa6soIaoC0AwYvZnJAzEAAIABAACAAAAAgAEAAAAAAAAAAA==', 9 | payjoin: 10 | 'cHNidP8BAJwCAAAAAu3cMHpMFX6UHpGPK+xGWLDw3QGfmDRLGLlTJE4czTayAQAAAAD/////sCrDg0SI1KmVv7LpsN35WnZ6G5Ibh5EgEpTTfUAbv6QAAAAAAP////8C3N7MAAAAAAAXqRQZSmgP8Vh4KTMhDH0sIU7z8cbLwIdxuTABAAAAABepFFf3jT1pZ2f01tHIrFmGurrSRO1vhwAAAAAAAAEBIManzAAAAAAAF6kU8YKUJ//lXt+c2a2J3k4fk6lM8v+HAQcXFgAUeomseQvDNhdw39dTc5Mz8pb0gIUBCGsCRzBEAiA11cfhH3R6+R25Y/0g64NUOV/DgiyiFJYsz9AYwIcQxwIgD3B4PEi+iKCfWkoOT0TxInvabmVeqBBi9JYwKOuTXjIBIQIHExZSjfM0g3ykBAQVLPMn4q7+3iu6syPS0KlqlhdpiwAAAA==', 11 | finaltx: 12 | '02000000000102eddc307a4c157e941e918f2bec4658b0f0dd019f98344b18b953244e1ccd36b20100000017160014c23213fbf390ef67f85b005a9ff312ef856fa2faffffffffb02ac3834488d4a995bfb2e9b0ddf95a767a1b921b8791201294d37d401bbfa400000000171600147a89ac790bc3361770dfd753739333f296f48085ffffffff02dcdecc000000000017a914194a680ff158782933210c7d2c214ef3f1c6cbc08771b930010000000017a91457f78d3d696767f4d6d1c8ac5986babad244ed6f8702483045022100c7fcc918ecbb754265c0dda155a27e7d3c54f483445e67136311f8f3f872c5b002204b01978ba1ae7fa436a244c4761cafdf3a9790606aa98301fdfe2363cee77fa301210330affe2ca48b2d719b9a86435a13bec1f40d4a0e046438b2b09d355ee74a475102473044022035d5c7e11f747af91db963fd20eb8354395fc3822ca214962ccfd018c08710c702200f70783c48be88a09f5a4a0e4f44f1227bda6e655ea81062f4963028eb935e32012102071316528df334837ca40404152cf327e2aefede2bbab323d2d0a96a9617698b00000000', 13 | }, 14 | p2wpkh: { 15 | wallet: 16 | 'cHNidP8BAHECAAAAAcG1DqRm0b8vhX6JpsU3m2N8V6xVz226gjY+pE7up8ebAAAAAAD/////AhY3AAAAAAAAFgAUn1AUWTvX0DxQkFIeS+rmPrPMvBrazjABAAAAABYAFD4WR6SuL+LmgQYyQeDKe7e53H2FAAAAAAABAR8ALTEBAAAAABYAFH+Ar5fIkaBhnFoV+l63h8a3ieXNIgICz9HHZqLbHIDNDIlaeWJXXhfij/uu5fpIoXRDkICd/IBHMEQCIA5rthangBAiievrSBxLjaY84rH0rTQWtLZZgjJXmCNwAiBfahFoEsKJIpYNT6gLpgnz4Kd5tfhwxVu4suC3JtYDRwEiBgLP0cdmotscgM0MiVp5YldeF+KP+67l+kihdEOQgJ38gBi9mckDVAAAgAEAAIAAAACAAAAAAAAAAAAAACICAnV5cU7BjADcEOOyGq9mFwih6VX/4pXpJdKCxY/szjOXGL2ZyQNUAACAAQAAgAAAAIABAAAAAAAAAAA=', 17 | payjoin: 18 | 'cHNidP8BAJoCAAAAAsG1DqRm0b8vhX6JpsU3m2N8V6xVz226gjY+pE7up8ebAAAAAAD/////r0UF7k4QIg7eU5xF5s+hAqE7Jfd6sipD1Yd8va6VK7AAAAAAAP////8CBLwwAQAAAAAWABQ+Fkekri/i5oEGMkHgynu3udx9hbhwzAAAAAAAFgAUn1AUWTvX0DxQkFIeS+rmPrPMvBoAAAAAAAABAR+iOcwAAAAAABYAFCZkjLYCTqSilHNgvSXmFJo2FwJAAQhrAkcwRAIgSrvgI38Qc1LQbeJHuGstnmAxKa0r1vs0HEaXV/l9lDACIGY3HXTY3OI/k1qJwKsR5xAdm0xisOIH7ExKDieGM3f8ASECW3pZhcq0tRXPiM7jLITLeCetYq48ElvgytyaUpGrvR0AAAA=', 19 | finaltx: 20 | '02000000000102c1b50ea466d1bf2f857e89a6c5379b637c57ac55cf6dba82363ea44eeea7c79b0000000000ffffffffaf4505ee4e10220ede539c45e6cfa102a13b25f77ab22a43d5877cbdae952bb00000000000ffffffff0204bc3001000000001600143e1647a4ae2fe2e681063241e0ca7bb7b9dc7d85b870cc00000000001600149f5014593bd7d03c5090521e4beae63eb3ccbc1a0247304402205b63aba308d01cc9420744527edb07ff1c26c3335539784a3f6c022d7df61e51022018c6f1637eedc99ea95cd2000936ecd4bc5e177b5be12afa5863bb4094e0ab59012102cfd1c766a2db1c80cd0c895a7962575e17e28ffbaee5fa48a1744390809dfc800247304402204abbe0237f107352d06de247b86b2d9e603129ad2bd6fb341c469757f97d9430022066371d74d8dce23f935a89c0ab11e7101d9b4c62b0e207ec4c4a0e27863377fc0121025b7a5985cab4b515cf88cee32c84cb7827ad62ae3c125be0cadc9a5291abbd1d00000000', 21 | }, 22 | }, 23 | ], 24 | invalid: [ 25 | { 26 | description: 'should throw when input is wrong type', 27 | exception: 28 | 'Inputs used do not support payjoin, they must be segwit \\(p2wpkh or p2sh-p2wpkh\\)', 29 | vector: { 30 | wallet: 31 | 'cHNidP8BAFUCAAAAARjxDP3puQxJ0a3RWQ95ciPPi+V2jZgZb/NuHV93KZ+wAAAAAAD/////AUQLAQAAAAAAGXapFL2ZyQMv/In1PSScRofmzbEatoddiKwAAAAAAAEAwAIAAAABf4FPD7V9reV6et8EcC8IHyUPnNdazWDZds5QNGbC95ABAAAAa0gwRQIhAPJ8LolgdP+68jLc2ijj8w5B44Ue6esWbH4gPFvVFhkUAiA3XC7eb5+x0NfDBbK0UAz7CngGv9FZ7uA9ipO9LCptnAEhA6SNzVPHrx8ylDHvD2Jv8wdRpQJZ7fGljNhKSJSBaiGX/////wEsDwEAAAAAABl2qRS9mckDL/yJ9T0knEaH5s2xGraHXYisAAAAACICAq91A34xjfh7oaDkCBF5GPpZJv0EyyHt9fflPYb3kdVtRzBEAiAQBWcXLHA1k+tm40C+IJnQ3ZgRfoCMkdLYsgvaGYiVagIgfFhmsgSKJqOS0TxGRKQvfnFIoxXaxKREDY4NKm4t0wMBAAA=', 32 | }, 33 | }, 34 | { 35 | description: 'should throw when input is missing utxo info', 36 | exception: 37 | 'The psbt should be able to be finalized with utxo information', 38 | vector: { 39 | wallet: 40 | 'cHNidP8BAFICAAAAATaMccRc8ZqCMWywc9JhNeYa3goqij3IOwNFchsjlTQiAAAAAAD/////AUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAAACICAq91A34xjfh7oaDkCBF5GPpZJv0EyyHt9fflPYb3kdVtRzBEAiAmwcg4sfSEltzMsbKbC5uC275kzLwdkoIw50hct8PADgIgLaKGSdoiFeoutm7MCRzzhbcKcp0bUN6mxIBt0/qnLu4BAAA=', 41 | }, 42 | }, 43 | { 44 | description: 'should throw when Psbt was not returned', 45 | exception: "We did not get the receiver's PSBT", 46 | vector: { 47 | wallet: 48 | 'cHNidP8BAFICAAAAAXmSxJ95noyq1oUFqPuKHoZW20QWzduxqeN0Ro6IallkAAAAAAD/////AUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAAAAEBHywPAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10iAgKvdQN+MY34e6Gg5AgReRj6WSb9BMsh7fX35T2G95HVbUcwRAIgXe1JNvFGQ34RNrNqoHeajhUHlBBRh5mZVWK0cpm1C50CIAps8ABtDVmfcL87uPfZezKU2/9dFBz0kxGaCQ2tOmTYAQAA', 49 | }, 50 | }, 51 | { 52 | description: 'should throw when payjoin has globalxpub', 53 | exception: "GlobalXPubs should not be included in the receiver's PSBT", 54 | vector: { 55 | wallet: 56 | 'cHNidP8BAFICAAAAAWclrsbhiD7G1ypleYAen/8KTO2pBB+hFRpUyCfyKSDCAAAAAAD/////AUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAAAAEBHywPAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10iAgKvdQN+MY34e6Gg5AgReRj6WSb9BMsh7fX35T2G95HVbUgwRQIhAL91FJzmGIV9GDY13Fvr5812i19/hSN0IoSkocRDwwOrAiAPJIE0ct0CJOxb24SEV+YrJr76wsaYmQ9My6OLSD8yLgEAAA==', 57 | payjoin: 58 | 'cHNidP8BAFICAAAAAWclrsbhiD7G1ypleYAen/8KTO2pBB+hFRpUyCfyKSDCAAAAAAD/////AUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAATwEENYfPA+I1YCmAAAAAfD1s5z8bo4zW3Gcu+UKRlSWWNTqMw0YPMRSPfwAj/yUDzCWNKVWnWB/fac8+aQFXs8OCyFm9TO7Y5J/EKYilXBsQvZnJAywAAIAAAACAAAAAgAABAR8sDwEAAAAAABYAFL2ZyQMv/In1PSScRofmzbEatoddIgICr3UDfjGN+HuhoOQIEXkY+lkm/QTLIe319+U9hveR1W1IMEUCIQC/dRSc5hiFfRg2Ndxb6+fNdotff4UjdCKEpKHEQ8MDqwIgDySBNHLdAiTsW9uEhFfmKya++sLGmJkPTMuji0g/Mi4BAAA=', 59 | }, 60 | }, 61 | { 62 | description: 'should throw when payjoin has bip32Derivation', 63 | exception: 64 | "Keypath information should not be included in the receiver's PSBT", 65 | vector: { 66 | wallet: 67 | 'cHNidP8BAFICAAAAAWclrsbhiD7G1ypleYAen/8KTO2pBB+hFRpUyCfyKSDCAAAAAAD/////AUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAAAAEBHywPAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10iAgKvdQN+MY34e6Gg5AgReRj6WSb9BMsh7fX35T2G95HVbUgwRQIhAL91FJzmGIV9GDY13Fvr5812i19/hSN0IoSkocRDwwOrAiAPJIE0ct0CJOxb24SEV+YrJr76wsaYmQ9My6OLSD8yLgEAAA==', 68 | payjoin: 69 | 'cHNidP8BAFICAAAAAVmJblimSxLc+35x7qXXCdLMPpxGuE/3cmnIKbkVoi+BAAAAAAD/////AUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAAAAEBHywPAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10iAgKvdQN+MY34e6Gg5AgReRj6WSb9BMsh7fX35T2G95HVbUgwRQIhAKlbaYs1G4wZAFeuTGSOXNQiYZXO2b0SPvGCWvBqCKjpAiAw1b/K7Kj0DwT6IPAa0lRTF/87zREFB06lQw29C0oT7AEiBgKvdQN+MY34e6Gg5AgReRj6WSb9BMsh7fX35T2G95HVbQi9mckDAAAAAAAA', 70 | }, 71 | }, 72 | { 73 | description: 'should throw when payjoin missing input from original', 74 | exception: "Receiver's PSBT is missing input #0 from the sent PSBT", 75 | vector: { 76 | wallet: 77 | 'cHNidP8BAFICAAAAAWclrsbhiD7G1ypleYAen/8KTO2pBB+hFRpUyCfyKSDCAAAAAAD/////AUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAAAAEBHywPAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10iAgKvdQN+MY34e6Gg5AgReRj6WSb9BMsh7fX35T2G95HVbUgwRQIhAL91FJzmGIV9GDY13Fvr5812i19/hSN0IoSkocRDwwOrAiAPJIE0ct0CJOxb24SEV+YrJr76wsaYmQ9My6OLSD8yLgEAAA==', 78 | payjoin: 79 | 'cHNidP8BAFICAAAAAdoRvMcqH3uh2NVL2FGlJxHL/8N4DIgRXLG8CeiP7UHNAAAAAAD/////AUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAAAAEBHywPAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10iAgKvdQN+MY34e6Gg5AgReRj6WSb9BMsh7fX35T2G95HVbUgwRQIhAPkHBAnXbxit4W8hymbVz+jiIH4EiY4ZzbGueMFePio8AiAVNhvfQhRhlgon4i2W6ySVpYuK/EXxeLzzF87gwlTBXQEAAA==', 80 | }, 81 | }, 82 | { 83 | description: 'should throw when payjoin has different input sequence', 84 | exception: 'Input #0 from original PSBT have a different sequence', 85 | vector: { 86 | wallet: 87 | 'cHNidP8BAFICAAAAARXVCcnqz+xMne9ayS0AbP/UFjcnr3ErQLAYC9wsQkzZAAAAAAD/////AUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAAAAEBHywPAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10iAgKvdQN+MY34e6Gg5AgReRj6WSb9BMsh7fX35T2G95HVbUgwRQIhALJwmOi45bjKatYtZo+fsUHPCwnXEQ5qrVnE1N2wvo3FAiBracRPjzVOe0jHw76C0sBojCUYcs08zIrVR/go5ULd2gEAAA==', 88 | payjoin: 89 | 'cHNidP8BAFICAAAAARXVCcnqz+xMne9ayS0AbP/UFjcnr3ErQLAYC9wsQkzZAAAAAAAqAAAAAUQLAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10AAAAAAAEBHywPAQAAAAAAFgAUvZnJAy/8ifU9JJxGh+bNsRq2h10iAgKvdQN+MY34e6Gg5AgReRj6WSb9BMsh7fX35T2G95HVbUgwRQIhALJwmOi45bjKatYtZo+fsUHPCwnXEQ5qrVnE1N2wvo3FAiBracRPjzVOe0jHw76C0sBojCUYcs08zIrVR/go5ULd2gEAAA==', 90 | }, 91 | }, 92 | ], 93 | }; 94 | -------------------------------------------------------------------------------- /test/request.spec.ts: -------------------------------------------------------------------------------- 1 | import { PayjoinRequester } from '../ts_src/request'; 2 | import fetchMock from 'jest-fetch-mock'; 3 | import * as bitcoin from 'bitcoinjs-lib'; 4 | import { default as VECTORS } from './fixtures/client.fixtures'; 5 | const PSBTTEXT = VECTORS.valid[0].p2wpkh.wallet; 6 | 7 | describe('payjoin requester', () => { 8 | beforeEach(() => { 9 | // if you have an existing `beforeEach` just add the following line to it 10 | fetchMock.doMock(); 11 | }); 12 | it('should fetch a psbt', async () => { 13 | fetchMock.mockResponseOnce(PSBTTEXT); 14 | const requester = new PayjoinRequester('http://127.0.0.1:12345/1234'); 15 | const response = await requester.requestPayjoin( 16 | bitcoin.Psbt.fromBase64(PSBTTEXT), 17 | ); 18 | expect(response.toBase64()).toEqual(PSBTTEXT); 19 | // @ts-ignore 20 | await expect(requester.requestPayjoin()).rejects.toThrowError( 21 | /Need to pass psbt/, 22 | ); 23 | fetchMock.mockRejectOnce(new Error()); 24 | await expect( 25 | requester.requestPayjoin(bitcoin.Psbt.fromBase64(PSBTTEXT)), 26 | ).rejects.toThrowError( 27 | /Something went wrong when requesting the payjoin endpoint./, 28 | ); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /ts_src/client.ts: -------------------------------------------------------------------------------- 1 | import { Psbt } from 'bitcoinjs-lib'; 2 | import { IPayjoinRequester, PayjoinRequester } from './request'; 3 | import { IPayjoinClientWallet } from './wallet'; 4 | import { 5 | checkSanity, 6 | getInputIndex, 7 | getInputsScriptPubKeyType, 8 | getFee, 9 | hasKeypathInformationSet, 10 | isFinalized, 11 | SUPPORTED_WALLET_FORMATS, 12 | } from './utils'; 13 | 14 | const BROADCAST_ATTEMPT_TIME = 1 * 60 * 1000; // 1 minute 15 | 16 | export class PayjoinClient { 17 | private wallet: IPayjoinClientWallet; 18 | private payjoinRequester: IPayjoinRequester; 19 | constructor(opts: PayjoinClientOpts) { 20 | this.wallet = opts.wallet; 21 | if (isRequesterOpts(opts)) { 22 | this.payjoinRequester = opts.payjoinRequester; 23 | } else { 24 | this.payjoinRequester = new PayjoinRequester(opts.payjoinUrl); 25 | } 26 | } 27 | 28 | private async getSumPaidToUs(psbt: Psbt): Promise { 29 | let sumPaidToUs = 0; 30 | 31 | for (const input of psbt.data.inputs) { 32 | const { bip32Derivation } = input; 33 | const pathFromRoot = bip32Derivation && bip32Derivation[0].path; 34 | if ( 35 | await this.wallet.isOwnOutputScript( 36 | input.witnessUtxo!.script, 37 | pathFromRoot, 38 | ) 39 | ) { 40 | sumPaidToUs -= input.witnessUtxo!.value; 41 | } 42 | } 43 | 44 | for (const [index, output] of Object.entries(psbt.txOutputs)) { 45 | const { bip32Derivation } = psbt.data.outputs[parseInt(index, 10)]; 46 | const pathFromRoot = bip32Derivation && bip32Derivation[0].path; 47 | if (await this.wallet.isOwnOutputScript(output.script, pathFromRoot)) { 48 | sumPaidToUs += output.value; 49 | } 50 | } 51 | 52 | return sumPaidToUs; 53 | } 54 | 55 | async run(): Promise { 56 | const psbt = await this.wallet.getPsbt(); 57 | const clonedPsbt = psbt.clone(); 58 | const originalType = getInputsScriptPubKeyType(clonedPsbt); 59 | clonedPsbt.finalizeAllInputs(); 60 | 61 | const originalTxHex = clonedPsbt.extractTransaction().toHex(); 62 | const broadcastOriginalNow = (): Promise => 63 | this.wallet.broadcastTx(originalTxHex); 64 | 65 | try { 66 | if (SUPPORTED_WALLET_FORMATS.indexOf(originalType) === -1) { 67 | throw new Error( 68 | 'Inputs used do not support payjoin, they must be segwit (p2wpkh or p2sh-p2wpkh)', 69 | ); 70 | } 71 | 72 | // We make sure we don't send unnecessary information to the receiver 73 | for (let index = 0; index < clonedPsbt.inputCount; index++) { 74 | clonedPsbt.clearFinalizedInput(index); 75 | } 76 | clonedPsbt.data.outputs.forEach((output): void => { 77 | delete output.bip32Derivation; 78 | }); 79 | delete clonedPsbt.data.globalMap.globalXpub; 80 | 81 | const payjoinPsbt = await this.payjoinRequester.requestPayjoin( 82 | clonedPsbt, 83 | ); 84 | if (!payjoinPsbt) throw new Error("We did not get the receiver's PSBT"); 85 | 86 | if ( 87 | payjoinPsbt.data.globalMap.globalXpub && 88 | (payjoinPsbt.data.globalMap.globalXpub as any[]).length > 0 89 | ) { 90 | throw new Error( 91 | "GlobalXPubs should not be included in the receiver's PSBT", 92 | ); 93 | } 94 | if ( 95 | hasKeypathInformationSet(payjoinPsbt.data.outputs) || 96 | hasKeypathInformationSet(payjoinPsbt.data.inputs) 97 | ) { 98 | throw new Error( 99 | "Keypath information should not be included in the receiver's PSBT", 100 | ); 101 | } 102 | 103 | const ourInputIndexes: number[] = []; 104 | // Add back input data from the original psbt (such as witnessUtxo) 105 | psbt.txInputs.forEach((originalInput, index): void => { 106 | const payjoinIndex = getInputIndex( 107 | payjoinPsbt, 108 | originalInput.hash, 109 | originalInput.index, 110 | ); 111 | 112 | if (payjoinIndex === -1) { 113 | throw new Error( 114 | `Receiver's PSBT is missing input #${index} from the sent PSBT`, 115 | ); 116 | } 117 | 118 | if ( 119 | originalInput.sequence !== payjoinPsbt.txInputs[payjoinIndex].sequence 120 | ) { 121 | throw new Error( 122 | `Input #${index} from original PSBT have a different sequence`, 123 | ); 124 | } 125 | 126 | payjoinPsbt.updateInput(payjoinIndex, psbt.data.inputs[index]); 127 | const payjoinPsbtInput = payjoinPsbt.data.inputs[payjoinIndex]; 128 | // In theory these shouldn't be here, but just in case, we need to 129 | // re-sign so this is throwing away the invalidated data. 130 | delete payjoinPsbtInput.partialSig; 131 | delete payjoinPsbtInput.finalScriptSig; 132 | delete payjoinPsbtInput.finalScriptWitness; 133 | 134 | ourInputIndexes.push(payjoinIndex); 135 | }); 136 | 137 | const sanityResult = checkSanity(payjoinPsbt); 138 | if ( 139 | !sanityResult.every((inputErrors): boolean => inputErrors.length === 0) 140 | ) { 141 | throw new Error( 142 | `Receiver's PSBT is insane:\n${JSON.stringify( 143 | sanityResult, 144 | null, 145 | 2, 146 | )}`, 147 | ); 148 | } 149 | 150 | // We make sure we don't sign what should not be signed 151 | for (let index = 0; index < payjoinPsbt.inputCount; index++) { 152 | // check if input is Finalized 153 | const ourInput = ourInputIndexes.indexOf(index) !== -1; 154 | if (isFinalized(payjoinPsbt.data.inputs[index])) { 155 | if (ourInput) { 156 | throw new Error( 157 | `Receiver's PSBT included a finalized input from original PSBT `, 158 | ); 159 | } else { 160 | payjoinPsbt.clearFinalizedInput(index); 161 | } 162 | } else if (!ourInput) { 163 | throw new Error(`Receiver's PSBT included a non-finalized new input`); 164 | } 165 | } 166 | 167 | for (let index = 0; index < payjoinPsbt.data.outputs.length; index++) { 168 | const output = payjoinPsbt.data.outputs[index]; 169 | const outputLegacy = payjoinPsbt.txOutputs[index]; 170 | // Make sure only our output has any information 171 | delete output.bip32Derivation; 172 | psbt.data.outputs.forEach((originalOutput, i): void => { 173 | // update the payjoin outputs 174 | const originalOutputLegacy = psbt.txOutputs[i]; 175 | 176 | if (outputLegacy.script.equals(originalOutputLegacy.script)) 177 | payjoinPsbt.updateOutput(index, originalOutput); 178 | }); 179 | } 180 | 181 | if (payjoinPsbt.version !== psbt.version) { 182 | throw new Error( 183 | 'The version field of the transaction has been modified', 184 | ); 185 | } 186 | if (payjoinPsbt.locktime !== psbt.locktime) { 187 | throw new Error( 188 | 'The LockTime field of the transaction has been modified', 189 | ); 190 | } 191 | if (payjoinPsbt.data.inputs.length <= psbt.data.inputs.length) { 192 | throw new Error( 193 | `Receiver's PSBT should have more inputs than the sent PSBT`, 194 | ); 195 | } 196 | 197 | if (getInputsScriptPubKeyType(payjoinPsbt) !== originalType) { 198 | throw new Error( 199 | `Receiver's PSBT included inputs which were of a different format than the sent PSBT`, 200 | ); 201 | } 202 | 203 | const paidBack = await this.getSumPaidToUs(psbt); 204 | const payjoinPaidBack = await this.getSumPaidToUs(payjoinPsbt); 205 | 206 | const signedPsbt = await this.wallet.signPsbt(payjoinPsbt); 207 | const tx = signedPsbt.extractTransaction(); 208 | psbt.finalizeAllInputs(); 209 | 210 | // TODO: make sure this logic is correct 211 | if (payjoinPaidBack < paidBack) { 212 | const overPaying = paidBack - payjoinPaidBack; 213 | const originalFee = psbt.getFee(); 214 | const additionalFee = signedPsbt.getFee() - originalFee; 215 | if (overPaying > additionalFee) 216 | throw new Error( 217 | 'The payjoin receiver is sending more money to himself', 218 | ); 219 | if (overPaying > originalFee) 220 | throw new Error( 221 | 'The payjoin receiver is making us pay more than twice the original fee', 222 | ); 223 | const newVirtualSize = tx.virtualSize(); 224 | // Let's check the difference is only for the fee and that feerate 225 | // did not changed that much 226 | const originalFeeRate = psbt.getFeeRate(); 227 | let expectedFee = getFee(originalFeeRate, newVirtualSize); 228 | // Signing precisely is hard science, give some breathing room for error. 229 | expectedFee += getFee(originalFeeRate, payjoinPsbt.inputCount * 2); 230 | if (overPaying > expectedFee - originalFee) 231 | throw new Error( 232 | 'The payjoin receiver increased the fee rate we are paying too much', 233 | ); 234 | } 235 | 236 | // Now broadcast. If this fails, there's a possibility the server is 237 | // trying to leak information by double spending an input, this is why 238 | // we schedule broadcast of original BEFORE we broadcast the payjoin. 239 | // And it is why schedule broadcast is expected to fail. (why you must 240 | // not throw an error.) 241 | const response = await this.wallet.broadcastTx(tx.toHex()); 242 | if (response !== '') { 243 | throw new Error( 244 | 'payjoin tx failed to broadcast.\nReason:\n' + response, 245 | ); 246 | } else { 247 | // Schedule original tx broadcast after succeeding, just in case. 248 | await this.wallet.scheduleBroadcastTx( 249 | originalTxHex, 250 | BROADCAST_ATTEMPT_TIME, 251 | ); 252 | } 253 | } catch (e) { 254 | // If anything goes wrong, broadcast original immediately. 255 | await broadcastOriginalNow(); 256 | throw e; 257 | } 258 | } 259 | } 260 | 261 | type PayjoinClientOpts = PayjoinClientOptsUrl | PayjoinClientOptsRequester; 262 | 263 | interface PayjoinClientOptsUrl { 264 | wallet: IPayjoinClientWallet; 265 | payjoinUrl: string; 266 | } 267 | 268 | interface PayjoinClientOptsRequester { 269 | wallet: IPayjoinClientWallet; 270 | payjoinRequester: IPayjoinRequester; 271 | } 272 | 273 | function isRequesterOpts( 274 | opts: PayjoinClientOpts, 275 | ): opts is PayjoinClientOptsRequester { 276 | return (opts as PayjoinClientOptsRequester).payjoinRequester !== undefined; 277 | } 278 | -------------------------------------------------------------------------------- /ts_src/index.ts: -------------------------------------------------------------------------------- 1 | export { PayjoinClient } from './client'; 2 | export { IPayjoinClientWallet } from './wallet'; 3 | export { IPayjoinRequester } from './request'; 4 | -------------------------------------------------------------------------------- /ts_src/request.ts: -------------------------------------------------------------------------------- 1 | import { Psbt } from 'bitcoinjs-lib'; 2 | 3 | /** 4 | * Handle known errors and return a generic message for unkonw errors. 5 | * 6 | * This prevents people integrating this library introducing an accidental 7 | * phishing vulnerability in their app by displaying a server generated 8 | * messages in their UI. 9 | * 10 | * We still expose the error code so custom handling of specific or unknown 11 | * error codes can still be added in the app. 12 | */ 13 | export class PayjoinEndpointError extends Error { 14 | static messageMap: { [key: string]: string } = { 15 | 'leaking-data': 16 | 'Key path information or GlobalXPubs should not be included in the original PSBT.', 17 | 'psbt-not-finalized': 'The original PSBT must be finalized.', 18 | unavailable: 'The payjoin endpoint is not available for now.', 19 | 'out-of-utxos': 20 | 'The receiver does not have any UTXO to contribute in a payjoin proposal.', 21 | 'not-enough-money': 22 | 'The receiver added some inputs but could not bump the fee of the payjoin proposal.', 23 | 'insane-psbt': 'Some consistency check on the PSBT failed.', 24 | 'version-unsupported': 'This version of payjoin is not supported.', 25 | 'need-utxo-information': 'The witness UTXO or non witness UTXO is missing.', 26 | 'invalid-transaction': 'The original transaction is invalid for payjoin.', 27 | }; 28 | 29 | static codeToMessage(code: string): string { 30 | return ( 31 | this.messageMap[code] || 32 | 'Something went wrong when requesting the payjoin endpoint.' 33 | ); 34 | } 35 | 36 | code: string; 37 | 38 | constructor(code: string) { 39 | super(PayjoinEndpointError.codeToMessage(code)); 40 | this.code = code; 41 | } 42 | } 43 | 44 | export interface IPayjoinRequester { 45 | /** 46 | * @async 47 | * This requests the payjoin from the payjoin server 48 | * 49 | * @param {Psbt} psbt - A fully signed, finalized, and valid Psbt. 50 | * @return {Promise} The payjoin proposal Psbt. 51 | */ 52 | requestPayjoin(psbt: Psbt): Promise; 53 | } 54 | 55 | export class PayjoinRequester implements IPayjoinRequester { 56 | constructor( 57 | private endpointUrl: string, 58 | private customFetch?: () => Promise, 59 | ) {} 60 | 61 | async requestPayjoin(psbt: Psbt): Promise { 62 | if (!psbt) { 63 | throw new Error('Need to pass psbt'); 64 | } 65 | 66 | const fetchFunction = this.customFetch || fetch; 67 | const response = await fetchFunction(this.endpointUrl, { 68 | method: 'POST', 69 | headers: new Headers({ 70 | 'Content-Type': 'text/plain', 71 | }), 72 | body: psbt.toBase64(), 73 | }).catch( 74 | (v: Error): Response => 75 | ({ 76 | ok: false, 77 | async text(): Promise { 78 | return v.message; 79 | }, 80 | } as any), 81 | ); 82 | const responseText = await response.text(); 83 | 84 | if (!response.ok) { 85 | let errorCode = ''; 86 | try { 87 | errorCode = JSON.parse(responseText).errorCode; 88 | } catch (err) {} 89 | 90 | throw new PayjoinEndpointError(errorCode); 91 | } 92 | 93 | return Psbt.fromBase64(responseText); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /ts_src/utils.ts: -------------------------------------------------------------------------------- 1 | import { payments, Psbt, Transaction, PsbtTxInput } from 'bitcoinjs-lib'; 2 | import { Bip32Derivation, PsbtInput } from 'bip174/src/lib/interfaces'; 3 | 4 | export enum ScriptPubKeyType { 5 | /// 6 | /// This type is reserved for scripts that are unsupported. 7 | /// 8 | Unsupported, 9 | /// 10 | /// Derive P2PKH addresses (P2PKH) 11 | /// Only use this for legacy code or coins not supporting segwit. 12 | /// 13 | Legacy, 14 | /// 15 | /// Derive Segwit (Bech32) addresses (P2WPKH) 16 | /// This will result in the cheapest fees. This is the recommended choice. 17 | /// 18 | Segwit, 19 | /// 20 | /// Derive P2SH address of a Segwit address (P2WPKH-P2SH) 21 | /// Use this when you worry that your users do not support Bech address format. 22 | /// 23 | SegwitP2SH, 24 | } 25 | 26 | export const SUPPORTED_WALLET_FORMATS = [ 27 | ScriptPubKeyType.Segwit, 28 | ScriptPubKeyType.SegwitP2SH, 29 | ]; 30 | 31 | export function getFee(feeRate: number, size: number): number { 32 | return feeRate * size; 33 | } 34 | 35 | export function checkSanity(psbt: Psbt): string[][] { 36 | const result: string[][] = []; 37 | psbt.data.inputs.forEach((value, index): void => { 38 | result[index] = checkInputSanity(value, psbt.txInputs[index]); 39 | }); 40 | return result; 41 | } 42 | 43 | function checkInputSanity(input: PsbtInput, txInput: PsbtTxInput): string[] { 44 | const errors: string[] = []; 45 | if (isFinalized(input)) { 46 | if (input.partialSig && input.partialSig.length > 0) { 47 | errors.push('Input finalized, but partial sigs are not empty'); 48 | } 49 | if (input.bip32Derivation && input.bip32Derivation.length > 0) { 50 | errors.push('Input finalized, but hd keypaths are not empty'); 51 | } 52 | if (input.sighashType !== undefined) { 53 | errors.push('Input finalized, but sighash type is not empty'); 54 | } 55 | if (input.redeemScript) { 56 | errors.push('Input finalized, but redeem script is not empty'); 57 | } 58 | if (input.witnessScript) { 59 | errors.push('Input finalized, but witness script is not empty'); 60 | } 61 | } 62 | if (input.witnessUtxo && input.nonWitnessUtxo) { 63 | errors.push('witness utxo and non witness utxo simultaneously present'); 64 | } 65 | 66 | if (input.witnessScript && !input.witnessUtxo) { 67 | errors.push('witness script present but no witness utxo'); 68 | } 69 | 70 | if (input.finalScriptWitness && !input.witnessUtxo) { 71 | errors.push('final witness script present but no witness utxo'); 72 | } 73 | 74 | if (input.nonWitnessUtxo) { 75 | const prevTx = Transaction.fromBuffer(input.nonWitnessUtxo); 76 | const prevOutTxId = prevTx.getHash(); 77 | let validOutpoint = true; 78 | 79 | if (!txInput.hash.equals(prevOutTxId)) { 80 | errors.push( 81 | 'non_witness_utxo does not match the transaction id referenced by the global transaction sign', 82 | ); 83 | validOutpoint = false; 84 | } 85 | if (txInput.index >= prevTx.outs.length) { 86 | errors.push( 87 | 'Global transaction referencing an out of bound output in non_witness_utxo', 88 | ); 89 | validOutpoint = false; 90 | } 91 | if (input.redeemScript && validOutpoint) { 92 | if ( 93 | !redeemScriptToScriptPubkey(input.redeemScript).equals( 94 | prevTx.outs[txInput.index].script, 95 | ) 96 | ) 97 | errors.push( 98 | 'The redeem_script is not coherent with the scriptPubKey of the non_witness_utxo', 99 | ); 100 | } 101 | } 102 | 103 | if (input.witnessUtxo) { 104 | if (input.redeemScript) { 105 | if ( 106 | !redeemScriptToScriptPubkey(input.redeemScript).equals( 107 | input.witnessUtxo.script, 108 | ) 109 | ) 110 | errors.push( 111 | 'The redeem_script is not coherent with the scriptPubKey of the witness_utxo', 112 | ); 113 | if ( 114 | input.witnessScript && 115 | input.redeemScript && 116 | !input.redeemScript.equals( 117 | witnessScriptToScriptPubkey(input.witnessScript), 118 | ) 119 | ) 120 | errors.push( 121 | 'witnessScript with witness UTXO does not match the redeemScript', 122 | ); 123 | } 124 | } 125 | 126 | return errors; 127 | } 128 | 129 | export function getInputsScriptPubKeyType(psbt: Psbt): ScriptPubKeyType { 130 | if ( 131 | psbt.data.inputs.filter((i): boolean => !i.witnessUtxo && !i.nonWitnessUtxo) 132 | .length > 0 133 | ) 134 | throw new Error( 135 | 'The psbt should be able to be finalized with utxo information', 136 | ); 137 | 138 | const types = new Set(); 139 | 140 | for (let i = 0; i < psbt.data.inputs.length; i++) { 141 | const type = psbt.getInputType(i); 142 | switch (type) { 143 | case 'witnesspubkeyhash': 144 | types.add(ScriptPubKeyType.Segwit); 145 | break; 146 | case 'p2sh-witnesspubkeyhash': 147 | types.add(ScriptPubKeyType.SegwitP2SH); 148 | break; 149 | case 'pubkeyhash': 150 | types.add(ScriptPubKeyType.Legacy); 151 | break; 152 | default: 153 | types.add(ScriptPubKeyType.Unsupported); 154 | } 155 | } 156 | 157 | if (types.size > 1) throw new Error('Inputs must all be the same type'); 158 | 159 | return types.values().next().value; 160 | } 161 | 162 | function redeemScriptToScriptPubkey(redeemScript: Buffer): Buffer { 163 | return payments.p2sh({ redeem: { output: redeemScript } }).output!; 164 | } 165 | 166 | function witnessScriptToScriptPubkey(witnessScript: Buffer): Buffer { 167 | return payments.p2wsh({ redeem: { output: witnessScript } }).output!; 168 | } 169 | 170 | export function hasKeypathInformationSet( 171 | items: { bip32Derivation?: Bip32Derivation[] }[], 172 | ): boolean { 173 | return ( 174 | items.filter( 175 | (value): boolean => 176 | !!value.bip32Derivation && value.bip32Derivation.length > 0, 177 | ).length > 0 178 | ); 179 | } 180 | 181 | export function isFinalized(input: PsbtInput): boolean { 182 | return ( 183 | input.finalScriptSig !== undefined || input.finalScriptWitness !== undefined 184 | ); 185 | } 186 | 187 | export function getInputIndex( 188 | psbt: Psbt, 189 | prevOutHash: Buffer, 190 | prevOutIndex: number, 191 | ): number { 192 | for (const [index, input] of psbt.txInputs.entries()) { 193 | if ( 194 | Buffer.compare(input.hash, prevOutHash) === 0 && 195 | input.index === prevOutIndex 196 | ) { 197 | return index; 198 | } 199 | } 200 | 201 | return -1; 202 | } 203 | -------------------------------------------------------------------------------- /ts_src/wallet.ts: -------------------------------------------------------------------------------- 1 | import { Psbt } from 'bitcoinjs-lib'; 2 | export interface IPayjoinClientWallet { 3 | /** 4 | * @async 5 | * This creates a fully signed, finalized, and valid Psbt. 6 | * 7 | * @return {Promise} The Original non-payjoin Psbt for submission to 8 | * the payjoin server. 9 | */ 10 | getPsbt(): Promise; 11 | /** 12 | * @async 13 | * This takes the payjoin Psbt and signs, and finalizes any un-finalized 14 | * inputs. Any checks against the payjoin proposal Psbt should be done here. 15 | * However, this library does perform some sanity checks. 16 | * 17 | * @param {Psbt} payjoinProposal - A Psbt proposal for the payjoin. It is 18 | * assumed that all inputs added by the server are signed and finalized. All 19 | * of the PayjoinClientWallet's inputs should be unsigned and unfinalized. 20 | * @return {Psbt} The signed and finalized payjoin proposal Psbt 21 | * for submission to the payjoin server. 22 | */ 23 | signPsbt(payjoinProposal: Psbt): Promise; 24 | /** 25 | * @async 26 | * This takes the fully signed and constructed payjoin transaction hex and 27 | * broadcasts it to the network. It returns true if succeeded and false if 28 | * broadcasting returned any errors. 29 | * 30 | * @param {string} txHex - A fully valid transaction hex string. 31 | * @return {string} Empty string ('') if succeeded, RPC error 32 | * message string etc. if failed. 33 | */ 34 | broadcastTx(txHex: string): Promise; 35 | /** 36 | * @async 37 | * This takes the original transaction (submitted to the payjoin server at 38 | * the beginning) and attempts to broadcast it X milliSeconds later. 39 | * Notably, this MUST NOT throw an error if the broadcast fails, and if 40 | * the broadcast succeeds it MUST be noted that something was wrong with 41 | * the payjoin transaction. 42 | * 43 | * @param {string} txHex - A fully valid transaction hex string. 44 | * @param {number} milliSeconds - The number of milliSeconds to wait until 45 | * attempting to broadcast 46 | * @return {void} This should return once the broadcast is scheduled 47 | * via setTimeout etc. (Do not wait until the broadcast occurs to return) 48 | */ 49 | scheduleBroadcastTx(txHex: string, milliSeconds: number): Promise; 50 | /** 51 | * @async 52 | * This accepts a script and optionally a BIP32 derivation path and returns a 53 | * boolean depending on whether or not the wallet owns this output script. 54 | * 55 | * @param {script} Buffer - An output script buffer. 56 | * @param {pathFromRoot} string - A BIP32 derivation path. 57 | * @return {boolean} A boolean depending on whether or not the wallet owns 58 | * this output script. 59 | */ 60 | isOwnOutputScript(script: Buffer, pathFromRoot?: string): Promise; 61 | } 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "outDir": "./src", 6 | "declaration": true, 7 | "rootDir": "./ts_src", 8 | "types": [ 9 | "node", 10 | "jest" 11 | ], 12 | "allowJs": false, 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "strictBindCallApply": true, 18 | "strictPropertyInitialization": true, 19 | "noImplicitThis": true, 20 | "alwaysStrict": true, 21 | "esModuleInterop": false, 22 | "resolveJsonModule": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "resolveJsonModule": true 26 | }, 27 | "include": [ 28 | "ts_src/**/*.ts" 29 | ], 30 | "exclude": [ 31 | "node_modules/**/*", 32 | "**/*.json" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "rules": { 5 | "arrow-parens": [true, "ban-single-arg-parens"], 6 | "curly": false, 7 | "indent": [ 8 | true, 9 | "spaces", 10 | 2 11 | ], 12 | "interface-name": [false], 13 | "match-default-export-name": true, 14 | "max-classes-per-file": [false], 15 | "member-access": [true, "no-public"], 16 | "no-bitwise": false, 17 | "no-console": false, 18 | "no-empty": [true, "allow-empty-catch"], 19 | "no-implicit-dependencies": false, 20 | "no-return-await": true, 21 | "no-var-requires": false, 22 | "no-unused-expression": false, 23 | "object-literal-sort-keys": false, 24 | "quotemark": [true, "single", "avoid-escape"], 25 | "typedef": [ 26 | true, 27 | "call-signature", 28 | "arrow-call-signature", 29 | "property-declaration" 30 | ], 31 | "variable-name": [ 32 | true, 33 | "ban-keywords", 34 | "check-format", 35 | "allow-leading-underscore", 36 | "allow-pascal-case" 37 | ] 38 | }, 39 | "rulesDirectory": [] 40 | } 41 | --------------------------------------------------------------------------------