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
98 changes: 98 additions & 0 deletions src/components/DefuseSDK/utils/addressValidation/zcash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest"
import { validateZcashUnifiedAddress } from "./zcash"

describe("validateZcashUnifiedAddress()", () => {
// From zcash-test-vectors, test-vectors/json/unified_address.json (vector index)
it.each([
[
"p2pkh + sapling + orchard (#3)",
"u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx",
],
[
"p2pkh + orchard (#9)",
"u1snf9yr883aj2hm8pksp9aymnqdwzy42rpzuffevj35hhxeckays5pcpeq7vy2mtgzlcuc4mnh9443qnuyje0yx6h59angywka4v2ap6kchh2j96ezf9w0c0auyz3wwts2lx5gmk2sk9",
],
[
"orchard only (#27)",
"u1ddnjsdcpm36r6aq79n3s68shjweksnmwtdltrh046s8m6xcws9ygyawalxx8n6hg6vegk0wh8zjnafxgh6msppjsljvyt0ynece3lvm0",
],
[
"orchard + unknown typecode (#36)",
"u1uehkuaq6rpfgt4ed5zpvhczg9apgpmyk5eq9qg23j8w7jxkhdnqzacte6gu8zgzfzgxy48ryzus3wnkhfxrxmlhs34xde3f34uxcnv3y6dsgj288vu56xs9f6ghvqsgkhuwtz4kkfxj8pa27v5p3ttlst340zvwx9nj6s0zw8p3wwk3zh37dwc7znqz52gj2fpaapzxzyagah0aeyxwa9fxxvyyj6w989v96ymsgf7s8s6ej9346p60fcjzzynvf9rmxevumdvt8l9mvhdfz4u5j4h7e0zjr2sde7fu7z9s02447qg6qzllm22egnx6ej6qczkkk2ygvpy08un9ggp853sddp6vskrlar6sygxec5f6c2t2eu9zmc728esy4sj9z853gxuplr6hw7lpcwzk20d85vuflnhlfv8nr3020r0v9z83ryudsyjv66rttxq2cscqlrdxakrmpjptzcf",
],
[
"sapling only (#45)",
"u187vrwl4ampyxd5m6aj38n4ndkmj8v6gs97hkt23aps3sn5k89a0gk2smluexgdprcrtm56ezc5c7tjwlrnnl79tjtrxmqd42c5mpyz7g",
],
])("valid: %s", (_name, address) => {
expect(validateZcashUnifiedAddress(address)).toBe(true)
})

// Vector #27's Orchard receiver plus a metadata item outside the
// MUST-understand range, which consumers ignore
it("valid: orchard + ignorable metadata 0xC0 (64 bytes)", () => {
expect(
validateZcashUnifiedAddress(
"u1fm32dnaewyvygn4djt26u3nadycxmn8999x4m8l8p6983laejlk2r3w48hvwrkp20jcz60ndygd6amvqak099rxe25tfp8u8f0888jqwlked7"
)
).toBe(true)
})

// Correct Bech32m and F4Jumble, each with one ZIP-316 rule broken
it.each([
[
"transparent receiver only (38 bytes)",
"u1zt8jctg466z3sa68p60yc332fyhg6zkg6e5d4c9he7xytx9ujvhs99rvt7qcxgmmgvg",
],
[
"wrong padding (61 bytes)",
"u13q3p90awvhjh93fzfm42nv2afced8f9yhl7ksq84szj5yt9wcdsqu4q2kc5jmjz7hel4s664jgvx7amcq8g8jhsunqv2337cdqkgeyyw",
],
[
"both P2PKH and P2SH (105 bytes)",
"u15zpfmukhsvjkdqge8qk0zql9n83mf66tazyz8pza546qg739detdxcl609rsefk9tu2s9vnuen9xmd2fnzu8rc63qul47k5g323a33zenfvz7fx8g5f3j5xgxfkcwsugy6yttw3c0f9rnsy3u5qd778mmk35uf4fc3l3q4mnnrz5z3",
],
[
"items out of order (106 bytes)",
"u1zt4xpnwgduql7pw2jlhnls693p4ewq208jpenffr4rvl8xtv8n5e8zww2ngeh2lg6wzwku35ts362yqpdxmyw22rfr5uex77lqsksngf264xsw9e09z4634tmwxc5nddtdt7dqz7lec2y4h0l2yfvj8hajafg99jdaxhf7l93ygy3lm4",
],
[
"wrong Orchard receiver length (60 bytes)",
"u1dzd7tpr26ffx83w3cgazmzd0z594f44tmuv4e6txtzhf5ua8zslufl7tn8n88mdtek4p0555cfxs3s7mux9werqt0fldsqtvffrqlt",
],
[
"MUST-understand metadata item 0xE0 (67 bytes)",
"u1hlfmws84g4h2fm56vfwtay0akyfesc6nur2ylhccpgqgjdylxmqvqmm57xdugy4sp70fw5mnmfhmrdt2ju8qur0qw0l282240exekagxkrtq02z7x5",
],
[
"non-canonical CompactSize typecode (63 bytes)",
"u1y5eet2q707fyylyujr36ldumsdjrg09x4m37250mdfpnz8m72x9exuc7ekeytkak6umm6udwpy2j94n4zprkkrsf2ah826ce3948z3zmyrk",
],
[
"typecode above 0x02000000 (67 bytes)",
"u1qxnk2cz4vxf852g36txc2hcf5ha7vah0dz7uv3f7233wtfhgrj9jkx3gan7erjy9vf3r5kv82dltwn4zmk48dm97ggcuqxk2jua33ffne44q4k93uu",
],
])("invalid: %s", (_name, address) => {
expect(validateZcashUnifiedAddress(address)).toBe(false)
})

// Not a mainnet unified address
it.each([
[
"testnet HRP (61 bytes)",
"utest1jeykdhzrwz45pnx9pqd2uefaxdedyrmyq4l2r5l9uxq583gj8k397f6s0wvf724py7fjuhy94glytwljsmezgyeyruk2wcktec3ykh4y",
],
[
"corrupted checksum",
"u1ddnjsdcpm36r6aq79n3s68shjweksnmwtdltrh046s8m6xcws9ygyawalxx8n6hg6vegk0wh8zjnafxgh6msppjsljvyt0ynece3lvmq",
],
[
"the 78-character example in validateAddress.test.ts",
"u1pv2h74qrlq9k9uqh6xk5p6vyzgvj84h6jedxg9e6hfhske3xjqf27f2qsm2qz4t3zqnll98t9n60",
],
["a transparent address", "t1XJD5btQc9qHHzywPS8xhPbs2hbXGXWLFq"],
["empty", ""],
])("invalid: %s", (_name, address) => {
expect(validateZcashUnifiedAddress(address)).toBe(false)
})
})
170 changes: 170 additions & 0 deletions src/components/DefuseSDK/utils/addressValidation/zcash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { blake2b } from "@noble/hashes/blake2b"
import { bech32m } from "@scure/base"

