Andrew Camilleri
5 years ago
committed by
GitHub
13 changed files with 4235 additions and 845 deletions
@ -1,4 +1,3 @@ |
|||
node_modules |
|||
.nyc_output |
|||
coverage |
|||
.idea |
|||
package-lock.json |
|||
|
@ -1,19 +1,15 @@ |
|||
sudo: false |
|||
language: node_js |
|||
node_js: |
|||
- "lts/*" |
|||
- "9" |
|||
- "10" |
|||
- "13" |
|||
- "lts/*" |
|||
matrix: |
|||
include: |
|||
- node_js: "lts/*" |
|||
env: TEST_SUITE=format:ci |
|||
- node_js: "lts/*" |
|||
env: TEST_SUITE=gitdiff:ci |
|||
- node_js: "lts/*" |
|||
env: TEST_SUITE=lint |
|||
- node_js: "lts/*" |
|||
env: TEST_SUITE=coverage |
|||
env: |
|||
- TEST_SUITE=unit |
|||
script: npm run-script $TEST_SUITE |
|||
- TEST_SUITE=test |
|||
script: npm run $TEST_SUITE |
|||
|
@ -0,0 +1,29 @@ |
|||
{ |
|||
"moduleFileExtensions": [ |
|||
"ts", |
|||
"js", |
|||
"json" |
|||
], |
|||
"transform": { |
|||
"^.+\\.ts$": "ts-jest" |
|||
}, |
|||
"testRegex": "/ts_src/.*\\.spec\\.ts$", |
|||
"testURL": "http://localhost/", |
|||
"coverageThreshold": { |
|||
"global": { |
|||
"statements": 0, |
|||
"branches": 0, |
|||
"functions": 0, |
|||
"lines": 0 |
|||
} |
|||
}, |
|||
"collectCoverageFrom": [ |
|||
"ts_src/**/*.ts", |
|||
"!**/node_modules/**" |
|||
], |
|||
"coverageReporters": [ |
|||
"lcov", |
|||
"text" |
|||
], |
|||
"verbose": true |
|||
} |
File diff suppressed because it is too large
@ -1,5 +1,5 @@ |
|||
import { Psbt } from 'bitcoinjs-lib'; |
|||
declare type Nullable<T> = T | null; |
|||
export declare function requestPayjoinWithCustomRemoteCall(psbt: Psbt, remoteCall: (psbt: Psbt) => Promise<Nullable<Psbt>>): Promise<null | undefined>; |
|||
export declare function requestPayjoin(psbt: Psbt, payjoinEndpoint: string): Promise<null | undefined>; |
|||
export declare function requestPayjoinWithCustomRemoteCall(psbt: Psbt, remoteCall: (psbt: Psbt) => Promise<Nullable<Psbt>>): Promise<void>; |
|||
export declare function requestPayjoin(psbt: Psbt, payjoinEndpoint: string): Promise<void>; |
|||
export {}; |
@ -1,88 +1,231 @@ |
|||
"use strict"; |
|||
Object.defineProperty(exports, "__esModule", { value: true }); |
|||
const bitcoinjs_lib_1 = require("bitcoinjs-lib"); |
|||
const payments_1 = require("bitcoinjs-lib/types/payments"); |
|||
'use strict'; |
|||
Object.defineProperty(exports, '__esModule', { value: true }); |
|||
const bitcoinjs_lib_1 = require('bitcoinjs-lib'); |
|||
const bitcoinjs_lib_2 = require('bitcoinjs-lib'); |
|||
async function requestPayjoinWithCustomRemoteCall(psbt, remoteCall) { |
|||
const clonedPsbt = psbt.clone(); |
|||
clonedPsbt.finalizeAllInputs(); |
|||
// We make sure we don't send unnecessary information to the receiver
|
|||
for (let index = 0; index < clonedPsbt.inputCount; index++) { |
|||
clonedPsbt.clearFinalizedInput(index); |
|||
} |
|||
clonedPsbt.data.outputs.forEach(output => { |
|||
delete output.bip32Derivation; |
|||
const clonedPsbt = psbt.clone(); |
|||
clonedPsbt.finalizeAllInputs(); |
|||
// We make sure we don't send unnecessary information to the receiver
|
|||
for (let index = 0; index < clonedPsbt.inputCount; index++) { |
|||
clonedPsbt.clearFinalizedInput(index); |
|||
} |
|||
clonedPsbt.data.outputs.forEach((output) => { |
|||
delete output.bip32Derivation; |
|||
}); |
|||
delete clonedPsbt.data.globalMap.globalXpub; |
|||
const payjoinPsbt = await remoteCall(clonedPsbt); |
|||
if (!payjoinPsbt) throw new Error("We did not get the receiver's PSBT"); |
|||
// no inputs were added?
|
|||
if (clonedPsbt.inputCount <= payjoinPsbt.inputCount) { |
|||
throw new Error( |
|||
"There were less inputs than before in the receiver's PSBT", |
|||
); |
|||
} |
|||
if ( |
|||
payjoinPsbt.data.globalMap.globalXpub && |
|||
payjoinPsbt.data.globalMap.globalXpub.length > 0 |
|||
) { |
|||
throw new Error( |
|||
"GlobalXPubs should not be included in the receiver's PSBT", |
|||
); |
|||
} |
|||
if ( |
|||
hasKeypathInformationSet(payjoinPsbt.data.outputs) || |
|||
hasKeypathInformationSet(payjoinPsbt.data.inputs) |
|||
) { |
|||
throw new Error( |
|||
"Keypath information should not be included in the receiver's PSBT", |
|||
); |
|||
} |
|||
const sanityResult = checkSanity(payjoinPsbt); |
|||
if (Object.keys(sanityResult).length > 0) { |
|||
throw new Error( |
|||
`Receiver's PSBT is insane: ${JSON.stringify(sanityResult)}`, |
|||
); |
|||
} |
|||
// We make sure we don't sign what should not be signed
|
|||
for (let index = 0; index < payjoinPsbt.inputCount; index++) { |
|||
// check if input is Finalized
|
|||
if (isFinalized(payjoinPsbt.data.inputs[index])) |
|||
payjoinPsbt.clearFinalizedInput(index); |
|||
} |
|||
for (let index = 0; index < payjoinPsbt.data.outputs.length; index++) { |
|||
const output = payjoinPsbt.data.outputs[index]; |
|||
const outputLegacy = getGlobalTransaction(payjoinPsbt).outs[index]; |
|||
// Make sure only our output has any information
|
|||
delete output.bip32Derivation; |
|||
psbt.data.outputs.forEach((originalOutput) => { |
|||
// update the payjoin outputs
|
|||
if ( |
|||
outputLegacy.script.equals( |
|||
// TODO: what if output is P2SH or P2WSH or anything other than P2WPKH?
|
|||
// Can we assume output will contain redeemScript and witnessScript?
|
|||
// If so, we could decompile scriptPubkey, RS, and WS, and search for
|
|||
// the pubkey and its hash160.
|
|||
bitcoinjs_lib_2.payments.p2wpkh({ |
|||
pubkey: originalOutput.bip32Derivation[0].pubkey, |
|||
}).output, |
|||
) |
|||
) |
|||
payjoinPsbt.updateOutput(index, originalOutput); |
|||
}); |
|||
delete clonedPsbt.data.globalMap.globalXpub; |
|||
const payjoinPsbt = await remoteCall(clonedPsbt); |
|||
if (!payjoinPsbt) |
|||
return null; |
|||
// no inputs were added?
|
|||
if (clonedPsbt.inputCount <= payjoinPsbt.inputCount) { |
|||
return null; |
|||
} |
|||
// TODO: check payjoinPsbt.version == psbt.version
|
|||
// TODO: check payjoinPsbt.locktime == psbt.locktime
|
|||
// TODO: check payjoinPsbt.inputs where input belongs to us, that it is not finalized
|
|||
// TODO: check payjoinPsbt.inputs where input belongs to us, that it is was included in psbt.inputs
|
|||
// TODO: check payjoinPsbt.inputs where input belongs to us, that its sequence has not changed from that of psbt.inputs
|
|||
// TODO: check payjoinPsbt.inputs where input is new, that it is finalized
|
|||
// TODO: check payjoinPsbt.inputs where input is new, that it is the same type as all other inputs from psbt.inputs (all==P2WPKH || all = P2SH-P2WPKH)
|
|||
// TODO: check psbt.inputs that payjoinPsbt.inputs contains them all
|
|||
// TODO: check payjoinPsbt.inputs > psbt.inputs
|
|||
// TODO: check that if spend amount of payjoinPsbt > spend amount of psbt:
|
|||
// TODO: * check if the difference is due to adjusting fee to increase transaction size
|
|||
} |
|||
exports.requestPayjoinWithCustomRemoteCall = requestPayjoinWithCustomRemoteCall; |
|||
async function requestPayjoin(psbt, payjoinEndpoint) { |
|||
return requestPayjoinWithCustomRemoteCall(psbt, (psbt1) => |
|||
doRequest(psbt1, payjoinEndpoint), |
|||
); |
|||
} |
|||
exports.requestPayjoin = requestPayjoin; |
|||
function checkSanity(psbt) { |
|||
const result = {}; |
|||
psbt.data.inputs.forEach((value, index) => { |
|||
const sanityResult = checkInputSanity( |
|||
value, |
|||
getGlobalTransaction(psbt).ins[index], |
|||
); |
|||
if (sanityResult.length > 0) { |
|||
result[index] = sanityResult; |
|||
} |
|||
}); |
|||
return result; |
|||
} |
|||
function checkInputSanity(input, txInput) { |
|||
const errors = []; |
|||
if (isFinalized(input)) { |
|||
if (input.partialSig && input.partialSig.length > 0) { |
|||
errors.push('Input finalized, but partial sigs are not empty'); |
|||
} |
|||
if (input.bip32Derivation && input.bip32Derivation.length > 0) { |
|||
errors.push('Input finalized, but hd keypaths are not empty'); |
|||
} |
|||
if (input.sighashType) { |
|||
errors.push('Input finalized, but sighash type is not null'); |
|||
} |
|||
if (input.redeemScript) { |
|||
errors.push('Input finalized, but redeem script is not null'); |
|||
} |
|||
if (input.witnessScript) { |
|||
errors.push('Input finalized, but witness script is not null'); |
|||
} |
|||
// We make sure we don't sign things what should not be signed
|
|||
for (let index = 0; index < payjoinPsbt.inputCount; index++) { |
|||
// Is Finalized
|
|||
if (payjoinPsbt.data.inputs[index].finalScriptSig !== undefined || |
|||
payjoinPsbt.data.inputs[index].finalScriptWitness !== undefined) |
|||
payjoinPsbt.clearFinalizedInput(index); |
|||
} |
|||
if (input.witnessUtxo && input.nonWitnessUtxo) { |
|||
errors.push('witness utxo and non witness utxo simultaneously present'); |
|||
} |
|||
if (input.witnessScript && !input.witnessUtxo) { |
|||
errors.push('witness script present but no witness utxo'); |
|||
} |
|||
if (!input.finalScriptWitness && !input.witnessUtxo) { |
|||
errors.push('final witness script present but no witness utxo'); |
|||
} |
|||
if (input.nonWitnessUtxo) { |
|||
// TODO: get hash
|
|||
const prevOutTxId = input.nonWitnessUtxo; |
|||
let validOutpoint = true; |
|||
if (txInput.hash !== prevOutTxId) { |
|||
errors.push( |
|||
'non_witness_utxo does not match the transaction id referenced by the global transaction sign', |
|||
); |
|||
validOutpoint = false; |
|||
} |
|||
for (let index = 0; index < payjoinPsbt.data.outputs.length; index++) { |
|||
const output = payjoinPsbt.data.outputs[index]; |
|||
// TODO: bitcoinjs-lib to expose outputs to Psbt class
|
|||
// instead of using private (JS has no private) attributes
|
|||
// @ts-ignore
|
|||
if (txInput.index >= input.nonWitnessUtxo.Outputs.length) { |
|||
errors.push( |
|||
'Global transaction referencing an out of bound output in non_witness_utxo', |
|||
); |
|||
validOutpoint = false; |
|||
} |
|||
if (input.redeemScript && validOutpoint) { |
|||
if ( |
|||
// @ts-ignore
|
|||
input.redeemScript.Hash.ScriptPubKey !== |
|||
// @ts-ignore
|
|||
input.nonWitnessUtxo.Outputs[txInput.index].ScriptPubKey |
|||
) |
|||
errors.push( |
|||
'The redeem_script is not coherent with the scriptPubKey of the non_witness_utxo', |
|||
); |
|||
} |
|||
} |
|||
if (input.witnessUtxo) { |
|||
if (input.redeemScript) { |
|||
if ( |
|||
// @ts-ignore
|
|||
input.redeemScript.Hash.ScriptPubKey !== input.witnessUtxo.ScriptPubKey |
|||
) |
|||
errors.push( |
|||
'The redeem_script is not coherent with the scriptPubKey of the witness_utxo', |
|||
); |
|||
if ( |
|||
input.witnessScript && |
|||
input.redeemScript && |
|||
// @ts-ignore
|
|||
const outputLegacy = payjoinPsbt.__CACHE.__TX.outs[index]; |
|||
// Make sure only the only our output have any information
|
|||
delete output.bip32Derivation; |
|||
psbt.data.outputs.forEach(originalOutput => { |
|||
// update the payjoin outputs
|
|||
if (outputLegacy.script.equals( |
|||
// TODO: what if output is P2SH or P2WSH or anything other than P2WPKH?
|
|||
// Can we assume output will contain redeemScript and witnessScript?
|
|||
// If so, we could decompile scriptPubkey, RS, and WS, and search for
|
|||
// the pubkey and its hash160.
|
|||
payments_1.p2wpkh({ |
|||
pubkey: originalOutput.bip32Derivation.pubkey, |
|||
}).output)) |
|||
payjoinPsbt.updateOutput(index, originalOutput); |
|||
}); |
|||
PayToWitScriptHashTemplate.Instance.ExtractScriptPubKeyParameters( |
|||
input.redeemScript, |
|||
) !== input.witnessScript.WitHash |
|||
) |
|||
errors.push( |
|||
'witnessScript with witness UTXO does not match the redeemScript', |
|||
); |
|||
} |
|||
// TODO: check payjoinPsbt.version == psbt.version
|
|||
// TODO: check payjoinPsbt.locktime == psbt.locktime
|
|||
// TODO: check payjoinPsbt.inputs where input belongs to us, that it is not finalized
|
|||
// TODO: check payjoinPsbt.inputs where input belongs to us, that it is was included in psbt.inputs
|
|||
// TODO: check payjoinPsbt.inputs where input belongs to us, that its sequence has not changed from that of psbt.inputs
|
|||
// TODO: check payjoinPsbt.inputs where input is new, that it is finalized
|
|||
// TODO: check payjoinPsbt.inputs where input is new, that it is the same type as all other inputs from psbt.inputs (all==P2WPKH || all = P2SH-P2WPKH)
|
|||
// TODO: check psbt.inputs that payjoinPsbt.inputs contains them all
|
|||
// TODO: check payjoinPsbt.inputs > psbt.inputs
|
|||
// TODO: check that if spend amount of payjoinPsbt > spend amount of psbt:
|
|||
// TODO: * check if the difference is due to adjusting fee to increase transaction size
|
|||
} |
|||
// figure out how to port this lofic
|
|||
// if (input.witnessUtxo.ScriptPubKey is Script s)
|
|||
// {
|
|||
//
|
|||
// if (!s.IsScriptType(ScriptType.P2SH) && !s.IsScriptType(ScriptType.Witness))
|
|||
// errors.push('A Witness UTXO is provided for a non-witness input');
|
|||
// if (s.IsScriptType(ScriptType.P2SH) && redeem_script is Script r && !r.IsScriptType(ScriptType.Witness))
|
|||
// errors.push('A Witness UTXO is provided for a non-witness input');
|
|||
// }
|
|||
return errors; |
|||
} |
|||
exports.requestPayjoinWithCustomRemoteCall = requestPayjoinWithCustomRemoteCall; |
|||
function requestPayjoin(psbt, payjoinEndpoint) { |
|||
return requestPayjoinWithCustomRemoteCall(psbt, psbt1 => doRequest(psbt1, payjoinEndpoint)); |
|||
function hasKeypathInformationSet(items) { |
|||
return ( |
|||
items.filter( |
|||
(value) => !!value.bip32Derivation && value.bip32Derivation.length > 0, |
|||
).length > 0 |
|||
); |
|||
} |
|||
function isFinalized(input) { |
|||
return ( |
|||
input.finalScriptSig !== undefined || input.finalScriptWitness !== undefined |
|||
); |
|||
} |
|||
function getGlobalTransaction(psbt) { |
|||
// TODO: bitcoinjs-lib to expose outputs to Psbt class
|
|||
// instead of using private (JS has no private) attributes
|
|||
// @ts-ignore
|
|||
return psbt.__CACHE.__TX; |
|||
} |
|||
exports.requestPayjoin = requestPayjoin; |
|||
function doRequest(psbt, payjoinEndpoint) { |
|||
return new Promise((resolve, reject) => { |
|||
if (!psbt) { |
|||
reject(); |
|||
} |
|||
const xhr = new XMLHttpRequest(); |
|||
xhr.onreadystatechange = () => { |
|||
if (xhr.readyState !== 4) |
|||
return; |
|||
if (xhr.status >= 200 && xhr.status < 300) { |
|||
resolve(bitcoinjs_lib_1.Psbt.fromHex(xhr.responseText)); |
|||
} |
|||
else { |
|||
reject(xhr.responseText); |
|||
} |
|||
}; |
|||
xhr.setRequestHeader('Content-Type', 'text/plain'); |
|||
xhr.open('POST', payjoinEndpoint); |
|||
xhr.send(psbt.toHex()); |
|||
}); |
|||
return new Promise((resolve, reject) => { |
|||
if (!psbt) { |
|||
reject(); |
|||
} |
|||
const xhr = new XMLHttpRequest(); |
|||
xhr.onreadystatechange = () => { |
|||
if (xhr.readyState !== 4) return; |
|||
if (xhr.status >= 200 && xhr.status < 300) { |
|||
resolve(bitcoinjs_lib_1.Psbt.fromHex(xhr.responseText)); |
|||
} else { |
|||
reject(xhr.responseText); |
|||
} |
|||
}; |
|||
xhr.setRequestHeader('Content-Type', 'text/plain'); |
|||
xhr.open('POST', payjoinEndpoint); |
|||
xhr.send(psbt.toHex()); |
|||
}); |
|||
} |
|||
|
@ -0,0 +1,10 @@ |
|||
import { requestPayjoin, requestPayjoinWithCustomRemoteCall } from './index'; |
|||
|
|||
describe('requestPayjoin', () => { |
|||
it('should exist', () => { |
|||
expect(requestPayjoin).toBeDefined(); |
|||
expect(typeof requestPayjoin).toBe('function'); |
|||
expect(requestPayjoinWithCustomRemoteCall).toBeDefined(); |
|||
expect(typeof requestPayjoinWithCustomRemoteCall).toBe('function'); |
|||
}); |
|||
}); |
Loading…
Reference in new issue