Skip to content

Commit 518a50d

Browse files
Merge pull request #5109 from BitGo/BTC-1582-script-in-address
Handle a transaction with an OP_RETURN
2 parents c5262dd + dcac55e commit 518a50d

File tree

7 files changed

+161
-23
lines changed

7 files changed

+161
-23
lines changed

modules/abstract-utxo/src/abstractUtxoCoin.ts

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,15 @@ import ScriptType2Of3 = utxolib.bitgo.outputScripts.ScriptType2Of3;
7676
import { isReplayProtectionUnspent } from './replayProtection';
7777
import { signAndVerifyPsbt, signAndVerifyWalletTransaction } from './sign';
7878
import { supportedCrossChainRecoveries } from './config';
79-
import { explainPsbt, explainTx, getPsbtTxInputs, getTxInputs } from './transaction';
79+
import {
80+
assertValidTransactionRecipient,
81+
explainPsbt,
82+
explainTx,
83+
fromExtendedAddressFormat,
84+
getPsbtTxInputs,
85+
getTxInputs,
86+
isExtendedAddressFormat,
87+
} from './transaction';
8088
import { assertDescriptorWalletAddress } from './descriptor/assertDescriptorWalletAddress';
8189

8290
type UtxoCustomSigningFunction<TNumber extends number | bigint> = {
@@ -447,6 +455,20 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
447455
}
448456
}
449457

