Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/firo-pushdata1-op-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rosen-bridge/rosen-extractor': patch
---

Fix Firo OP_RETURN parsing for OP_PUSHDATA1 payloads.
81 changes: 65 additions & 16 deletions packages/rosen-extractor/lib/getRosenData/firo/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { address } from 'bitcoinjs-lib';
import { MinimalOnChainRosenData } from '../../types';
import { parseRosenData } from '../../utils';

const OP_RETURN = 0x6a;
const OP_PUSHDATA1 = 0x4c;
const OP_PUSHDATA2 = 0x4d;
const OP_PUSHDATA4 = 0x4e;
const MAX_OP_RETURN_DATA_BYTES = 80;

const firoNetwork = {
// Firo network parameters
messagePrefix: '\x19Firo Signed Message:\n',
Expand All @@ -16,6 +22,64 @@ const firoNetwork = {
wif: 0xd2,
};

/**
* Extracts Rosen OP_RETURN payload from a Firo scriptPubKey.
*
* Supports direct push and OP_PUSHDATA1/2/4 encodings while enforcing Rosen's
* 80-byte OP_RETURN payload limit.
*
* @param scriptPubKeyHex Firo scriptPubKey hex
* @returns OP_RETURN payload hex
*/
const parseOpReturnData = (scriptPubKeyHex: string): string => {
Comment thread
RaaCT0R marked this conversation as resolved.
if (scriptPubKeyHex.length % 2 !== 0) throw Error(`script hex length is odd`);

const scriptLength = scriptPubKeyHex.length / 2;
let offset = 0;

const readBytes = (length: number) => {
if (offset + length > scriptLength)
throw Error(
`script length is unexpected [${offset + length} > ${scriptLength}]`,
);

const result = scriptPubKeyHex.slice(offset * 2, (offset + length) * 2);
offset += length;
return result;
};

const opcode = parseInt(readBytes(1), 16);
if (opcode !== OP_RETURN)
throw Error(`script does not start with OP_RETURN opcode (6a)`);

const pushOpcode = parseInt(readBytes(1), 16);
let dataLength: number;
if (pushOpcode < OP_PUSHDATA1) {
dataLength = pushOpcode;
} else if (pushOpcode === OP_PUSHDATA1) {
dataLength = parseInt(readBytes(1), 16);
} else if (pushOpcode === OP_PUSHDATA2) {
dataLength = Buffer.from(readBytes(2), 'hex').readUInt16LE();
} else if (pushOpcode === OP_PUSHDATA4) {
dataLength = Buffer.from(readBytes(4), 'hex').readUInt32LE();
} else {
throw Error(`script contains unsupported push opcode [${pushOpcode}]`);
}

if (dataLength > MAX_OP_RETURN_DATA_BYTES)
throw Error(
`OP_RETURN data length exceeds Rosen limit [${dataLength} > ${MAX_OP_RETURN_DATA_BYTES}]`,
);

const expectedScriptLength = offset + dataLength;
if (Number.isNaN(dataLength) || expectedScriptLength !== scriptLength)
throw Error(
`script length is unexpected [${expectedScriptLength} !== ${scriptLength}]`,
);

return readBytes(dataLength);
};

/**
* Converts a Firocoin address to its corresponding output script
* @param addr The Firocoin address to convert
Expand All @@ -38,20 +102,5 @@ export const addressToOutputScript = (addr: string): string => {
export const parseOpReturn = (
scriptPubKeyHex: string,
): MinimalOnChainRosenData => {
// check OP_RETURN opcode
if (scriptPubKeyHex.slice(0, 2) !== '6a')
throw Error(`script does not start with OP_RETURN opcode (6a)`);

// check script length (should not use more than one OP_RETURN)
const dataLength = scriptPubKeyHex.slice(2, 4);
if (parseInt(dataLength, 16) + 2 !== scriptPubKeyHex.length / 2)
throw Error(
`script length is unexpected [${parseInt(dataLength, 16) + 3} !== ${
scriptPubKeyHex.length / 2
}]`,
);

const remainingData = scriptPubKeyHex.slice(4);

return parseRosenData(remainingData);
return parseRosenData(parseOpReturnData(scriptPubKeyHex));
};
37 changes: 37 additions & 0 deletions packages/rosen-extractor/tests/getRosenData/firo/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,43 @@ describe('parseOpReturn', () => {
expect(result).toStrictEqual(testData.opReturnData);
});

/**
* @target parseOpReturn should extract rosen data from %s
* @dependencies
* @scenario
* - mock OP_RETURN scriptPubKeys using OP_PUSHDATA1, OP_PUSHDATA2 and OP_PUSHDATA4
* - run test for each script
* - check returned value
* @expected
* - it should return expected rosen data
*/
it.each([
['OP_PUSHDATA1', testData.opReturnScripts.validPushData1],
['OP_PUSHDATA2', testData.opReturnScripts.validPushData2],
['OP_PUSHDATA4', testData.opReturnScripts.validPushData4],
])('should extract rosen data from %s', (_pushDataEncoding, script) => {
const result = parseOpReturn(script);

expect(result).toStrictEqual(testData.opReturnData);
});

/**
* @target parseOpReturn should reject OP_RETURN data over Rosen 80-byte limit
* @dependencies
* @scenario
* - mock OP_RETURN scriptPubKey with OP_PUSHDATA1 and 81-byte payload
* - run test & check thrown exception
* @expected
* - it should throw error
*/
it('should reject OP_RETURN data over Rosen 80-byte limit', () => {
const script = testData.opReturnScripts.tooLongPushData1;

expect(() => {
parseOpReturn(script);
}).toThrow('OP_RETURN data length exceeds Rosen limit [81 > 80]');
});

/**
* @target parseOpReturn should throw error
* when script does not start with OP_RETURN opcode
Expand Down
13 changes: 11 additions & 2 deletions packages/rosen-extractor/tests/getRosenData/firo/utilsTestData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ export const validFiroAddress = 'a3io3zMLfg9nchA3KSoSEvPz1tztnKDuaT';
export const validFiroOutputScript =
'76a91420f87f7f202c3d40dea06df136862fdbeef3f8e788ac';
export const invalidFiroAddress = 'a3io3zMLfg9nchA3KSoSEvPz1tztnKDUZZ';
const validOpReturnPayload =
'00000000000098968000000000009896802103f999da8e6e42660e4464d17d29e63bc006734a6710a24eb489b466323d3a9339';
const padOpReturnPayload = (length: number) =>
validOpReturnPayload + '00'.repeat(length - validOpReturnPayload.length / 2);
const pushData1Script = (length: number) =>
`6a4c${length.toString(16).padStart(2, '0')}${padOpReturnPayload(length)}`;
export const opReturnScripts = {
valid:
'6a3300000000000098968000000000009896802103f999da8e6e42660e4464d17d29e63bc006734a6710a24eb489b466323d3a9339',
valid: `6a33${validOpReturnPayload}`,
validPushData1: pushData1Script(76),
validPushData2: `6a4d3300${validOpReturnPayload}`,
validPushData4: `6a4e33000000${validOpReturnPayload}`,
tooLongPushData1: pushData1Script(81),
noOpReturn:
'3300000000000098968000000000009896802103f999da8e6e42660e4464d17d29e63bc006734a6710a24eb489b466323d3a9339',
invalidToChain:
Expand Down
Loading