You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
279 lines
8.8 KiB
279 lines
8.8 KiB
5 years ago
|
import { Psbt, Transaction } from 'bitcoinjs-lib';
|
||
5 years ago
|
import { payments } from 'bitcoinjs-lib';
|
||
|
import {
|
||
|
Bip32Derivation,
|
||
|
GlobalXpub,
|
||
|
PsbtInput,
|
||
|
} from 'bip174/src/lib/interfaces';
|
||
5 years ago
|
|
||
|
type Nullable<T> = T | null;
|
||
5 years ago
|
interface TxInput {
|
||
|
hash: Buffer;
|
||
|
index: number;
|
||
|
script: Buffer;
|
||
|
sequence: number;
|
||
|
witness: Buffer[];
|
||
|
}
|
||
5 years ago
|
|
||
5 years ago
|
export async function requestPayjoinWithCustomRemoteCall(
|
||
|
psbt: Psbt,
|
||
|
remoteCall: (psbt: Psbt) => Promise<Nullable<Psbt>>,
|
||
|
): Promise<void> {
|
||
5 years ago
|
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);
|
||
|
}
|
||
5 years ago
|
clonedPsbt.data.outputs.forEach((output): void => {
|
||
5 years ago
|
delete output.bip32Derivation;
|
||
|
});
|
||
|
delete clonedPsbt.data.globalMap.globalXpub;
|
||
|
|
||
|
const payjoinPsbt = await remoteCall(clonedPsbt);
|
||
5 years ago
|
if (!payjoinPsbt) throw new Error("We did not get the receiver's PSBT");
|
||
5 years ago
|
|
||
5 years ago
|
// no inputs were added?
|
||
|
if (clonedPsbt.inputCount <= payjoinPsbt.inputCount) {
|
||
5 years ago
|
throw new Error(
|
||
|
"There were less inputs than before in the receiver's PSBT",
|
||
|
);
|
||
5 years ago
|
}
|
||
|
|
||
5 years ago
|
if (
|
||
|
payjoinPsbt.data.globalMap.globalXpub &&
|
||
|
(payjoinPsbt.data.globalMap.globalXpub as GlobalXpub[]).length > 0
|
||
|
) {
|
||
|
throw new Error(
|
||
|
"GlobalXPubs should not be included in the receiver's PSBT",
|
||
|
);
|
||
5 years ago
|
}
|
||
5 years ago
|
if (
|
||
|
hasKeypathInformationSet(payjoinPsbt.data.outputs) ||
|
||
|
hasKeypathInformationSet(payjoinPsbt.data.inputs)
|
||
|
) {
|
||
|
throw new Error(
|
||
|
"Keypath information should not be included in the receiver's PSBT",
|
||
|
);
|
||
5 years ago
|
}
|
||
5 years ago
|
|
||
5 years ago
|
const sanityResult = checkSanity(payjoinPsbt);
|
||
5 years ago
|
if (Object.keys(sanityResult).length > 0) {
|
||
|
throw new Error(
|
||
|
`Receiver's PSBT is insane: ${JSON.stringify(sanityResult)}`,
|
||
|
);
|
||
5 years ago
|
}
|
||
5 years ago
|
|
||
5 years ago
|
// We make sure we don't sign what should not be signed
|
||
5 years ago
|
for (let index = 0; index < payjoinPsbt.inputCount; index++) {
|
||
5 years ago
|
// check if input is Finalized
|
||
5 years ago
|
if (isFinalized(payjoinPsbt.data.inputs[index]))
|
||
5 years ago
|
payjoinPsbt.clearFinalizedInput(index);
|
||
|
}
|
||
5 years ago
|
|
||
5 years ago
|
for (let index = 0; index < payjoinPsbt.data.outputs.length; index++) {
|
||
|
const output = payjoinPsbt.data.outputs[index];
|
||
|
const outputLegacy = getGlobalTransaction(payjoinPsbt).outs[index];
|
||
5 years ago
|
// Make sure only our output has any information
|
||
5 years ago
|
delete output.bip32Derivation;
|
||
5 years ago
|
psbt.data.outputs.forEach((originalOutput): void => {
|
||
5 years ago
|
// 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.
|
||
5 years ago
|
payments.p2wpkh({
|
||
|
pubkey: originalOutput.bip32Derivation![0].pubkey,
|
||
|
}).output!,
|
||
5 years ago
|
)
|
||
|
)
|
||
|
payjoinPsbt.updateOutput(index, originalOutput);
|
||
|
});
|
||
|
}
|
||
|
// 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
|
||
|
}
|
||
|
|
||
5 years ago
|
export async function requestPayjoin(
|
||
|
psbt: Psbt,
|
||
|
payjoinEndpoint: string,
|
||
|
): Promise<void> {
|
||
|
return requestPayjoinWithCustomRemoteCall(
|
||
|
psbt,
|
||
|
(psbt1): Promise<Nullable<Psbt>> => doRequest(psbt1, payjoinEndpoint),
|
||
|
);
|
||
5 years ago
|
}
|
||
|
|
||
5 years ago
|
function checkSanity(psbt: Psbt): { [index: number]: string[] } {
|
||
|
const result: { [index: number]: string[] } = {};
|
||
5 years ago
|
psbt.data.inputs.forEach((value, index): void => {
|
||
|
const sanityResult = checkInputSanity(
|
||
|
value,
|
||
|
getGlobalTransaction(psbt).ins[index],
|
||
|
);
|
||
5 years ago
|
if (sanityResult.length > 0) {
|
||
|
result[index] = sanityResult;
|
||
|
}
|
||
|
});
|
||
|
return result;
|
||
5 years ago
|
}
|
||
|
|
||
5 years ago
|
function checkInputSanity(input: PsbtInput, txInput: TxInput): string[] {
|
||
5 years ago
|
const errors: string[] = [];
|
||
|
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');
|
||
|
}
|
||
|
}
|
||
|
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');
|
||
|
}
|
||
|
|
||
5 years ago
|
if (!input.finalScriptWitness && !input.witnessUtxo) {
|
||
5 years ago
|
errors.push('final witness script present but no witness utxo');
|
||
|
}
|
||
|
|
||
5 years ago
|
if (input.nonWitnessUtxo) {
|
||
5 years ago
|
// TODO: get hash
|
||
5 years ago
|
const prevOutTxId = input.nonWitnessUtxo;
|
||
|
let validOutpoint = true;
|
||
|
|
||
5 years ago
|
if (txInput.hash !== prevOutTxId) {
|
||
|
errors.push(
|
||
|
'non_witness_utxo does not match the transaction id referenced by the global transaction sign',
|
||
|
);
|
||
5 years ago
|
validOutpoint = false;
|
||
|
}
|
||
5 years ago
|
// @ts-ignore
|
||
5 years ago
|
if (txInput.index >= input.nonWitnessUtxo.Outputs.length) {
|
||
5 years ago
|
errors.push(
|
||
|
'Global transaction referencing an out of bound output in non_witness_utxo',
|
||
|
);
|
||
5 years ago
|
validOutpoint = false;
|
||
|
}
|
||
|
if (input.redeemScript && validOutpoint) {
|
||
5 years ago
|
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',
|
||
|
);
|
||
5 years ago
|
}
|
||
|
}
|
||
|
|
||
5 years ago
|
if (input.witnessUtxo) {
|
||
|
if (input.redeemScript) {
|
||
5 years ago
|
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 &&
|
||
5 years ago
|
input.redeemScript &&
|
||
5 years ago
|
// @ts-ignore
|
||
|
PayToWitScriptHashTemplate.Instance.ExtractScriptPubKeyParameters(
|
||
|
input.redeemScript,
|
||
|
// @ts-ignore
|
||
|
) !== input.witnessScript.WitHash
|
||
|
)
|
||
|
errors.push(
|
||
|
'witnessScript with witness UTXO does not match the redeemScript',
|
||
|
);
|
||
5 years ago
|
}
|
||
|
}
|
||
|
|
||
5 years ago
|
// figure out how to port this lofic
|
||
5 years ago
|
// 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');
|
||
|
// }
|
||
5 years ago
|
|
||
5 years ago
|
return errors;
|
||
5 years ago
|
}
|
||
|
|
||
5 years ago
|
function hasKeypathInformationSet(
|
||
|
items: { bip32Derivation?: Bip32Derivation[] }[],
|
||
|
): boolean {
|
||
|
return (
|
||
|
items.filter(
|
||
|
(value): boolean =>
|
||
|
!!value.bip32Derivation && value.bip32Derivation.length > 0,
|
||
|
).length > 0
|
||
|
);
|
||
5 years ago
|
}
|
||
|
|
||
5 years ago
|
function isFinalized(input: PsbtInput): boolean {
|
||
|
return (
|
||
|
input.finalScriptSig !== undefined || input.finalScriptWitness !== undefined
|
||
|
);
|
||
5 years ago
|
}
|
||
|
|
||
|
function getGlobalTransaction(psbt: Psbt): Transaction {
|
||
5 years ago
|
// TODO: bitcoinjs-lib to expose outputs to Psbt class
|
||
|
// instead of using private (JS has no private) attributes
|
||
|
// @ts-ignore
|
||
|
return psbt.__CACHE.__TX;
|
||
|
}
|
||
|
|
||
5 years ago
|
function doRequest(
|
||
|
psbt: Psbt,
|
||
|
payjoinEndpoint: string,
|
||
|
): Promise<Nullable<Psbt>> {
|
||
|
return new Promise<Nullable<Psbt>>((resolve, reject): void => {
|
||
5 years ago
|
if (!psbt) {
|
||
|
reject();
|
||
|
}
|
||
|
|
||
|
const xhr = new XMLHttpRequest();
|
||
5 years ago
|
xhr.onreadystatechange = (): void => {
|
||
5 years ago
|
if (xhr.readyState !== 4) return;
|
||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||
|
resolve(Psbt.fromHex(xhr.responseText));
|
||
|
} else {
|
||
|
reject(xhr.responseText);
|
||
|
}
|
||
|
};
|
||
|
xhr.setRequestHeader('Content-Type', 'text/plain');
|
||
|
xhr.open('POST', payjoinEndpoint);
|
||
|
xhr.send(psbt.toHex());
|
||
|
});
|
||
|
}
|