// ZIP-316, https://zips.z.cash/zip-0316 (Revision 0, the `u` prefix)
const HRP = "u"
const PADDING_LENGTH = 16
// F4Jumble's input range: the shortest is a transparent-only Revision 2 `tu` address
const F4JUMBLE_MIN = 38
const F4JUMBLE_MAX = 4_194_368
// Typecodes and lengths are CompactSize, at most Bitcoin's MAX_SIZE (as in librustzcash)
const MAX_COMPACT_SIZE = 0x02000000

const P2PKH = 0x00
const P2SH = 0x01
const SAPLING = 0x02
const ORCHARD = 0x03
const RECEIVER_LENGTH: Record<number, number> = {
[P2PKH]: 20,
[P2SH]: 20,
[SAPLING]: 43,
[ORCHARD]: 43,
}

/**
* Validates Zcash mainnet unified addresses (ZIP-316 Revision 0, `u1…`).
* Decodes the whole address: Bech32m (no 90-character limit), F4Jumble⁻¹,
* the HRP padding, then the typecode-length-value items. A Revision 0
* address must hold a Sapling or Orchard receiver, so a transparent-only one
* is rejected, and no MUST-understand metadata; other items with typecodes we
* don't know are ignored, as ZIP-316 requires. Returns true if valid, false if
* invalid
*/
export function validateZcashUnifiedAddress(address: string): boolean {
try {
const { prefix, bytes } = bech32m.decodeToBytes(
address as `${string}1${string}`
)
if (prefix !== HRP) return false

const raw = f4jumbleInv(bytes)
if (!hasPadding(raw.subarray(raw.length - PADDING_LENGTH))) return false

const typecodes = readTypecodes(
raw.subarray(0, raw.length - PADDING_LENGTH)
)
if (typecodes === null) return false
if (typecodes.includes(P2PKH) && typecodes.includes(P2SH)) return false

return typecodes.includes(SAPLING) || typecodes.includes(ORCHARD)
} catch {
return false
}
}

