diff --git a/.changeset/firo-pushdata1-op-return.md b/.changeset/firo-pushdata1-op-return.md new file mode 100644 index 00000000..86d9b6b3 --- /dev/null +++ b/.changeset/firo-pushdata1-op-return.md @@ -0,0 +1,5 @@ +--- +'@rosen-bridge/rosen-extractor': patch +--- + +Fix Firo OP_RETURN parsing for OP_PUSHDATA1 payloads. diff --git a/packages/rosen-extractor/lib/getRosenData/firo/utils.ts b/packages/rosen-extractor/lib/getRosenData/firo/utils.ts index 2f8b0f1f..215a6316 100644 --- a/packages/rosen-extractor/lib/getRosenData/firo/utils.ts +++ b/packages/rosen-extractor/lib/getRosenData/firo/utils.ts @@ -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', @@ -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 => { + 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 @@ -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)); }; diff --git a/packages/rosen-extractor/tests/getRosenData/firo/utils.spec.ts b/packages/rosen-extractor/tests/getRosenData/firo/utils.spec.ts index 7588a2fe..e657fce8 100644 --- a/packages/rosen-extractor/tests/getRosenData/firo/utils.spec.ts +++ b/packages/rosen-extractor/tests/getRosenData/firo/utils.spec.ts @@ -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 diff --git a/packages/rosen-extractor/tests/getRosenData/firo/utilsTestData.ts b/packages/rosen-extractor/tests/getRosenData/firo/utilsTestData.ts index 51b4e894..a07be104 100644 --- a/packages/rosen-extractor/tests/getRosenData/firo/utilsTestData.ts +++ b/packages/rosen-extractor/tests/getRosenData/firo/utilsTestData.ts @@ -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: