Skip to content

Commit 04f8518

Browse files
feat(abstract-utxo): explain and verify tx with op return
TICKET: BTC-1582
1 parent 8b76b97 commit 04f8518

File tree

4 files changed

+91
-23
lines changed

4 files changed

+91
-23
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -449,16 +449,18 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
449449
}
450450

451451
preprocessBuildParams(params: Record<string, any>): Record<string, any> {
452-
params.recipients =
453-
params.recipients && params.recipients instanceof Array
454-
? params?.recipients?.map((recipient) => {
455-
if (recipient.address.startsWith(ScriptRecipientPrefix)) {
456-
const { address, ...rest } = recipient;
457-
return { ...rest, script: address.replace(ScriptRecipientPrefix, '') };
458-
}
459-
return recipient;
460-
})
461-
: params.recipients;
452+
if (params.recipients !== undefined) {
453+
params.recipients =
454+
params.recipients instanceof Array
455+
? params?.recipients?.map((recipient) => {
456+
if (recipient.address.startsWith(ScriptRecipientPrefix)) {
457+
const { address, ...rest } = recipient;
458+
return { ...rest, script: address.replace(ScriptRecipientPrefix, '') };
459+
}
460+
return recipient;
461+
})
462+
: params.recipients;
463+
}
462464

463465
return params;
464466
}
@@ -594,25 +596,47 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
594596
assert(txParams.rbfTxIds.length === 1);
595597