/** The HRP, zero-padded to 16 bytes. */
function hasPadding(tail: Uint8Array): boolean {
const expected = new Uint8Array(PADDING_LENGTH)
expected.set(new TextEncoder().encode(HRP))
return tail.every((byte, i) => byte === expected[i])
}

/**
* The items' typecodes, or null if an item is truncated, out of order
* (typecodes must strictly increase), a known receiver has the wrong length,
* or an item is MUST-understand metadata, which Revision 0 does not allow.
*/
function readTypecodes(body: Uint8Array): number[] | null {
const typecodes: number[] = []
let at = 0
while (at < body.length) {
const typecode = readCompactSize(body, at)
if (typecode === null) return null
const length = readCompactSize(body, typecode.next)
if (length === null) return null
if (length.next + length.value > body.length) return null

const previous = typecodes[typecodes.length - 1]
if (previous !== undefined && typecode.value <= previous) return null

const expected = RECEIVER_LENGTH[typecode.value]
if (expected !== undefined && expected !== length.value) return null
if (isMustUnderstand(typecode.value)) return null

typecodes.push(typecode.value)
at = length.next + length.value
}
return typecodes
}

/** ZIP-316 metadata typecodes 0xE0 to 0xFC are "MUST-understand". */
function isMustUnderstand(typecode: number): boolean {
return typecode >= 0xe0 && typecode <= 0xfc
}

/**
* A Bitcoin-style CompactSize; null if it runs past the end, is not in its
* shortest form or is above MAX_COMPACT_SIZE.
*/
function readCompactSize(
bytes: Uint8Array,
at: number
): { value: number; next: number } | null {
const first = bytes[at]
if (first === undefined) return null
if (first < 0xfd) return { value: first, next: at + 1 }

const width = first === 0xfd ? 2 : first === 0xfe ? 4 : 8
if (at + 1 + width > bytes.length) return null
let value = 0
for (let i = width; i >= 1; i--) value = value * 256 + (bytes[at + i] ?? 0)
const shortest = first === 0xfd ? 0xfd : first === 0xfe ? 0x10000 : 2 ** 32
if (value < shortest || value > MAX_COMPACT_SIZE) return null
return { value, next: at + 1 + width }
}

const H_PERSONALIZATION = new TextEncoder().encode("UA_F4Jumble_H")
const G_PERSONALIZATION = new TextEncoder().encode("UA_F4Jumble_G")

/** Byte-wise XOR of `a` with `b`, the length of `a`. */
function xor(a: Uint8Array, b: Uint8Array): Uint8Array {
return a.map((byte, i) => byte ^ (b[i] ?? 0))
}

/**
* F4Jumble's H_i: BLAKE2b with a `length`-byte digest, personalized with
* "UA_F4Jumble_H" and the bytes i, 0, 0.
*/
function f4H(i: number, u: Uint8Array, length: number): Uint8Array {
return blake2b(u, {
dkLen: length,
personalization: Uint8Array.from([...H_PERSONALIZATION, i, 0, 0]),
})
}

