From 84c8e04c838b614c09f9976a4bdad296952bd1e3 Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Thu, 15 Oct 2026 03:52:43 +0000 Subject: [PATCH] fix(evolution): validate RewardAccount header type and bech32 prefix (#456) --- ...reward-account-header-prefix-validation.md | 5 ++ packages/evolution/src/RewardAccount.ts | 32 ++++++-- .../RewardAccount.HeaderValidation.test.ts | 73 +++++++++++++++++++ 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 .changeset/reward-account-header-prefix-validation.md create mode 100644 packages/evolution/test/RewardAccount.HeaderValidation.test.ts diff --git a/.changeset/reward-account-header-prefix-validation.md b/.changeset/reward-account-header-prefix-validation.md new file mode 100644 index 00000000..8d3d8699 --- /dev/null +++ b/.changeset/reward-account-header-prefix-validation.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +`RewardAccount` parsing now validates the CIP-19 header type and the bech32 prefix, mirroring the checks `Address` already has. `FromBytes` (and therefore `FromHex`) rejects any header type other than 14 (stake key) or 15 (script), so a 29-byte enterprise or pointer address no longer parses as a reward account. `FromBech32` rejects prefixes other than `stake` / `stake_test` and rejects a prefix that disagrees with the network id in the header. Valid `stake` / `stake_test` accounts with key or script credentials round-trip unchanged. diff --git a/packages/evolution/src/RewardAccount.ts b/packages/evolution/src/RewardAccount.ts index 7f07285e..3ae7cbfa 100644 --- a/packages/evolution/src/RewardAccount.ts +++ b/packages/evolution/src/RewardAccount.ts @@ -77,7 +77,7 @@ export const FromBytes = Schema.transformOrFail( result.set(stakeCredentialBytes, 1) return yield* ParseResult.succeed(result) }), - decode: (_, __, ___, fromA) => + decode: (_, __, ast, fromA) => Eff.gen(function* () { const header = fromA[0] // Extract network ID from the lower 4 bits @@ -85,6 +85,13 @@ export const FromBytes = Schema.transformOrFail( // Extract address type from the upper 4 bits (bits 4-7) const addressType = header >> 4 + // RewardAccount (CIP-19): only header types 14 (stake key) and 15 (script) are reward types + if (addressType !== 0b1110 && addressType !== 0b1111) { + return yield* ParseResult.fail( + new ParseResult.Type(ast, fromA, `Invalid reward address type: ${addressType}`) + ) + } + const isStakeKey = (addressType & 0b0001) === 0 const stakeCredential: Credential.Credential = isStakeKey ? new KeyHash.KeyHash({ @@ -123,17 +130,30 @@ export const FromBech32 = Schema.transformOrFail(Schema.String, Schema.typeSchem }), decode: (fromA, _, ast) => Eff.gen(function* () { - const result = yield* Eff.try({ + const decoded = yield* Eff.try({ try: () => { // Note: `as any` needed because bech32.decode expects template literal type `${Prefix}1${string}` // but Schema provides plain string. Consider using decodeToBytes which accepts string. - const decoded = bech32.decode(fromA as any, false) - const bytes = bech32.fromWords(decoded.words) - return new Uint8Array(bytes) + const result = bech32.decode(fromA as any, false) + return { prefix: result.prefix, bytes: new Uint8Array(bech32.fromWords(result.words)) } }, catch: () => new ParseResult.Type(ast, fromA, `Failed to decode Bech32: ${fromA}`) }) - return yield* ParseResult.decode(FromBytes)(result) + // Reject non-stake prefixes (e.g. addr / addr_test payment addresses) + if (decoded.prefix !== "stake" && decoded.prefix !== "stake_test") { + return yield* ParseResult.fail( + new ParseResult.Type(ast, fromA, `Invalid reward account prefix: ${decoded.prefix}`) + ) + } + const rewardAccount = yield* ParseResult.decode(FromBytes)(decoded.bytes) + // Prefix must agree with the network in the header (testnet -> stake_test) + const expectedPrefix = rewardAccount.networkId === 0 ? "stake_test" : "stake" + if (decoded.prefix !== expectedPrefix) { + return yield* ParseResult.fail( + new ParseResult.Type(ast, fromA, `Prefix ${decoded.prefix} does not match network ${rewardAccount.networkId}`) + ) + } + return rewardAccount }) }).annotations({ identifier: "RewardAccount.FromBech32", diff --git a/packages/evolution/test/RewardAccount.HeaderValidation.test.ts b/packages/evolution/test/RewardAccount.HeaderValidation.test.ts new file mode 100644 index 00000000..df26524a --- /dev/null +++ b/packages/evolution/test/RewardAccount.HeaderValidation.test.ts @@ -0,0 +1,73 @@ +import { bech32 } from "@scure/base" +import { describe, expect, it } from "vitest" + +import * as Bytes from "../src/Bytes.js" +import * as KeyHash from "../src/KeyHash.js" +import * as PrivateKey from "../src/PrivateKey.js" +import * as RewardAccount from "../src/RewardAccount.js" +import * as ScriptHash from "../src/ScriptHash.js" + +describe("RewardAccount header and prefix validation (CIP-19)", () => { + const keyHash = KeyHash.fromPrivateKey(PrivateKey.fromBytes(PrivateKey.generate())) + const hashHex = Bytes.toHex(keyHash.hash) + + it("rejects a non-reward header type supplied as hex", () => { + // 0x61 = type 6 (enterprise, key), mainnet — a payment address header, not a reward one + expect(() => RewardAccount.fromHex("61" + hashHex)).toThrow(/Invalid reward address type: 6/) + // 0x41 = type 4 (pointer), mainnet + expect(() => RewardAccount.fromHex("41" + hashHex)).toThrow(/Invalid reward address type: 4/) + // 0x01 = type 0 (base, key/key), mainnet + expect(() => RewardAccount.fromHex("01" + hashHex)).toThrow(/Invalid reward address type: 0/) + }) + + it("rejects a non-reward header type supplied as bytes", () => { + const enterpriseBytes = Bytes.fromHex("61" + hashHex) + expect(() => RewardAccount.fromBytes(enterpriseBytes)).toThrow(/Invalid reward address type: 6/) + }) + + it("rejects a non-stake bech32 prefix", () => { + const reward = new RewardAccount.RewardAccount({ networkId: 1, stakeCredential: keyHash }) + const bytes = RewardAccount.toBytes(reward) + const addrBech32 = bech32.encode("addr", bech32.toWords(bytes), false) + expect(() => RewardAccount.fromBech32(addrBech32)).toThrow(/Invalid reward account prefix: addr/) + const addrTestBech32 = bech32.encode("addr_test", bech32.toWords(bytes), false) + expect(() => RewardAccount.fromBech32(addrTestBech32)).toThrow(/Invalid reward account prefix: addr_test/) + }) + + it("rejects a stake prefix that disagrees with the network", () => { + // Testnet reward payload (networkId 0) encoded under the mainnet "stake" prefix + const testnet = new RewardAccount.RewardAccount({ networkId: 0, stakeCredential: keyHash }) + const mislabeled = bech32.encode("stake", bech32.toWords(RewardAccount.toBytes(testnet)), false) + expect(() => RewardAccount.fromBech32(mislabeled)).toThrow(/does not match network 0/) + // Mainnet reward payload encoded under "stake_test" + const mainnet = new RewardAccount.RewardAccount({ networkId: 1, stakeCredential: keyHash }) + const mislabeledTest = bech32.encode("stake_test", bech32.toWords(RewardAccount.toBytes(mainnet)), false) + expect(() => RewardAccount.fromBech32(mislabeledTest)).toThrow(/does not match network 1/) + }) + + it("still parses valid stake and stake_test round-trips (key credential)", () => { + const mainnet = new RewardAccount.RewardAccount({ networkId: 1, stakeCredential: keyHash }) + // sanity: 0xe1 = type 14 (reward, stake-key), mainnet + expect(RewardAccount.toHex(mainnet).slice(0, 2)).toBe("e1") + expect(RewardAccount.fromHex(RewardAccount.toHex(mainnet)).stakeCredential._tag).toBe("KeyHash") + const mainnetBech32 = RewardAccount.toBech32(mainnet) + expect(mainnetBech32.startsWith("stake1")).toBe(true) + expect(RewardAccount.fromBech32(mainnetBech32).networkId).toBe(1) + + const testnet = new RewardAccount.RewardAccount({ networkId: 0, stakeCredential: keyHash }) + expect(RewardAccount.toHex(testnet).slice(0, 2)).toBe("e0") + const testnetBech32 = RewardAccount.toBech32(testnet) + expect(testnetBech32.startsWith("stake_test1")).toBe(true) + expect(RewardAccount.fromBech32(testnetBech32).networkId).toBe(0) + }) + + it("still parses a valid script-credential reward account (type 15)", () => { + const scriptHash = new ScriptHash.ScriptHash({ hash: keyHash.hash }) + const reward = new RewardAccount.RewardAccount({ networkId: 1, stakeCredential: scriptHash }) + // sanity: 0xf1 = type 15 (reward, script), mainnet + expect(RewardAccount.toHex(reward).slice(0, 2)).toBe("f1") + const parsed = RewardAccount.fromHex(RewardAccount.toHex(reward)) + expect(parsed.stakeCredential._tag).toBe("ScriptHash") + expect(RewardAccount.fromBech32(RewardAccount.toBech32(reward)).stakeCredential._tag).toBe("ScriptHash") + }) +})