596598
const txToBeReplaced = await wallet.getTransaction({ txHash: txParams.rbfTxIds[0], includeRbf: true });
597-
expectedOutputs = txToBeReplaced.outputs
598-
.filter((output) => output.wallet !== wallet.id()) // For self-sends, the walletId will be the same as the wallet's id
599-
.map((output) => {
600-
return { amount: BigInt(output.valueString), address: this.canonicalAddress(output.address) };
601-
});
599+
expectedOutputs = txToBeReplaced.outputs.flatMap(
600+
(output: { valueString: string; address?: string; wallet?: string }) => {
601+
// For self-sends, the walletId will be the same as the wallet's id
602+
if (output.wallet === wallet.id()) {
603+
return [];
604+
}
605+
// In the case that this is an OP_RETURN output or another non-encodable scriptPubkey, we dont have an address.
606+
// We will verify that the amount is zero, and if it isnt then we will throw an error.
607+
if (!output.address) {
608+
if (output.valueString !== '0') {
609+
throw new Error(`Only zero amounts allowed for non-encodeable scriptPubkeys: ${JSON.stringify(output)}`);
610+
}
611+
return [{ amount: BigInt(0) }];
612+
}
613+
return [{ amount: BigInt(output.valueString), address: this.canonicalAddress(output.address) }];
614+
}
615+
);
602616
} else {
603617
// verify that each recipient from txParams has their own output
604-
expectedOutputs = _.get(txParams, 'recipients', [] as TransactionRecipient[]).map((output) => {
605-
return { ...output, address: this.canonicalAddress(output.address) };
618+
expectedOutputs = _.get(txParams, 'recipients', [] as TransactionRecipient[]).flatMap((output) => {
619+
if (output.address === undefined) {
620+
if (output.amount.toString() !== '0') {
621+
throw new Error(`Only zero amounts allowed for non-encodeable scriptPubkeys: ${output}`);
622+
}
623+
return [output];
624+
}
625+
return [{ ...output, address: this.canonicalAddress(output.address) }];
606626
});
607627
if (params.txParams.allowExternalChangeAddress && params.txParams.changeAddress) {
608628
// when an external change address is explicitly specified, count all outputs going towards that
609629
// address in the expected outputs (regardless of the output amount)
610630
expectedOutputs.push(
611-
...allOutputs
612-
.map((output) => {
613-
return { ...output, address: this.canonicalAddress(output.address) };
614-
})
615-
.filter((output) => output.address === this.canonicalAddress(params.txParams.changeAddress as string))
631+
...allOutputs.flatMap((output) => {
632+
if (
633+
output.address === undefined ||
634+
output.address !== this.canonicalAddress(params.txParams.changeAddress as string)
635+
) {
636+
return [];
637+
}
638+
return [{ ...output, address: this.canonicalAddress(output.address) }];
639+
})
616640
);
617641
}
618642
}

modules/abstract-utxo/src/parseOutput.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,15 @@ export async function parseOutput({
213213
const disableNetworking = !!verification.disableNetworking;
214214
const currentAddress = currentOutput.address;
215215

216+
if (currentAddress === undefined) {
217+
// In the case that the address is undefined, it means that the output has a non-encodeable scriptPubkey
218+
// If this is the case, then we need to check that the amount is 0 and we can skip the rest.
219+
if (currentOutput.amount.toString() !== '0') {
220+
throw new Error('output with undefined address must have amount of 0');
221+
}
222+
return currentOutput;
223+
}
224+
216225
// attempt to grab the address details from either the prebuilt tx, or the verification params.
217226
// If both of these are empty, then we will try to get the address details from bitgo instead
218227
const addressDetailsPrebuild = _.get(txPrebuild, `txInfo.walletAddressDetails.${currentAddress}`, {});

modules/abstract-utxo/src/transaction.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ import {
88
TransactionExplanation,
99
TransactionPrebuild,
1010
Output,
11+
ScriptRecipientPrefix,
1112
} from './abstractUtxoCoin';
1213
import { bip32, BIP32Interface, bitgo } from '@bitgo/utxo-lib';
1314

15+
// https://github.com/bitcoin/bitcoin/blob/5961b23898ee7c0af2626c46d5d70e80136578d3/src/script/script.h#L47
16+
const OP_RETURN_IDENTIFIER = Buffer.from('6a', 'hex');
17+
1418
/**
1519
* Get the inputs for a psbt from a prebuild.
1620
*/
@@ -106,7 +110,14 @@ function explainCommon<TNumber extends number | bigint>(
106110
const changeAddresses = changeInfo?.map((info) => info.address) ?? [];
107111

108112
tx.outs.forEach((currentOutput) => {
109-
const currentAddress = utxolib.address.fromOutputScript(currentOutput.script, network);
113+
// Try to encode the script pubkey with an address. If it fails, try to parse it as an OP_RETURN output with the prefix.
114+
// If that fails, then it is an unrecognized scriptPubkey and should fail
115+
let currentAddress: string;
116+
if (currentOutput.script.subarray(0, 1).equals(OP_RETURN_IDENTIFIER)) {
117+
currentAddress = `${ScriptRecipientPrefix}${currentOutput.script.toString('hex')}`;
118+
} else {
119+
currentAddress = utxolib.address.fromOutputScript(currentOutput.script, network);
120+
}
110121
const currentAmount = BigInt(currentOutput.value);
111122

112123
if (changeAddresses.includes(currentAddress)) {

modules/bitgo/test/v2/unit/wallet.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1859,6 +1859,30 @@ describe('V2 Wallet:', function () {
18591859
postProcessStub.restore();
18601860
});
18611861

1862+
it('should pass script outputs with the proper structure to wallet platform', async function () {
1863+
const script = '6a11223344556677889900';
1864+
nock(bgUrl)
1865+
.post(`/api/v2/${wallet.coin()}/wallet/${wallet.id()}/tx/build`, {
1866+
...tbtcHotWalletDefaultParams,
1867+
recipients: [{ script, amount: 1e6 }],
1868+
})
1869+
.query({})
1870+
.reply(200, {});
1871+
1872+
const blockHeight = 100;
1873+
const blockHeightStub = sinon.stub(basecoin, 'getLatestBlockHeight').resolves(blockHeight);
1874+
const postProcessStub = sinon.stub(basecoin, 'postProcessPrebuild').resolves({});
1875+
await wallet.prebuildTransaction({ recipients: [{ address: `scriptPubkey:${script}`, amount: 1e6 }] });
1876+
blockHeightStub.should.have.been.calledOnce();
1877+
postProcessStub.should.have.been.calledOnceWith({
1878+
blockHeight: 100,
1879+
wallet: wallet,
1880+
buildParams: { ...tbtcHotWalletDefaultParams, recipients: [{ script, amount: 1e6 }] },
1881+
});
1882+
blockHeightStub.restore();
1883+
postProcessStub.restore();
1884+
});
1885+
18621886
it('prebuild should call build and getLatestBlockHeight for utxo coins', async function () {
18631887
const params = {};
18641888
nock(bgUrl)

0 commit comments

Comments
 (0)