Skip to content

Commit 24ede47

Browse files
committed
fix(ts-sdk): locate refund auth-anchor positionally per protocol
1 parent d664bec commit 24ede47

5 files changed

Lines changed: 214 additions & 186 deletions

File tree

packages/babylon-ts-sdk/src/tbv/core/managers/pegin/__tests__/assertAuthAnchorOpReturn.test.ts

Lines changed: 66 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { describe, expect, it } from "vitest";
33

44
import {
55
assertAuthAnchorOpReturn,
6-
findAuthAnchorOpReturn,
6+
countHtlcOutputs,
7+
readAuthAnchorOpReturn,
78
} from "../assertAuthAnchorOpReturn";
89

910
const ANCHOR_HASH = "ab".repeat(32);
@@ -133,100 +134,104 @@ describe("assertAuthAnchorOpReturn", () => {
133134
});
134135
});
135136

136-
describe("findAuthAnchorOpReturn", () => {
137-
it("returns {vout, hash} for a single-vault tx (HTLC@0, OP_RETURN@1)", () => {
138-
const txHex = buildTxHex([htlcOutput(), opReturnOutput(ANCHOR_HASH)]);
139-
expect(findAuthAnchorOpReturn(txHex)).toEqual({
140-
vout: 1,
141-
hash: ANCHOR_HASH,
142-
});
137+
describe("countHtlcOutputs", () => {
138+
it("counts 1 for a single-vault auth-anchored tx [HTLC, OP_RETURN, anchor]", () => {
139+
const txHex = buildTxHex([
140+
htlcOutput(),
141+
opReturnOutput(ANCHOR_HASH),
142+
htlcOutput(),
143+
]);
144+
expect(countHtlcOutputs(txHex)).toBe(1);
143145
});
144146

145-
it("returns {vout, hash} for a two-vault tx (HTLCs@0,1, OP_RETURN@2)", () => {
147+
it("counts 1 for a single-vault tx with no auth anchor [HTLC, anchor]", () => {
148+
const txHex = buildTxHex([htlcOutput(), htlcOutput()]);
149+
expect(countHtlcOutputs(txHex)).toBe(1);
150+
});
151+
152+
it("counts 2 for a two-vault auth-anchored tx [HTLC, HTLC, OP_RETURN, anchor]", () => {
146153
const txHex = buildTxHex([
147154
htlcOutput(),
148155
htlcOutput(),
149156
opReturnOutput(ANCHOR_HASH),
157+
htlcOutput(),
150158
]);
151-
expect(findAuthAnchorOpReturn(txHex)).toEqual({
152-
vout: 2,
153-
hash: ANCHOR_HASH,
154-
});
159+
expect(countHtlcOutputs(txHex)).toBe(2);
155160
});
156161

157-
it("returns {vout, hash} even when followed by a CPFP-anchor-like output", () => {
158-
// Real funded Pre-PegIns also carry a CPFP anchor / change output
159-
// after the OP_RETURN. The finder must locate the anchor regardless
160-
// of what comes after.
162+
it("counts 2 for a two-vault tx with no auth anchor [HTLC, HTLC, anchor]", () => {
163+
const txHex = buildTxHex([htlcOutput(), htlcOutput(), htlcOutput()]);
164+
expect(countHtlcOutputs(txHex)).toBe(2);
165+
});
166+
167+
it("stops at the first OP_RETURN — a trailing extra OP_RETURN does not change the count", () => {
168+
// [HTLC, OP_RETURN, OP_RETURN]: the second OP_RETURN sits in the
169+
// last (CPFP-anchor) slot and is irrelevant; the HTLC count is 1.
161170
const txHex = buildTxHex([
162171
htlcOutput(),
163172
opReturnOutput(ANCHOR_HASH),
164-
htlcOutput(),
173+
opReturnOutput(OTHER_HASH),
165174
]);
166-
expect(findAuthAnchorOpReturn(txHex)).toEqual({
167-
vout: 1,
168-
hash: ANCHOR_HASH,
169-
});
175+
expect(countHtlcOutputs(txHex)).toBe(1);
170176
});
171177

172-
it("strips a leading 0x prefix from the funded tx hex", () => {
173-
const txHex = buildTxHex([htlcOutput(), opReturnOutput(ANCHOR_HASH)]);
174-
expect(findAuthAnchorOpReturn(`0x${txHex}`)).toEqual({
175-
vout: 1,
176-
hash: ANCHOR_HASH,
177-
});
178+
it("throws when the tx has fewer than 2 outputs", () => {
179+
const txHex = buildTxHex([htlcOutput()]);
180+
expect(() => countHtlcOutputs(txHex)).toThrow(/at least 2 outputs/);
178181
});
179182

180-
it("returns undefined for a legacy (non-auth-anchored) tx with no OP_RETURN", () => {
181-
const txHex = buildTxHex([htlcOutput(), htlcOutput()]);
182-
expect(findAuthAnchorOpReturn(txHex)).toBeUndefined();
183+
it("strips a leading 0x prefix from the funded tx hex", () => {
184+
const txHex = buildTxHex([htlcOutput(), opReturnOutput(ANCHOR_HASH)]);
185+
expect(countHtlcOutputs(`0x${txHex}`)).toBe(1);
183186
});
187+
});
184188

185-
it("throws when more than one OP_RETURN+PUSH32 output is present (ambiguous)", () => {
186-
// A well-formed Pre-PegIn carries exactly one auth-anchor commitment.
187-
// Multiple matches are malformed input — refuse rather than guess.
189+
describe("readAuthAnchorOpReturn", () => {
190+
it("returns the hash when vout points at an OP_RETURN PUSH32 output", () => {
188191
const txHex = buildTxHex([
189192
htlcOutput(),
190193
opReturnOutput(ANCHOR_HASH),
191-
opReturnOutput(OTHER_HASH),
194+
htlcOutput(),
192195
]);
193-
expect(() => findAuthAnchorOpReturn(txHex)).toThrow(
194-
/OP_RETURN PUSH32 outputs/,
195-
);
196+
expect(readAuthAnchorOpReturn(txHex, 1)).toBe(ANCHOR_HASH);
197+
});
198+
199+
it("returns undefined when the output at vout is not an OP_RETURN (no auth anchor)", () => {
200+
// [HTLC, anchor] — vout 1 is the CPFP anchor, not an OP_RETURN.
201+
const txHex = buildTxHex([htlcOutput(), htlcOutput()]);
202+
expect(readAuthAnchorOpReturn(txHex, 1)).toBeUndefined();
203+
});
204+
205+
it("returns undefined when vout is out of bounds", () => {
206+
const txHex = buildTxHex([htlcOutput(), htlcOutput()]);
207+
expect(readAuthAnchorOpReturn(txHex, 5)).toBeUndefined();
196208
});
197209

198-
it("ignores OP_RETURN outputs with non-zero value", () => {
199-
// Non-standard OP_RETURNs (non-zero value) cannot have been emitted
200-
// by the WASM builder — skip them rather than treat them as a hit.
210+
it("reads only the output at vout — an OP_RETURN elsewhere is ignored", () => {
211+
// OP_RETURN at vout 2, but vout 1 is a plain output → no auth anchor.
201212
const txHex = buildTxHex([
202213
htlcOutput(),
203-
opReturnOutput(ANCHOR_HASH, /* value */ 546),
214+
htlcOutput(),
215+
opReturnOutput(ANCHOR_HASH),
204216
]);
205-
expect(findAuthAnchorOpReturn(txHex)).toBeUndefined();
217+
expect(readAuthAnchorOpReturn(txHex, 1)).toBeUndefined();
206218
});
207219

208-
it("ignores OP_RETURN outputs with non-32-byte payloads", () => {
209-
// OP_RETURN PUSH16 <16 bytes> — wrong push opcode, shorter payload.
210-
const tooShort = {
211-
scriptHex: `6a10${"ab".repeat(16)}`,
212-
value: 0,
213-
};
214-
const txHex = buildTxHex([htlcOutput(), tooShort]);
215-
expect(findAuthAnchorOpReturn(txHex)).toBeUndefined();
220+
it("throws when the output at vout is an OP_RETURN but not a clean 32-byte push", () => {
221+
// OP_RETURN PUSH16 <16 bytes> — an OP_RETURN, but malformed as an
222+
// auth anchor. Must throw, not silently degrade to "no anchor".
223+
const malformed = { scriptHex: `6a10${"ab".repeat(16)}`, value: 0 };
224+
const txHex = buildTxHex([htlcOutput(), malformed]);
225+
expect(() => readAuthAnchorOpReturn(txHex, 1)).toThrow(/malformed/);
216226
});
217227

218-
it("returns undefined for unparseable hex", () => {
219-
expect(findAuthAnchorOpReturn("not a real tx hex")).toBeUndefined();
228+
it("strips a leading 0x prefix from the funded tx hex", () => {
229+
const txHex = buildTxHex([htlcOutput(), opReturnOutput(ANCHOR_HASH)]);
230+
expect(readAuthAnchorOpReturn(`0x${txHex}`, 1)).toBe(ANCHOR_HASH);
220231
});
221232

222233
it("normalizes the hash to lowercase regardless of input case", () => {
223-
// OP_RETURN payloads are raw bytes; the hex serialization picks a
224-
// case. Normalize at the boundary so downstream byte-equality holds.
225-
const upperHash = "CD".repeat(32);
226-
const txHex = buildTxHex([htlcOutput(), opReturnOutput(upperHash)]);
227-
expect(findAuthAnchorOpReturn(txHex)).toEqual({
228-
vout: 1,
229-
hash: "cd".repeat(32),
230-
});
234+
const txHex = buildTxHex([htlcOutput(), opReturnOutput("CD".repeat(32))]);
235+
expect(readAuthAnchorOpReturn(txHex, 1)).toBe("cd".repeat(32));
231236
});
232237
});

packages/babylon-ts-sdk/src/tbv/core/managers/pegin/assertAuthAnchorOpReturn.ts

Lines changed: 49 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -80,90 +80,74 @@ export function assertAuthAnchorOpReturn(
8080
}
8181

8282
/**
83-
* Best-effort reader for the auth-anchor OP_RETURN payload at `vout` of
84-
* a funded Pre-PegIn transaction.
83+
* Read the auth-anchor OP_RETURN payload at the fixed index `vout` of a
84+
* funded Pre-PegIn transaction.
8585
*
86-
* Returns the 32-byte payload as lowercase hex (no `0x` prefix) if the
87-
* output at `vout` is exactly `OP_RETURN || PUSH32 || <32 bytes>` with
88-
* a zero value. Returns `undefined` for any structural mismatch —
89-
* missing output, wrong script shape, non-zero value — so legacy
90-
* non-auth-anchored Pre-PegIns parse as "no anchor" rather than
91-
* raising.
86+
* Positional, matching the protocol: the auth anchor — when present — sits
87+
* at `vout = htlcCount` (see {@link countHtlcOutputs}). Mirrors btc-vault
88+
* `PrePegInTx::extract_auth_anchor_hash`:
89+
* - returns `undefined` when the output at `vout` is absent or is not an
90+
* OP_RETURN (no auth anchor — a legitimate shape);
91+
* - returns the 32-byte payload as lowercase hex when the output is a
92+
* well-formed `OP_RETURN || PUSH32 || <32 bytes>`;
93+
* - throws when the output IS an OP_RETURN but malformed — that must not
94+
* silently collapse to "no anchor".
9295
*
93-
* Used by the refund flow to reconstruct the unfunded WASM template
94-
* with the same output shape as the on-chain funded transaction.
95-
* Assertion semantics (compare against an expected value, throw on
96-
* mismatch) live in {@link assertAuthAnchorOpReturn}.
96+
* @throws If `vout`'s output is an OP_RETURN that is not a clean 32-byte push.
9797
*/
9898
export function readAuthAnchorOpReturn(
9999
fundedPrePeginTxHex: string,
100100
vout: number,
101101
): string | undefined {
102-
let tx: bitcoin.Transaction;
103-
try {
104-
tx = bitcoin.Transaction.fromHex(stripHexPrefix(fundedPrePeginTxHex));
105-
} catch {
106-
// Best-effort: unparseable hex is also "no extractable anchor".
107-
// The same hex flows into the refund PSBT primitive immediately
108-
// after, where Transaction.fromHex will surface a real parse error.
109-
return undefined;
110-
}
111-
112-
if (tx.outs.length <= vout) return undefined;
102+
const tx = bitcoin.Transaction.fromHex(stripHexPrefix(fundedPrePeginTxHex));
113103

114104
const output = tx.outs[vout];
105+
if (output === undefined) return undefined;
106+
115107
const script = output.script;
116-
if (
117-
script.length !== OP_RETURN_PUSH32_SCRIPT_LEN ||
118-
script[0] !== OP_RETURN ||
119-
script[1] !== OP_PUSH32
120-
) {
121-
return undefined;
122-
}
123-
if (output.value !== 0) return undefined;
108+
// Not an OP_RETURN → no auth anchor at this position (legitimate).
109+
if (script.length === 0 || script[0] !== OP_RETURN) return undefined;
124110

111+
// It IS an OP_RETURN — it must be the canonical 32-byte push, or throw.
112+
if (script.length !== OP_RETURN_PUSH32_SCRIPT_LEN || script[1] !== OP_PUSH32) {
113+
throw new Error(
114+
`Auth-anchor OP_RETURN at vout ${vout} is malformed: expected ` +
115+
`${OP_RETURN_PUSH32_SCRIPT_LEN}-byte OP_RETURN + PUSH32 layout, got a ` +
116+
`${script.length}-byte script`,
117+
);
118+
}
125119
return script.slice(2).toString("hex").toLowerCase();
126120
}
127121

128122
/**
129-
* Scan a funded Pre-PegIn transaction for its auth-anchor commitment
130-
* (an `OP_RETURN || PUSH32 || <32 bytes>` output with value 0).
123+
* Count the HTLC outputs at the head of a funded Pre-PegIn transaction.
124+
*
125+
* Mirrors btc-vault `PrePegInTx::count_htlc_outputs` and the contract's
126+
* `PeginLogic._countHtlcOutputs`: the HTLC outputs are the contiguous
127+
* leading outputs before the first OP_RETURN; the optional auth-anchor
128+
* OP_RETURN sits right after them, and the CPFP anchor is always the last
129+
* output. Walks outputs `[0, len - 1)` (the last is the CPFP anchor) and
130+
* stops at the first OP_RETURN.
131131
*
132-
* Returns `{ vout, hash }` for exactly one match, `undefined` for zero
133-
* matches (legacy / unparseable). Throws on multiple matches — that
134-
* shape is malformed and must not silently collapse to "no anchor".
132+
* The returned count doubles as the auth-anchor index for
133+
* {@link readAuthAnchorOpReturn}.
134+
*
135+
* @throws If the transaction has fewer than 2 outputs.
135136
*/
136-
export function findAuthAnchorOpReturn(
137-
fundedPrePeginTxHex: string,
138-
): { vout: number; hash: string } | undefined {
139-
let tx: bitcoin.Transaction;
140-
try {
141-
tx = bitcoin.Transaction.fromHex(stripHexPrefix(fundedPrePeginTxHex));
142-
} catch {
143-
return undefined;
144-
}
145-
146-
const hits: { vout: number; hash: string }[] = [];
147-
for (let i = 0; i < tx.outs.length; i++) {
148-
const output = tx.outs[i];
149-
const script = output.script;
150-
if (
151-
script.length === OP_RETURN_PUSH32_SCRIPT_LEN &&
152-
script[0] === OP_RETURN &&
153-
script[1] === OP_PUSH32 &&
154-
output.value === 0
155-
) {
156-
hits.push({
157-
vout: i,
158-
hash: script.slice(2).toString("hex").toLowerCase(),
159-
});
160-
}
161-
}
137+
export function countHtlcOutputs(fundedPrePeginTxHex: string): number {
138+
const tx = bitcoin.Transaction.fromHex(stripHexPrefix(fundedPrePeginTxHex));
162139

163-
if (hits.length > 1) {
140+
if (tx.outs.length < 2) {
164141
throw new Error(
165-
`Funded Pre-PegIn has ${hits.length} OP_RETURN PUSH32 outputs (vouts ${hits.map((h) => h.vout).join(", ")}); expected at most 1.`,
142+
`Funded Pre-PegIn must have at least 2 outputs (HTLC + CPFP anchor), ` +
143+
`got ${tx.outs.length}`,
166144
);
167145
}
168-
return hits[0];
146+
147+
let count = 0;
148+
for (let i = 0; i < tx.outs.length - 1; i++) {
149+
if (tx.outs[i].script[0] === OP_RETURN) break;
150+
count++;
151+
}
152+
return count;
169153
}

packages/babylon-ts-sdk/src/tbv/core/managers/pegin/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
export {
1010
assertAuthAnchorOpReturn,
11-
findAuthAnchorOpReturn,
11+
countHtlcOutputs,
1212
readAuthAnchorOpReturn,
1313
} from "./assertAuthAnchorOpReturn";
1414
export {

0 commit comments

Comments
 (0)