fix: use correct Wownero network bytes and 2-byte varint prefix for address validation

The monero Python library assumes 1-byte network prefixes (Monero format).
Wownero uses 2-byte varint prefixes (base58 prefix 4146 for primary, 12208
for subaddress), so Address() was rejecting valid Wownero addresses with
"Primary address is invalid".

Replace monero lib Address usage with native Wownero validation:
- Correct netbytes: 0xb2 (primary), 0xb0 (subaddress), 0xb3 (integrated)
- 2-byte varint prefix offset for key extraction (bytes 2-33, 34-65)
- 2-byte varint prefix in subaddress derivation encoding
This commit is contained in:
jwinterm
2026-03-13 14:57:57 -04:00
parent 2ccfee4caa
commit 20eff23335
2 changed files with 78 additions and 32 deletions
+48 -14
View File
@@ -28,7 +28,6 @@ from .config import (
from .db import get_db
from .formatting import format_xmr_amount
from .models import Invoice, ProfileHistory, User, Webhook, WebhookDelivery
from monero.address import Address, IntegratedAddress, SubAddress
from .rates import get_wow_rate
from .subaddress_allocator import MAX_SUBADDRESS_INDEX, create_subaddress_for_user
from .schemas import (
@@ -358,28 +357,63 @@ def _resolve_invoice_amount(
def _validate_payment_address_and_view_key(payment_address: str, view_key: str) -> None:
"""Validate a Wownero primary address and its secret view key.
Wownero uses 2-byte varint prefixes (unlike Monero's 1-byte), so the
standard monero Python library cannot parse them correctly. We do
base58-decode + keccak checksum + ed25519 view-key verification here.
"""
from binascii import hexlify, unhexlify
from monero import base58, ed25519
from monero.keccak import keccak_256
# Wownero base58 prefix varints (first decoded byte)
_WOW_PRIMARY_BYTE0 = 178 # base58 prefix 4146 -> "Wo"
_WOW_SUBADDR_BYTE0 = 176 # base58 prefix 12208 -> "Ww"
_WOW_INTEGRATED_BYTE0 = 179 # base58 prefix 4147
try:
address = Address(payment_address)
except ValueError as exc:
try:
SubAddress(payment_address)
except ValueError:
try:
IntegratedAddress(payment_address)
except ValueError:
decoded = bytearray(unhexlify(base58.decode(payment_address)))
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Primary address is invalid",
) from exc
)
# Verify keccak-256 checksum (last 4 bytes)
if decoded[-4:] != keccak_256(decoded[:-4]).digest()[:4]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Integrated addresses are not supported. Use the primary address.",
) from exc
detail="Primary address is invalid",
)
first_byte = decoded[0]
if first_byte == _WOW_SUBADDR_BYTE0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Subaddresses are not supported. Use the primary address.",
) from exc
if not address.check_private_view_key(view_key):
)
if first_byte == _WOW_INTEGRATED_BYTE0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Integrated addresses are not supported. Use the primary address.",
)
if first_byte != _WOW_PRIMARY_BYTE0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Primary address is invalid",
)
# Wownero has 2-byte varint prefix -> keys start at offset 2
pub_spend = decoded[2:34]
pub_view = decoded[34:66]
# Derive public view key from the secret view key and compare
try:
derived_pub_view = ed25519.public_from_secret_hex(view_key)
if unhexlify(derived_pub_view) != bytes(pub_view):
raise ValueError("mismatch")
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Secret view key does not match the primary address",
+25 -13
View File
@@ -3,16 +3,28 @@ from __future__ import annotations
from binascii import hexlify, unhexlify
import struct
from monero import base58, const, ed25519
from monero.address import Address
from monero import base58, ed25519
from monero.keccak import keccak_256
# Override monero library network constants for Wownero
from monero import const as _mc
_mc.NETS = ('main',)
_mc.MASTERADDR_NETBYTES = (53,) # Wownero mainnet address prefix
_mc.SUBADDR_NETBYTES = (63,) # Wownero subaddress prefix
_mc.INTADDRR_NETBYTES = (54,) # Wownero integrated address prefix
# Wownero network prefix bytes (first byte of varint-encoded base58 prefix).
# Primary: base58 prefix 4146 -> varint [0xb2, 0x20] -> "Wo"
# Subaddr: base58 prefix 12208 -> varint [0xb0, 0x5f] -> "Ww"
_WOW_PRIMARY_VARINT = bytes([0xb2, 0x20])
_WOW_SUBADDR_VARINT = bytes([0xb0, 0x5f])
def _decode_wow_address(payment_address: str) -> tuple[bytes, bytes]:
"""Decode a Wownero primary address and return (pub_spend, pub_view) as raw bytes."""
decoded = bytearray(unhexlify(base58.decode(payment_address)))
# Verify checksum
if decoded[-4:] != keccak_256(decoded[:-4]).digest()[:4]:
raise ValueError("Invalid checksum")
if decoded[0] != _WOW_PRIMARY_VARINT[0]:
raise ValueError(f"Not a Wownero primary address (netbyte {decoded[0]})")
# 2-byte varint prefix, then 32-byte spend key, 32-byte view key, 4-byte checksum
pub_spend = bytes(decoded[2:34])
pub_view = bytes(decoded[34:66])
return pub_spend, pub_view
def derive_subaddress(
@@ -22,9 +34,9 @@ def derive_subaddress(
account_index: int,
address_index: int,
) -> str:
address = Address(payment_address)
"""Derive a Wownero subaddress from a primary address and secret view key."""
pub_spend, _pub_view = _decode_wow_address(payment_address)
view_key_bytes = unhexlify(view_key)
spend_pub_bytes = unhexlify(address.spend_key())
data = (
b"SubAddr\0"
@@ -33,9 +45,9 @@ def derive_subaddress(
)
m = ed25519.scalar_reduce(keccak_256(data).digest())
mG = ed25519.scalarmult_B(m)
D = ed25519.edwards_add(spend_pub_bytes, mG)
D = ed25519.edwards_add(pub_spend, mG)
C = ed25519.scalarmult(view_key_bytes, D)
prefix = const.SUBADDR_NETBYTES[const.NETS.index(address.net)]
payload = bytearray([prefix]) + D + C
# Wownero subaddress uses 2-byte varint prefix
payload = bytearray(_WOW_SUBADDR_VARINT) + D + C
checksum = keccak_256(payload).digest()[:4]
return base58.encode(hexlify(payload + checksum))