/**
* F4Jumble's G_i: 64-byte BLAKE2b blocks j = 0, 1, …, each personalized with
* "UA_F4Jumble_G", the byte i and j as 16-bit little-endian, cut to `length`.
*/
function f4G(i: number, u: Uint8Array, length: number): Uint8Array {
const out = new Uint8Array(length)
for (let j = 0; j * 64 < length; j++) {
const block = blake2b(u, {
dkLen: 64,
personalization: Uint8Array.from([
...G_PERSONALIZATION,
i,
j & 0xff,
j >> 8,
]),
})
out.set(block.subarray(0, Math.min(64, length - j * 64)), j * 64)
}
return out
}

/** The inverse of F4Jumble, ZIP-316's unkeyed 4-round Feistel construction. */
function f4jumbleInv(m: Uint8Array): Uint8Array {
if (m.length < F4JUMBLE_MIN || m.length > F4JUMBLE_MAX) {
throw new Error("F4Jumble input out of range")
}
const lengthL = Math.min(64, Math.floor(m.length / 2))
const lengthR = m.length - lengthL
const c = m.subarray(0, lengthL)
const d = m.subarray(lengthL)
const y = xor(c, f4H(1, d, lengthL))
const x = xor(d, f4G(1, y, lengthR))
const a = xor(y, f4H(0, x, lengthL))
const b = xor(x, f4G(0, a, lengthR))
return Uint8Array.from([...a, ...b])
}
8 changes: 8 additions & 0 deletions src/components/DefuseSDK/utils/validateAddress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ describe("validateAddress", () => {
expect(validateAddress(transparentAddress, "zcash")).toBe(true)
expect(validateAddress(shieldedAddress, "zcash")).toBe(false)
expect(validateAddress(unifiedAddress, "zcash")).toBe(false)
// zcash-test-vectors unified_address.json #27: a single Orchard receiver
const orchardUnifiedAddress =
"u1ddnjsdcpm36r6aq79n3s68shjweksnmwtdltrh046s8m6xcws9ygyawalxx8n6hg6vegk0wh8zjnafxgh6msppjsljvyt0ynece3lvm0"
expect(validateAddress(orchardUnifiedAddress, "zcash")).toBe(true)
// 69 characters: well-formed Bech32m, but a transparent receiver only
const transparentOnlyUnifiedAddress =
"u1zt8jctg466z3sa68p60yc332fyhg6zkg6e5d4c9he7xytx9ujvhs99rvt7qcxgmmgvg"
expect(validateAddress(transparentOnlyUnifiedAddress, "zcash")).toBe(false)
expect(validateAddress(texAddress, "zcash")).toBe(true)
})

Expand Down
12 changes: 4 additions & 8 deletions src/components/DefuseSDK/utils/validateAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "ripple-address-codec"
import type { SupportedChainName } from "../types/base"
import { validateCardanoAddress } from "./addressValidation/cardano"
import { validateZcashUnifiedAddress } from "./addressValidation/zcash"

export function validateAddress(
address: string,
Expand Down Expand Up @@ -132,6 +133,7 @@ function validateXrpAddress(address: string) {
* Supports:
* - Transparent addresses (t1, t3)
* - TEX addresses (tex1)
* - Unified addresses (u1, ZIP-316)
*/
function validateZcashAddress(address: string) {
// Transparent address validation
Expand All @@ -155,14 +157,8 @@ function validateZcashAddress(address: string) {
}

// Unified address validation
const uaHrp = "u"
if (address.startsWith(`${uaHrp}1`)) {
try {
const decoded = bech32m.decodeToBytes(address)
return decoded.prefix === uaHrp
} catch {
return false
}
if (address.startsWith("u1")) {
return validateZcashUnifiedAddress(address)
}

return false
Expand Down