Skip to content

Commit b173c86

Browse files
Merge pull request #5079 from BitGo/BTC-1578-follow-up
Parse and verify CoreDao OP_RETURN
2 parents f4af4ae + ab671f2 commit b173c86

File tree

5 files changed

+466
-309
lines changed

5 files changed

+466
-309
lines changed

modules/utxo-coredao/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export * from './transaction';
1+
export * from './opReturn';
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { payments, networks } from '@bitgo/utxo-lib';
2+
3+
// Source: https://docs.coredao.org/docs/Learn/products/btc-staking/design
4+
export const CORE_DAO_TESTNET_CHAIN_ID = Buffer.from('045b', 'hex');
5+
export const CORE_DAO_MAINNET_CHAIN_ID = Buffer.from('045c', 'hex');
6+
export const CORE_DAO_SATOSHI_PLUS_IDENTIFIER = Buffer.from('5341542b', 'hex');
7+
// https://github.com/bitcoin/bitcoin/blob/5961b23898ee7c0af2626c46d5d70e80136578d3/src/script/script.h#L47
8+
const OP_RETURN_IDENTIFIER = Buffer.from('6a', 'hex');
9+
10+
export function encodeTimelock(timelock: number): Buffer {
11+
const buff = Buffer.alloc(4);
12+
buff.writeUInt32LE(timelock);
13+
return buff;
14+
}
15+
16+
export function decodeTimelock(buffer: Buffer): number {
17+
if (buffer.length !== 4) {
18+
throw new Error('Invalid timelock buffer length');
19+
}
20+
return buffer.readUInt32LE();
21+
}
22+
23+
type BaseParams = {
24+
version: number;
25+
chainId: Buffer;
26+
delegator: Buffer;
27+
validator: Buffer;
28+
fee: number;
29+
};
30+
31+
type OpReturnParams = BaseParams & ({ redeemScript: Buffer } | { timelock: number });
32+
33+
/**
34+
* Create a CoreDAO OP_RETURN output script
35+
*
36+
* @param version Version of the OP_RETURN
37+
* @param chainId Chain ID
38+
* @param delegator Delegator address
39+
* @param validator Validator address
40+
* @param fee Fee for relayer
41+
* @param redeemScript Redeem script of the staking output
42+
* @param timelock Timelock for the staking output
43+
* @returns Buffer OP_RETURN buffer
44+
*/
45+
export function createCoreDaoOpReturnOutputScript({
46+
version,
47+
chainId,
48+
delegator,
49+
validator,
50+
fee,
51+
...rest
52+
}: OpReturnParams): Buffer {
53+
/**
54+
* As of v2, this is the construction of the OP_RETURN:
55+
* Source: https://docs.coredao.org/docs/Learn/products/btc-staking/design#op_return-output
56+
*
57+
* The OP_RETURN output should contain all staking information in order, and be composed in the following format:
58+
*
59+
* Satoshi Plus Identifier: (SAT+) 4 bytes
60+
* Version: (0x01) 1 byte
61+
* Chain ID: (0x045b (1115) for Core Testnet and 0x045c (1116) for Core Mainnet) 2 bytes
62+
* Delegator: The Core address to receive rewards, 20 bytes
63+
* Validator: The Core validator address to stake to, 20 bytes
64+
* Fee: Fee for relayer, 1 byte, range [0,255], measured in CORE
65+
* (Optional) RedeemScript
66+
* (Optional) Timelock: 4 bytes
67+
*
68+
* Either RedeemScript or Timelock must be available, the purpose is to allow relayer to
69+
* obtain the RedeemScript and submit transactions on Core. If a RedeemScript is provided,
70+
* relayer will use it directly. Otherwise, relayer will construct the redeem script based
71+
* on the timelock and the information in the transaction inputs.
72+
*
73+
* Note that any length > 80 bytes wont be relayed by nodes and therefore we will throw an error.
74+
*/
75+
if (version < 0 || version > 255) {
76+
throw new Error('Invalid version - out of range');
77+
}
78+
const versionBuffer = Buffer.alloc(1, version);
79+
80+
if (!(chainId.equals(CORE_DAO_TESTNET_CHAIN_ID) || chainId.equals(CORE_DAO_MAINNET_CHAIN_ID))) {
81+
throw new Error('Invalid chain ID');
82+
}
83+
84+
if (delegator.length !== 20) {
85+
throw new Error('Invalid delegator address');
86+
}
87+
88+
if (validator.length !== 20) {
89+
throw new Error('Invalid validator address');
90+
}
91+
92+
if (fee < 0 || fee > 255) {
93+
throw new Error('Invalid fee - out of range');
94+
}
95+
const feeBuffer = Buffer.alloc(1, fee);
96+
97+
if (feeBuffer.length !== 1) {
98+
throw new Error('Invalid fee');
99+
}
100+
101+
const redeemScriptBuffer = 'redeemScript' in rest ? rest.redeemScript : Buffer.from([]);
102+
if ('timelock' in rest && (rest.timelock < 0 || rest.timelock > 4294967295)) {
103+
throw new Error('Invalid timelock - out of range');
104+
}
105+
106+
// encode the number into a 4-byte buffer
107+
// if timelock is provided, write it into 32-bit little-endian
108+
const timelockBuffer = 'timelock' in rest ? encodeTimelock(rest.timelock) : Buffer.from([]);
109+
const data = Buffer.concat([
110+
CORE_DAO_SATOSHI_PLUS_IDENTIFIER,
111+
versionBuffer,
112+
chainId,
113+
delegator,
114+
validator,
115+
feeBuffer,
116+
redeemScriptBuffer,
117+
timelockBuffer,
118+
]);
119+
if (data.length > 80) {
120+
throw new Error('OP_RETURN outputs cannot have a length larger than 80 bytes');
121+
}
122+
123+
const payment = payments.embed({
124+
data: [data],
125+
network: chainId.equals(CORE_DAO_TESTNET_CHAIN_ID) ? networks.testnet : networks.bitcoin,
126+
});
127+
if (!payment.output) {
128+
throw new Error('Unable to create OP_RETURN output');
129+
}
130+
131+
return payment.output;
132+
}
133+
134+
/**
135+
* Parse a CoreDAO OP_RETURN output script into the constituent parts
136+
* @param script
137+
* @returns OpReturnParams
138+
*/
139+
export function parseCoreDaoOpReturnOutputScript(script: Buffer): OpReturnParams {
140+
if (!script.subarray(0, 1).equals(OP_RETURN_IDENTIFIER)) {
141+
throw new Error('First byte must be an OP_RETURN');
142+
}
143+
144+
const payment = payments.embed({
145+
output: script,
146+
});
147+
const data = payment.data;
148+
if (!data || data.length !== 1) {
149+
throw new Error('Invalid OP_RETURN output');
150+
}
151+
const dataBuffer = data[0];
152+
if (dataBuffer.length > 80) {
153+
throw new Error(`OP_RETURN outputs cannot have a length larger than 80 bytes`);
154+
}
155+
let offset = 0;
156+
157+
// Decode satoshi+ identifier
158+
if (!dataBuffer.subarray(offset, offset + 4).equals(CORE_DAO_SATOSHI_PLUS_IDENTIFIER)) {
159+
throw new Error('Invalid satoshi+ identifier');
160+
}
161+
offset += 4;
162+
163+
// Decode version
164+
const version = dataBuffer[offset];
165+
offset += 1;
166+
167+
// Decode chainId
168+
const chainId = Buffer.from(dataBuffer.subarray(offset, offset + 2));
169+
if (!(chainId.equals(CORE_DAO_TESTNET_CHAIN_ID) || chainId.equals(CORE_DAO_MAINNET_CHAIN_ID))) {
170+
throw new Error(
171+
`Invalid ChainID: ${chainId.toString('hex')}. Must be either 0x045b (testnet) or 0x045c (mainnet).`
172+
);
173+
}
174+
offset += 2;
175+
176+
// Decode delegator
177+
const delegator = Buffer.from(dataBuffer.subarray(offset, offset + 20));
178+
offset += 20;
179+
180+
// Decode validator
181+
const validator = Buffer.from(dataBuffer.subarray(offset, offset + 20));
182+
offset += 20;
183+
184+
// Decode fee
185+
const fee = dataBuffer[offset];
186+
offset += 1;
187+
188+
const baseParams = { version, chainId, delegator, validator, fee };
189+
190+
// Decode redeemScript or timelock
191+
if (offset === dataBuffer.length - 4) {
192+
return { ...baseParams, timelock: decodeTimelock(dataBuffer.subarray(offset)) };
193+
} else {
194+
return { ...baseParams, redeemScript: Buffer.from(dataBuffer.subarray(offset)) };
195+
}
196+
}

modules/utxo-coredao/src/transaction.ts

Lines changed: 0 additions & 130 deletions
This file was deleted.

0 commit comments

Comments
 (0)