Skip to content
Open
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/reward-account-header-prefix-validation.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 26 additions & 6 deletions packages/evolution/src/RewardAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,21 @@ 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
const networkId = header & 0b00001111
// 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({
Expand Down Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions packages/evolution/test/RewardAccount.HeaderValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})