458+
preprocessBuildParams(params: Record<string, any>): Record<string, any> {
459+
if (params.recipients !== undefined) {
460+
params.recipients =
461+
params.recipients instanceof Array
462+
? params?.recipients?.map((recipient) => {
463+
const { address, ...rest } = recipient;
464+
return { ...rest, ...fromExtendedAddressFormat(address) };
465+
})
466+
: params.recipients;
467+
}
468+
469+
return params;
470+
}
471+
450472
/**
451473
* Get the latest block height
452474
* @param reqId
@@ -459,6 +481,13 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
459481
return (chainhead as any).height;
460482
}
461483

484+
checkRecipient(recipient: { address: string; amount: number | string }): void {
485+
assertValidTransactionRecipient(recipient);
486+
if (!isExtendedAddressFormat(recipient.address)) {
487+
super.checkRecipient(recipient);
488+
}
489+
}
490+
462491
/**
463492
* Run custom coin logic after a transaction prebuild has been received from BitGo
464493
* @param prebuild
@@ -511,6 +540,18 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
511540
return utxolib.bitgo.createTransactionFromHex<TNumber>(hex, this.network, this.amountType);
512541
}
513542

543+
toCanonicalTransactionRecipient(output: { valueString: string; address?: string }): {
544+
amount: bigint;
545+
address?: string;
546+
} {
547+
const amount = BigInt(output.valueString);
548+
assertValidTransactionRecipient({ amount, address: output.address });
549+
if (!output.address) {
550+
return { amount };
551+
}
552+
return { amount, address: this.canonicalAddress(output.address) };
553+
}
554+
514555
/**
515556
* Extract and fill transaction details such as internal/change spend, external spend (explicit vs. implicit), etc.
516557
* @param params
@@ -567,25 +608,39 @@ export abstract class AbstractUtxoCoin extends BaseCoin {
567608
assert(txParams.rbfTxIds.length === 1);
568609

569610
const txToBeReplaced = await wallet.getTransaction({ txHash: txParams.rbfTxIds[0], includeRbf: true });
570-
expectedOutputs = txToBeReplaced.outputs
571-
.filter((output) => output.wallet !== wallet.id()) // For self-sends, the walletId will be the same as the wallet's id
572-
.map((output) => {
573-
return { amount: BigInt(output.valueString), address: this.canonicalAddress(output.address) };
574-
});
611+
expectedOutputs = txToBeReplaced.outputs.flatMap(
612+
(output: { valueString: string; address?: string; wallet?: string }) => {
613+
// For self-sends, the walletId will be the same as the wallet's id
614+
if (output.wallet === wallet.id()) {
615+
return [];
616+
}
617+
return [this.toCanonicalTransactionRecipient(output)];
618+
}
619+
);
575620
} else {
576621
// verify that each recipient from txParams has their own output
577-
expectedOutputs = _.get(txParams, 'recipients', [] as TransactionRecipient[]).map((output) => {
578-
return { ...output, address: this.canonicalAddress(output.address) };
622+
expectedOutputs = _.get(txParams, 'recipients', [] as TransactionRecipient[]).flatMap((output) => {
623+
if (output.address === undefined) {
624+
if (output.amount.toString() !== '0') {
625+
throw new Error(`Only zero amounts allowed for non-encodeable scriptPubkeys: ${output}`);
626+
}
627+
return [output];
628+
}
629+
return [{ ...output, address: this.canonicalAddress(output.address) }];
579630
});
580631
if (params.txParams.allowExternalChangeAddress && params.txParams.changeAddress) {
581632
// when an external change address is explicitly specified, count all outputs going towards that
582633
// address in the expected outputs (regardless of the output amount)
583634
expectedOutputs.push(
584-
...allOutputs
585-
.map((output) => {
586-
return { ...output, address: this.canonicalAddress(output.address) };
587-
})
588-
.filter((output) => output.address === this.canonicalAddress(params.txParams.changeAddress as string))
635+
...allOutputs.flatMap((output) => {
636+
if (
637+
output.address === undefined ||
638+
output.address !== this.canonicalAddress(params.txParams.changeAddress as string)
639+
) {
640+
return [];
641+
}
642+
return [{ ...output, address: this.canonicalAddress(output.address) }];
643+
})
589644
);
590645
}
591646
}

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: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,40 @@ import {
1111
} from './abstractUtxoCoin';
1212
import { bip32, BIP32Interface, bitgo } from '@bitgo/utxo-lib';
1313

14+
const ScriptRecipientPrefix = 'scriptPubkey:';
15+
16+
/**
17+
* An extended address is one that encodes either a regular address or a hex encoded script with the prefix `scriptPubkey:`.
18+
* This function converts the extended address format to either a script or an address.
19+
* @param extendedAddress
20+
*/
21+
export function fromExtendedAddressFormat(extendedAddress: string): { address: string } | { script: string } {
22+
if (extendedAddress.startsWith(ScriptRecipientPrefix)) {
23+
return { script: extendedAddress.replace(ScriptRecipientPrefix, '') };
24+
}
25+
return { address: extendedAddress };
26+
}
27+
28+
export function toExtendedAddressFormat(script: Buffer, network: utxolib.Network): string {
29+
return script[0] === utxolib.opcodes.OP_RETURN
30+
? `${ScriptRecipientPrefix}${script.toString('hex')}`
31+
: utxolib.address.fromOutputScript(script, network);
32+
}
33+
34+
export function isExtendedAddressFormat(address: string): boolean {
35+
return address.startsWith(ScriptRecipientPrefix);
36+
}
37+
38+
export function assertValidTransactionRecipient(output: { amount: bigint | number | string; address?: string }): void {
39+
// In the case that this is an OP_RETURN output or another non-encodable scriptPubkey, we dont have an address.
40+
// We will verify that the amount is zero, and if it isnt then we will throw an error.
41+
if (!output.address || output.address.startsWith(ScriptRecipientPrefix)) {
42+
if (output.amount.toString() !== '0') {
43+
throw new Error(`Only zero amounts allowed for non-encodeable scriptPubkeys: ${JSON.stringify(output)}`);
44+
}
45+
}
46+
}
47+
1448
/**
1549
* Get the inputs for a psbt from a prebuild.
1650
*/
@@ -106,7 +140,9 @@ function explainCommon<TNumber extends number | bigint>(
106140
const changeAddresses = changeInfo?.map((info) => info.address) ?? [];
107141

108142
tx.outs.forEach((currentOutput) => {
109-
const currentAddress = utxolib.address.fromOutputScript(currentOutput.script, network);
143+
// Try to encode the script pubkey with an address. If it fails, try to parse it as an OP_RETURN output with the prefix.
144+
// If that fails, then it is an unrecognized scriptPubkey and should fail
145+
const currentAddress = toExtendedAddressFormat(currentOutput.script, network);
110146
const currentAmount = BigInt(currentOutput.value);
111147

112148
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)

modules/sdk-core/src/bitgo/baseCoin/baseCoin.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,16 @@ export abstract class BaseCoin implements IBaseCoin {
225225
return bigNumber.toFormat(null as any, null as any, { groupSeparator: '', decimalSeparator: '.' });
226226
}
227227

228+
checkRecipient(recipient: { address: string; amount: string | number }): void {
229+
const amount = new BigNumber(recipient.amount);
230+
if (amount.isNegative()) {
231+
throw new Error('invalid argument for amount - positive number greater than zero or numeric string expected');
232+
}
233+
if (!this.valuelessTransferAllowed() && amount.isZero()) {
234+
throw new Error('invalid argument for amount - positive number greater than zero or numeric string expected');
235+
}
236+
}
237+
228238
/**
229239
* Convert a currency amount represented in big units (btc, eth, xrp, xlm)
230240
* to base units (satoshi, wei, atoms, drops, stroops)
@@ -239,6 +249,14 @@ export abstract class BaseCoin implements IBaseCoin {
239249
return bigNumber.toFixed(0);
240250
}
241251

252+
/**
253+
* Preprocess the build parameters before sending to the API
254+
* @param buildParams
255+
*/
256+
preprocessBuildParams(buildParams: Record<string, any>): Record<string, any> {
257+
return buildParams;
258+
}
259+
242260
/**
243261
* Sign message with private key
244262
*

modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ export interface IBaseCoin {
473473
getFamily(): string;
474474
getFullName(): string;
475475
valuelessTransferAllowed(): boolean;
476+
checkRecipient(recipient: { address: string; amount: string | number }): void;
476477
sweepWithSendMany(): boolean;
477478
transactionDataAllowed(): boolean;
478479
allowsAccountConsolidations(): boolean;
@@ -484,6 +485,7 @@ export interface IBaseCoin {
484485
getBaseFactor(): number | string;
485486
baseUnitsToBigUnits(baseUnits: string | number): string;
486487
bigUnitsToBaseUnits(bigUnits: string | number): string;
488+
preprocessBuildParams(params: Record<string, any>): Record<string, any>;
487489
signMessage(key: { prv: string }, message: string): Promise<Buffer>;
488490
createKeySignatures(
489491
prv: string,

modules/sdk-core/src/bitgo/wallet/wallet.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1747,7 +1747,7 @@ export class Wallet implements IWallet {
17471747
}
17481748

17491749
// Whitelist params to build tx
1750-
const whitelistedParams = _.pick(params, this.prebuildWhitelistedParams());
1750+
const whitelistedParams = this.baseCoin.preprocessBuildParams(_.pick(params, this.prebuildWhitelistedParams()));
17511751
debug('prebuilding transaction: %O', whitelistedParams);
17521752

17531753
if (params.reqId) {
@@ -2484,13 +2484,7 @@ export class Wallet implements IWallet {
24842484
const coin = this.baseCoin;
24852485
if (_.isObject(params.recipients)) {
24862486
params.recipients.map(function (recipient) {
2487-
const amount = new BigNumber(recipient.amount);
2488-
if (amount.isNegative()) {
2489-
throw new Error('invalid argument for amount - positive number greater than zero or numeric string expected');
2490-
}
2491-
if (!coin.valuelessTransferAllowed() && amount.isZero()) {
2492-
throw new Error('invalid argument for amount - positive number greater than zero or numeric string expected');
2493-
}
2487+
coin.checkRecipient(recipient);
24942488
});
24952489
}
24962490

@@ -3661,7 +3655,7 @@ export class Wallet implements IWallet {
36613655
// extract the whitelisted params from the top level, in case
36623656
// other invalid params are present that would fail encoding
36633657
// and fall back to the body params
3664-
const whitelistedParams = _.pick(params, whitelistedSendParams);
3658+
const whitelistedParams = this.baseCoin.preprocessBuildParams(_.pick(params, whitelistedSendParams));
36653659
const reqTracer = reqId || new RequestTracer();
36663660
this.bitgo.setRequestTracer(reqTracer);
36673661
return postWithCodec(

0 commit comments

Comments
 (0)