From 6d34f54f7967e89471ce736096fabfcfcdeec175 Mon Sep 17 00:00:00 2001 From: Maciej Kula Date: Wed, 20 May 2026 03:21:03 +0200 Subject: [PATCH 1/3] add full SS58 prefix support --- e2e/generator/Cargo.lock | 4 +- e2e/generator/Cargo.toml | 2 +- e2e/generator/src/main.rs | 167 +++++++++++++++++++++++++--------- e2e/test-vectors.json | 29 ++++++ go/conformance_test.go | 40 ++++++-- go/ss58.go | 48 ++++++++-- go/ss58_test.go | 24 +++-- go/types.go | 2 +- python/samp/ss58.py | 43 +++++++-- python/samp/types.py | 2 +- python/tests/test_ss58.py | 21 ++++- rust/src/error.rs | 5 +- rust/src/ss58.rs | 45 +++++++-- rust/src/types.rs | 2 +- rust/tests/conformance.rs | 158 ++++++++++++++++++++++---------- typescript/src/ss58.ts | 58 +++++++++--- typescript/src/types.ts | 2 +- typescript/test/ss58.test.ts | 43 +++++++-- typescript/test/types.test.ts | 6 +- 19 files changed, 533 insertions(+), 168 deletions(-) diff --git a/e2e/generator/Cargo.lock b/e2e/generator/Cargo.lock index ff8ebe9..e8e92b3 100644 --- a/e2e/generator/Cargo.lock +++ b/e2e/generator/Cargo.lock @@ -440,7 +440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "samp" +name = "samp-core" version = "1.1.0" dependencies = [ "blake2", @@ -465,7 +465,7 @@ dependencies = [ "getrandom 0.3.4", "hex", "hkdf", - "samp", + "samp-core", "schnorrkel", "serde", "serde_json", diff --git a/e2e/generator/Cargo.toml b/e2e/generator/Cargo.toml index 4cc7f4a..f1104f7 100644 --- a/e2e/generator/Cargo.toml +++ b/e2e/generator/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" publish = false [dependencies] -samp = { path = "../../rust" } +samp = { package = "samp-core", path = "../../rust" } schnorrkel = "0.11" curve25519-dalek = { version = "4", features = ["digest"] } hkdf = "0.12" diff --git a/e2e/generator/src/main.rs b/e2e/generator/src/main.rs index 654449d..3e122d1 100644 --- a/e2e/generator/src/main.rs +++ b/e2e/generator/src/main.rs @@ -12,7 +12,10 @@ use samp::encryption; use samp::extrinsic::{build_signed_extrinsic, ChainParams}; use samp::scale::{decode_compact, encode_compact}; use samp::wire::*; -use samp::{BlockRef, GenesisHash, Nonce, Pubkey, Seed, Signature}; +use samp::{ + BlockRef, CallArgs, CallIdx, ContentKey, EphPubkey, ExtrinsicNonce, GenesisHash, Nonce, + PalletIdx, Pubkey, Seed, Signature, SpecVersion, Ss58Prefix, TxVersion, +}; fn h(bytes: &[u8]) -> String { format!("0x{}", hex::encode(bytes)) @@ -112,6 +115,18 @@ struct NegativeCases { truncated_encrypted: String, } +#[derive(Serialize)] +struct Ss58CaseVec { + prefix: u16, + address: String, +} + +#[derive(Serialize)] +struct Ss58Vec { + pubkey: String, + cases: Vec, +} + #[derive(Serialize)] struct TestVectors { alice: KeypairVec, @@ -126,6 +141,7 @@ struct TestVectors { group_message: GroupMsgVec, edge_cases: EdgeCases, negative_cases: NegativeCases, + ss58: Ss58Vec, } fn make_keypair_vec(seed: &[u8; 32]) -> KeypairVec { @@ -135,7 +151,7 @@ fn make_keypair_vec(seed: &[u8; 32]) -> KeypairVec { KeypairVec { seed: h(seed), sr25519_public: h(&kp.public.to_bytes()), - signing_scalar: h(&scalar.to_bytes()), + signing_scalar: h(scalar.expose_secret()), } } @@ -234,10 +250,15 @@ fn main() { &encrypted_content, ); - let samp::Remark::Encrypted(parsed) = decode_remark(&enc_remark).unwrap() else { + let samp::Remark::Encrypted { + nonce: parsed_nonce, + ciphertext: parsed_ciphertext, + .. + } = decode_remark(&enc_remark).unwrap() + else { panic!("expected Encrypted"); }; - let decrypted = encryption::decrypt(&parsed, &bob_scalar).unwrap(); + let decrypted = encryption::decrypt(&parsed_ciphertext, &parsed_nonce, &bob_scalar).unwrap(); assert_eq!(decrypted.as_bytes(), plaintext); // === Thread message === @@ -254,9 +275,13 @@ fn main() { let thread_nonce = Nonce::from_bytes(thread_nonce_bytes); let thread_plaintext_typed = samp::Plaintext::from_bytes(thread_plaintext.clone()); - let thread_encrypted = - encryption::encrypt(&thread_plaintext_typed, &bob_pubkey, &thread_nonce, &alice_seed) - .unwrap(); + let thread_encrypted = encryption::encrypt( + &thread_plaintext_typed, + &bob_pubkey, + &thread_nonce, + &alice_seed, + ) + .unwrap(); let thread_view_tag = encryption::compute_view_tag(&alice_seed, &bob_pubkey, &thread_nonce).unwrap(); let thread_remark = encode_encrypted( @@ -266,10 +291,16 @@ fn main() { &thread_encrypted, ); - let samp::Remark::Thread(thread_parsed) = decode_remark(&thread_remark).unwrap() else { + let samp::Remark::Thread { + nonce: thread_parsed_nonce, + ciphertext: thread_parsed_ciphertext, + .. + } = decode_remark(&thread_remark).unwrap() + else { panic!("expected Thread"); }; - let thread_decrypted = encryption::decrypt(&thread_parsed, &bob_scalar).unwrap(); + let thread_decrypted = + encryption::decrypt(&thread_parsed_ciphertext, &thread_parsed_nonce, &bob_scalar).unwrap(); assert_eq!(thread_decrypted.as_bytes(), thread_plaintext.as_slice()); // === Sender self-decryption intermediates === @@ -295,7 +326,8 @@ fn main() { .unwrap(); let sd_shared = (sd_eph_scalar * sd_recip_point).compress().to_bytes(); - let sd_decrypted = encryption::decrypt_as_sender(&parsed, &alice_seed).unwrap(); + let sd_decrypted = + encryption::decrypt_as_sender(&parsed_ciphertext, &parsed_nonce, &alice_seed).unwrap(); assert_eq!(sd_decrypted.as_bytes(), plaintext); // === Channel message === @@ -327,14 +359,19 @@ fn main() { let mut root_plaintext = member_list_encoded.clone(); root_plaintext.extend_from_slice(group_body); - let group_inner = - encode_thread_content(BlockRef::ZERO, BlockRef::ZERO, BlockRef::ZERO, &root_plaintext); + let group_inner = encode_thread_content( + BlockRef::ZERO, + BlockRef::ZERO, + BlockRef::ZERO, + &root_plaintext, + ); let content_key: [u8; 32] = [0xDD; 32]; let group_eph_scalar = encryption::derive_group_ephemeral(&alice_seed, &group_nonce); let group_eph_pubkey = (group_eph_scalar * RISTRETTO_BASEPOINT_POINT).compress(); + let group_content_key = ContentKey::from_bytes(content_key); let group_capsules = encryption::build_capsules( - &content_key, + &group_content_key, &group_members, &group_eph_scalar, &group_nonce, @@ -342,11 +379,14 @@ fn main() { let group_cipher = ChaCha20Poly1305::new((&content_key).into()); let group_ciphertext_raw = group_cipher - .encrypt(ChaChaNonce::from_slice(&group_nonce_bytes), group_inner.as_slice()) + .encrypt( + ChaChaNonce::from_slice(&group_nonce_bytes), + group_inner.as_slice(), + ) .expect("group encryption"); let group_ciphertext = samp::Ciphertext::from_bytes(group_ciphertext_raw); - let group_eph_pubkey_pk = Pubkey::from_bytes(group_eph_pubkey.to_bytes()); + let group_eph_pubkey_pk = EphPubkey::from_bytes(group_eph_pubkey.to_bytes()); let group_remark = encode_group( &group_nonce, &group_eph_pubkey_pk, @@ -354,25 +394,38 @@ fn main() { &group_ciphertext, ); - let group_payload_for_decode = match decode_remark(&group_remark).unwrap() { - samp::Remark::Group(p) => p, - _ => panic!("expected Group"), - }; - let bob_decrypted = - encryption::decrypt_from_group(&group_payload_for_decode, &bob_scalar, Some(3)).unwrap(); + let (group_nonce_for_decode, group_payload_for_decode) = + match decode_remark(&group_remark).unwrap() { + samp::Remark::Group { nonce, content } => (nonce, content), + _ => panic!("expected Group"), + }; + let bob_decrypted = encryption::decrypt_from_group( + &group_payload_for_decode, + &group_nonce_for_decode, + &bob_scalar, + Some(3), + ) + .unwrap(); assert_eq!(bob_decrypted.as_bytes(), group_inner.as_slice()); - let charlie_decrypted = - encryption::decrypt_from_group(&group_payload_for_decode, &charlie_scalar, Some(3)) - .unwrap(); + let charlie_decrypted = encryption::decrypt_from_group( + &group_payload_for_decode, + &group_nonce_for_decode, + &charlie_scalar, + Some(3), + ) + .unwrap(); assert_eq!(charlie_decrypted.as_bytes(), group_inner.as_slice()); let random_seed = Seed::from_bytes([0xEE; 32]); let random_scalar = encryption::sr25519_signing_scalar(&random_seed); - assert!( - encryption::decrypt_from_group(&group_payload_for_decode, &random_scalar, Some(3)) - .is_err() - ); + assert!(encryption::decrypt_from_group( + &group_payload_for_decode, + &group_nonce_for_decode, + &random_scalar, + Some(3), + ) + .is_err()); // === Edge cases === let empty_body_public = encode_public(&bob_pubkey, ""); @@ -396,6 +449,16 @@ fn main() { let scale_vectors = build_scale_vectors(); let extrinsic_vectors = build_extrinsic_vectors(&alice_kp, &alice_seed_bytes); + let ss58_cases = [0u16, 42, 63, 64, 255, 16_383] + .into_iter() + .map(|prefix| { + let prefix_typed = Ss58Prefix::new(prefix).unwrap(); + Ss58CaseVec { + prefix, + address: bob_pubkey.to_ss58(prefix_typed).as_str().to_string(), + } + }) + .collect(); let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -486,9 +549,19 @@ fn main() { reserved_type, truncated_encrypted, }, + ss58: Ss58Vec { + pubkey: h(bob_pubkey.as_bytes()), + cases: ss58_cases, + }, }; - println!("{}", serde_json::to_string_pretty(&vectors).unwrap()); + let vectors_json = serde_json::to_string_pretty(&vectors).unwrap(); + std::fs::write( + out_dir.join("test-vectors.json"), + format!("{vectors_json}\n"), + ) + .expect("write test-vectors.json"); + println!("{vectors_json}"); } #[derive(Serialize)] @@ -560,13 +633,16 @@ struct ExtrinsicVectors { cases: Vec, } -fn build_extrinsic_vectors(alice_kp: &schnorrkel::Keypair, _alice_seed: &[u8; 32]) -> ExtrinsicVectors { +fn build_extrinsic_vectors( + alice_kp: &schnorrkel::Keypair, + _alice_seed: &[u8; 32], +) -> ExtrinsicVectors { let public_key = Pubkey::from_bytes(alice_kp.public.to_bytes()); - let chain = ChainParams { - genesis_hash: GenesisHash::from_bytes([0x11; 32]), - spec_version: 100, - tx_version: 1, - }; + let chain = ChainParams::new( + GenesisHash::from_bytes([0x11; 32]), + SpecVersion::new(100), + TxVersion::new(1), + ); let fixed_signature = Signature::from_bytes([0xAB; 64]); let long_payload = vec![0xCD; 1024]; @@ -618,17 +694,18 @@ struct CaseInputs<'a> { } fn build_case(c: CaseInputs<'_>) -> ExtrinsicCaseVec { - let mut call_args = Vec::new(); - encode_compact(c.remark.len() as u64, &mut call_args); - call_args.extend_from_slice(c.remark); + let mut call_args_raw = Vec::new(); + encode_compact(c.remark.len() as u64, &mut call_args_raw); + call_args_raw.extend_from_slice(c.remark); + let call_args = CallArgs::from_bytes(call_args_raw); let extrinsic = build_signed_extrinsic( - c.pallet_idx, - c.call_idx, + PalletIdx::new(c.pallet_idx), + CallIdx::new(c.call_idx), &call_args, c.public_key, |_msg| *c.fixed_signature, - c.nonce, + ExtrinsicNonce::new(c.nonce), c.chain, ) .expect("build_signed_extrinsic"); @@ -637,14 +714,14 @@ fn build_case(c: CaseInputs<'_>) -> ExtrinsicCaseVec { label: c.label, pallet_idx: c.pallet_idx, call_idx: c.call_idx, - call_args: h(&call_args), + call_args: h(call_args.as_bytes()), public_key: h(c.public_key.as_bytes()), fixed_signature: h(c.fixed_signature.as_bytes()), nonce: c.nonce, chain_params: ChainParamsVec { - genesis_hash: h(c.chain.genesis_hash.as_bytes()), - spec_version: c.chain.spec_version, - tx_version: c.chain.tx_version, + genesis_hash: h(c.chain.genesis_hash().as_bytes()), + spec_version: c.chain.spec_version().get(), + tx_version: c.chain.tx_version().get(), }, expected_extrinsic: h(extrinsic.as_bytes()), } diff --git a/e2e/test-vectors.json b/e2e/test-vectors.json index 8d9e6c9..f4af9cf 100644 --- a/e2e/test-vectors.json +++ b/e2e/test-vectors.json @@ -104,5 +104,34 @@ "non_samp_version": "0x2100", "reserved_type": "0x17", "truncated_encrypted": "0x12000102" + }, + "ss58": { + "pubkey": "0x8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48", + "cases": [ + { + "prefix": 0, + "address": "14E5nqKAp3oAJcmzgZhUD2RcptBeUBScxKHgJKU4HPNcKVf3" + }, + { + "prefix": 42, + "address": "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" + }, + { + "prefix": 63, + "address": "7Lpe5LRa2Ntx9KGDk77xzoBPYTCAvj7QqaBx4Nz2TFqL3sLw" + }, + { + "prefix": 64, + "address": "cEYoHYutFpXcexmxW4JMuaK2JzGqnVYxL7WxeGScJuR4t4bFg" + }, + { + "prefix": 255, + "address": "yGFxbGGNhpzjSAKB7iU4FRoxjKetFPqxqwEucsa8nqudSJANV" + }, + { + "prefix": 16383, + "address": "yNYZ9YmV73KqE11fLn1UkDaiW3DtmrPddpVotddem1n6A2RWL" + } + ] } } diff --git a/go/conformance_test.go b/go/conformance_test.go index f486eae..98f3744 100644 --- a/go/conformance_test.go +++ b/go/conformance_test.go @@ -96,6 +96,16 @@ type negativeCases struct { TruncatedEncrypted string `json:"truncated_encrypted"` } +type ss58CaseVec struct { + Prefix uint16 `json:"prefix"` + Address string `json:"address"` +} + +type ss58Vec struct { + Pubkey string `json:"pubkey"` + Cases []ss58CaseVec `json:"cases"` +} + type testVectors struct { Alice keypairVec `json:"alice"` Bob keypairVec `json:"bob"` @@ -109,6 +119,7 @@ type testVectors struct { GroupMessage groupMsgVec `json:"group_message"` EdgeCases edgeCases `json:"edge_cases"` NegativeCases negativeCases `json:"negative_cases"` + Ss58 ss58Vec `json:"ss58"` } func h(s string) []byte { @@ -936,6 +947,21 @@ func TestTypeStringMethods(t *testing.T) { require.Equal(t, 4, cd.Len()) } +func TestConformanceSs58BoundaryPrefixes(t *testing.T) { + v := loadVectors(t) + pk := pkFromHex(v.Ss58.Pubkey) + for _, c := range v.Ss58.Cases { + prefix, err := Ss58PrefixNew(c.Prefix) + require.NoError(t, err) + addr := Ss58AddressEncode(pk, prefix) + require.Equal(t, c.Address, addr.String()) + parsed, err := Ss58AddressParse(c.Address) + require.NoError(t, err) + require.Equal(t, pk, parsed.Pubkey()) + require.Equal(t, prefix, parsed.Prefix()) + } +} + // --- Coverage: secret.go --- func TestSecretTypesCoverage(t *testing.T) { @@ -1792,9 +1818,9 @@ func TestReadTypeDefVariantWithFields(t *testing.T) { // name="Foo" (compact 3 + "Foo"), 0 fields, index=0, 1 doc string "test doc" fooName := append([]byte{0x0C}, []byte("Foo")...) docStr := append([]byte{0x20}, []byte("test doc")...) - variant := append(fooName, 0x00) // 0 fields - variant = append(variant, 0x00) // index=0 - variant = append(variant, 0x04) // 1 doc + variant := append(fooName, 0x00) // 0 fields + variant = append(variant, 0x00) // index=0 + variant = append(variant, 0x04) // 1 doc variant = append(variant, docStr...) data := append([]byte{0x01, 0x04}, variant...) @@ -1847,11 +1873,11 @@ func TestReadRegistryNonSequentialId(t *testing.T) { func TestReadFieldsWithNamedField(t *testing.T) { // 1 field: Option(Some) for name="foo", compact type_id=0, Option(None) for typeName, 0 docs data := []byte{ - 0x04, // compact 1 + 0x04, // compact 1 0x01, 0x0C, 'f', 'o', 'o', // Option(Some, "foo") - 0x00, // compact 0 (type_id) - 0x00, // Option(None) for typeName - 0x00, // compact 0 (docs) + 0x00, // compact 0 (type_id) + 0x00, // Option(None) for typeName + 0x00, // compact 0 (docs) } r := &reader{data: data, pos: 0} fields, err := readFields(r) diff --git a/go/ss58.go b/go/ss58.go index d48deab..a265a86 100644 --- a/go/ss58.go +++ b/go/ss58.go @@ -7,8 +7,9 @@ import ( const ss58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" func ss58Encode(pubkey Pubkey, prefix Ss58Prefix) Ss58Address { - payload := make([]byte, 0, 35) - payload = append(payload, byte(prefix.v)) + prefixBytes := ss58EncodePrefix(prefix) + payload := make([]byte, 0, len(prefixBytes)+34) + payload = append(payload, prefixBytes...) payload = append(payload, pubkey.b[:]...) h, _ := blake2b.New512(nil) h.Write([]byte("SS58PRE")) @@ -26,13 +27,17 @@ func ss58Decode(s string) (Ss58Address, error) { if len(decoded) < 35 { return Ss58Address{}, ErrSs58TooShort } - if decoded[0] >= 64 { - return Ss58Address{}, ErrSs58PrefixUnsupported + prefixValue, prefixLen, err := ss58DecodePrefix(decoded) + if err != nil { + return Ss58Address{}, err } - pubkeyEnd := 1 + 32 + pubkeyEnd := prefixLen + 32 if len(decoded) < pubkeyEnd+2 { return Ss58Address{}, ErrSs58TooShort } + if len(decoded) != pubkeyEnd+2 { + return Ss58Address{}, ErrSs58BadChecksum + } payload := decoded[:pubkeyEnd] expected := decoded[pubkeyEnd : pubkeyEnd+2] h, _ := blake2b.New512(nil) @@ -43,14 +48,43 @@ func ss58Decode(s string) (Ss58Address, error) { return Ss58Address{}, ErrSs58BadChecksum } var pk [32]byte - copy(pk[:], decoded[1:pubkeyEnd]) - prefix, err := Ss58PrefixNew(uint16(decoded[0])) + copy(pk[:], decoded[prefixLen:pubkeyEnd]) + prefix, err := Ss58PrefixNew(prefixValue) if err != nil { return Ss58Address{}, err } return Ss58Address{address: s, pubkey: Pubkey{pk}, prefix: prefix}, nil } +func ss58EncodePrefix(prefix Ss58Prefix) []byte { + value := prefix.v + if value < 64 { + return []byte{byte(value)} + } + return []byte{ + byte((value&0b0000000011111100)>>2) | 0b01000000, + byte(value>>8) | byte((value&0b0000000000000011)<<6), + } +} + +func ss58DecodePrefix(decoded []byte) (uint16, int, error) { + first := decoded[0] + if first&0b10000000 != 0 { + return 0, 0, ErrSs58PrefixUnsupported + } + if first&0b01000000 == 0 { + return uint16(first), 1, nil + } + if len(decoded) < 2 { + return 0, 0, ErrSs58TooShort + } + second := decoded[1] + value := (uint16(first&0b00111111) << 2) | + uint16(second>>6) | + (uint16(second&0b00111111) << 8) + return value, 2, nil +} + func bs58Decode(input string) ([]byte, bool) { bytes := []byte{0} for _, c := range input { diff --git a/go/ss58_test.go b/go/ss58_test.go index d59f871..d7d652f 100644 --- a/go/ss58_test.go +++ b/go/ss58_test.go @@ -66,14 +66,8 @@ func TestSs58DecodeNonASCII(t *testing.T) { } func TestSs58DecodeUnsupportedPrefix(t *testing.T) { - // Encode a pubkey with prefix byte = 64 (>= 64 triggers unsupported). - // We manually build a base58 string that decodes to a payload with prefix 64. - // The simplest way: encode a valid address, then re-encode with a high prefix byte. - // Instead, just ensure the error by testing with known invalid addresses. - // A valid prefix-0 address starts with "1"; prefix-64+ would be different. - // Actually, let's just test through the internal function indirectly. + // Ensure malformed high-bit prefix markers fail without panicking. _, err := Ss58AddressParse("2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") - // This may produce various errors; the key is no panic. require.Error(t, err) } @@ -99,6 +93,20 @@ func TestSs58PrefixBoundary(t *testing.T) { require.NoError(t, err) require.Equal(t, uint16(63), parsed.Prefix().Get()) - _, err = Ss58PrefixNew(64) + prefix64, err := Ss58PrefixNew(64) + require.NoError(t, err) + addr = Ss58AddressEncode(testPubkey, prefix64) + parsed, err = Ss58AddressParse(addr.String()) + require.NoError(t, err) + require.Equal(t, uint16(64), parsed.Prefix().Get()) + + prefixMax, err := Ss58PrefixNew(16383) + require.NoError(t, err) + addr = Ss58AddressEncode(testPubkey, prefixMax) + parsed, err = Ss58AddressParse(addr.String()) + require.NoError(t, err) + require.Equal(t, uint16(16383), parsed.Prefix().Get()) + + _, err = Ss58PrefixNew(16384) require.Error(t, err) } diff --git a/go/types.go b/go/types.go index aa6906e..00acb71 100644 --- a/go/types.go +++ b/go/types.go @@ -203,7 +203,7 @@ var ( ) func Ss58PrefixNew(v uint16) (Ss58Prefix, error) { - if v > 63 { + if v > 16383 { return Ss58Prefix{}, fmt.Errorf("%w: %d", ErrSs58PrefixUnsupported, v) } return Ss58Prefix{v}, nil diff --git a/python/samp/ss58.py b/python/samp/ss58.py index 14d8768..72975c7 100644 --- a/python/samp/ss58.py +++ b/python/samp/ss58.py @@ -9,9 +9,8 @@ def encode(pubkey: Pubkey, prefix: Ss58Prefix) -> Ss58Address: - prefix_byte = int(prefix) payload = bytearray() - payload.append(prefix_byte) + payload.extend(_encode_prefix(int(prefix))) payload.extend(pubkey) h = hashlib.blake2b(b"SS58PRE" + bytes(payload), digest_size=64).digest() payload.extend(h[:2]) @@ -24,15 +23,43 @@ def decode(address: str) -> Ss58Address: raise SampError("ss58 invalid base58") if len(decoded) < 35: raise SampError("ss58 too short") - if decoded[0] >= 64: - raise SampError(f"ss58 prefix unsupported: {decoded[0]}") - payload = decoded[:33] - checksum = decoded[33:35] + prefix_value, prefix_len = _decode_prefix(decoded) + pubkey_end = prefix_len + 32 + if len(decoded) < pubkey_end + 2: + raise SampError("ss58 too short") + if len(decoded) != pubkey_end + 2: + raise SampError("ss58 bad checksum") + payload = decoded[:pubkey_end] + checksum = decoded[pubkey_end : pubkey_end + 2] h = hashlib.blake2b(b"SS58PRE" + payload, digest_size=64).digest() if h[:2] != checksum: raise SampError("ss58 bad checksum") - prefix = ss58_prefix_from_int(decoded[0]) - return Ss58Address.from_parts(address, pubkey_from_bytes(decoded[1:33]), prefix) + prefix = ss58_prefix_from_int(prefix_value) + return Ss58Address.from_parts(address, pubkey_from_bytes(decoded[prefix_len:pubkey_end]), prefix) + + +def _encode_prefix(prefix: int) -> bytes: + if prefix < 64: + return bytes([prefix]) + return bytes( + [ + ((prefix & 0b0000_0000_1111_1100) >> 2) | 0b0100_0000, + (prefix >> 8) | ((prefix & 0b0000_0000_0000_0011) << 6), + ] + ) + + +def _decode_prefix(decoded: bytes) -> tuple[int, int]: + first = decoded[0] + if first & 0b1000_0000 != 0: + raise SampError(f"ss58 prefix unsupported: {first}") + if first & 0b0100_0000 == 0: + return first, 1 + if len(decoded) < 2: + raise SampError("ss58 too short") + second = decoded[1] + prefix = ((first & 0b0011_1111) << 2) | (second >> 6) | ((second & 0b0011_1111) << 8) + return prefix, 2 def _bs58_decode(s: str) -> bytes | None: diff --git a/python/samp/types.py b/python/samp/types.py index eb7491c..fe5129d 100644 --- a/python/samp/types.py +++ b/python/samp/types.py @@ -151,7 +151,7 @@ def view_tag_from_int(n: int) -> ViewTag: def ss58_prefix_from_int(n: int) -> Ss58Prefix: - if not 0 <= n <= 63: + if not 0 <= n <= 16_383: raise SampError(f"ss58 prefix unsupported: {n}") return Ss58Prefix(n) diff --git a/python/tests/test_ss58.py b/python/tests/test_ss58.py index c6e0436..4ddaa4d 100644 --- a/python/tests/test_ss58.py +++ b/python/tests/test_ss58.py @@ -1,9 +1,14 @@ from __future__ import annotations +import json +from pathlib import Path + import pytest from samp import SampError, Ss58Address, pubkey_from_bytes, ss58_prefix_from_int +VECTORS_PATH = Path(__file__).resolve().parent.parent.parent / "e2e" / "test-vectors.json" + def _alice_pk() -> bytes: return bytes.fromhex( @@ -51,8 +56,22 @@ def test_ss58_decode_empty(): def test_ss58_prefix_boundary(): assert ss58_prefix_from_int(63) == 63 + assert ss58_prefix_from_int(64) == 64 + assert ss58_prefix_from_int(16_383) == 16_383 with pytest.raises(SampError): - ss58_prefix_from_int(64) + ss58_prefix_from_int(16_384) + + +def test_ss58_vectors_round_trip_boundary_prefixes(): + vectors = json.loads(VECTORS_PATH.read_text())["ss58"] + pk = pubkey_from_bytes(bytes.fromhex(vectors["pubkey"].removeprefix("0x"))) + for case in vectors["cases"]: + prefix = ss58_prefix_from_int(case["prefix"]) + addr = Ss58Address.encode(pk, prefix) + assert addr.as_str() == case["address"] + decoded = Ss58Address.parse(case["address"]) + assert bytes(decoded.pubkey()) == bytes(pk) + assert int(decoded.prefix()) == case["prefix"] def test_ss58_decode_invalid_base58_char(): diff --git a/rust/src/error.rs b/rust/src/error.rs index 645886c..fd471fd 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -32,10 +32,7 @@ impl fmt::Display for SampError { Self::ExtIndexOverflow(n) => write!(f, "ext index {n} exceeds u16::MAX"), Self::InvalidCapsules(n) => write!(f, "capsules length {n} not a multiple of 33"), Self::Ss58PrefixUnsupported(p) => { - write!( - f, - "SS58 prefix {p} requires two-byte encoding (unsupported)" - ) + write!(f, "SS58 prefix {p} exceeds 16383") } Self::Ss58InvalidBase58 => write!(f, "SS58 address contains invalid base58"), Self::Ss58TooShort => write!(f, "SS58 address too short"), diff --git a/rust/src/ss58.rs b/rust/src/ss58.rs index c38145f..9e62c5e 100644 --- a/rust/src/ss58.rs +++ b/rust/src/ss58.rs @@ -4,9 +4,9 @@ use crate::error::SampError; use crate::types::{Pubkey, Ss58Address, Ss58Prefix}; pub fn encode(pubkey: &Pubkey, prefix: Ss58Prefix) -> Ss58Address { - let prefix_byte = u8::try_from(prefix.get()).expect("validated < 64 by Ss58Prefix::new"); - let mut payload = Vec::with_capacity(35); - payload.push(prefix_byte); + let prefix_bytes = encode_prefix(prefix); + let mut payload = Vec::with_capacity(prefix_bytes.len() + 34); + payload.extend_from_slice(&prefix_bytes); payload.extend_from_slice(pubkey.as_bytes()); let mut hasher = blake2::Blake2b512::new(); hasher.update(b"SS58PRE"); @@ -22,14 +22,14 @@ pub fn decode(address: &str) -> Result { if decoded.len() < 35 { return Err(SampError::Ss58TooShort); } - if decoded[0] >= 64 { - return Err(SampError::Ss58PrefixUnsupported(u16::from(decoded[0]))); - } - let prefix_len = 1; + let (prefix_value, prefix_len) = decode_prefix(&decoded)?; let pubkey_end = prefix_len + 32; if decoded.len() < pubkey_end + 2 { return Err(SampError::Ss58TooShort); } + if decoded.len() != pubkey_end + 2 { + return Err(SampError::Ss58BadChecksum); + } let payload = &decoded[..pubkey_end]; let expected_checksum = &decoded[pubkey_end..pubkey_end + 2]; let mut hasher = blake2::Blake2b512::new(); @@ -41,7 +41,7 @@ pub fn decode(address: &str) -> Result { } let mut pk = [0u8; 32]; pk.copy_from_slice(&decoded[prefix_len..pubkey_end]); - let prefix = Ss58Prefix::new(u16::from(decoded[0]))?; + let prefix = Ss58Prefix::new(prefix_value)?; Ok(Ss58Address::from_parts( address.to_string(), Pubkey::from_bytes(pk), @@ -49,6 +49,35 @@ pub fn decode(address: &str) -> Result { )) } +fn encode_prefix(prefix: Ss58Prefix) -> Vec { + let value = prefix.get(); + if value < 64 { + return vec![value as u8]; + } + vec![ + (((value & 0b0000_0000_1111_1100) >> 2) as u8) | 0b0100_0000, + ((value >> 8) as u8) | (((value & 0b0000_0000_0000_0011) as u8) << 6), + ] +} + +fn decode_prefix(decoded: &[u8]) -> Result<(u16, usize), SampError> { + let first = decoded[0]; + if first & 0b1000_0000 != 0 { + return Err(SampError::Ss58PrefixUnsupported(u16::from(first))); + } + if first & 0b0100_0000 == 0 { + return Ok((u16::from(first), 1)); + } + if decoded.len() < 2 { + return Err(SampError::Ss58TooShort); + } + let second = decoded[1]; + let value = (u16::from(first & 0b0011_1111) << 2) + | (u16::from(second >> 6)) + | (u16::from(second & 0b0011_1111) << 8); + Ok((value, 2)) +} + fn bs58_decode(input: &str) -> Result, ()> { const ALPHABET: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; let mut bytes = vec![0u8]; diff --git a/rust/src/types.rs b/rust/src/types.rs index cd1acce..57d5680 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -489,7 +489,7 @@ impl Ss58Prefix { pub const KUSAMA: Self = Self(2); pub fn new(value: u16) -> Result { - if value > 63 { + if value > 16_383 { return Err(SampError::Ss58PrefixUnsupported(value)); } Ok(Self(value)) diff --git a/rust/tests/conformance.rs b/rust/tests/conformance.rs index ae5fdad..3d3b722 100644 --- a/rust/tests/conformance.rs +++ b/rust/tests/conformance.rs @@ -139,6 +139,18 @@ struct NegativeCases { truncated_encrypted: String, } +#[derive(Deserialize)] +struct Ss58CaseVec { + prefix: u16, + address: String, +} + +#[derive(Deserialize)] +struct Ss58Vec { + pubkey: String, + cases: Vec, +} + #[derive(Deserialize)] struct TestVectors { alice: KeypairVec, @@ -153,6 +165,7 @@ struct TestVectors { group_message: GroupMsgVec, edge_cases: EdgeCases, negative_cases: NegativeCases, + ss58: Ss58Vec, } fn load_vectors() -> TestVectors { @@ -554,8 +567,8 @@ fn ss58_prefix_63_valid() { } #[test] -fn ss58_prefix_64_invalid() { - assert!(Ss58Prefix::new(64).is_err()); +fn ss58_prefix_64_valid() { + assert!(Ss58Prefix::new(64).is_ok()); } // --- Phase 2: Types + Secret tests --- @@ -683,7 +696,8 @@ fn group_encrypt_single_member() { }; let member_scalar = encryption::sr25519_signing_scalar(&member_seed); - let recovered = encryption::decrypt_from_group(&content, &nonce, &member_scalar, Some(1)).unwrap(); + let recovered = + encryption::decrypt_from_group(&content, &nonce, &member_scalar, Some(1)).unwrap(); assert_eq!(recovered.as_bytes(), plaintext.as_bytes()); } @@ -747,7 +761,10 @@ fn channel_create_round_trip() { #[test] fn content_type_application_round_trip() { assert_eq!(ContentType::Application(0x18).to_byte(), 0x18); - assert_eq!(ContentType::from_byte(0x18).unwrap(), ContentType::Application(0x18)); + assert_eq!( + ContentType::from_byte(0x18).unwrap(), + ContentType::Application(0x18) + ); } #[test] @@ -979,7 +996,8 @@ fn type_ss58_prefix_round_trip() { assert_eq!(Ss58Prefix::SUBSTRATE_GENERIC.get(), 42); assert_eq!(Ss58Prefix::POLKADOT.get(), 0); assert_eq!(Ss58Prefix::KUSAMA.get(), 2); - assert!(Ss58Prefix::new(64).is_err()); + assert_eq!(Ss58Prefix::new(16_383).unwrap().get(), 16_383); + assert!(Ss58Prefix::new(16_384).is_err()); } #[test] @@ -1207,7 +1225,10 @@ fn decode_remark_channel_body() { BlockRef::ZERO, "hello", ); - let Remark::Channel { body, channel_ref, .. } = decode_remark(&remark).unwrap() else { + let Remark::Channel { + body, channel_ref, .. + } = decode_remark(&remark).unwrap() + else { panic!("expected Channel"); }; assert_eq!(body, "hello"); @@ -1250,7 +1271,10 @@ fn decode_group_content_valid() { #[test] fn encode_group_members_round_trip() { - let pubs = vec![Pubkey::from_bytes([0xAA; 32]), Pubkey::from_bytes([0xBB; 32])]; + let pubs = vec![ + Pubkey::from_bytes([0xAA; 32]), + Pubkey::from_bytes([0xBB; 32]), + ]; let encoded = encode_group_members(&pubs); let (decoded, remaining) = decode_group_members(&encoded).unwrap(); assert_eq!(decoded.len(), 2); @@ -1337,7 +1361,17 @@ fn scale_decode_compact_modes() { #[test] fn scale_encode_compact_round_trip() { - for val in [0u64, 1, 63, 64, 16383, 16384, (1 << 30) - 1, 1 << 30, u64::MAX] { + for val in [ + 0u64, + 1, + 63, + 64, + 16383, + 16384, + (1 << 30) - 1, + 1 << 30, + u64::MAX, + ] { let mut buf = Vec::new(); samp::encode_compact(val, &mut buf); let (decoded, _) = samp::decode_compact(&buf).unwrap(); @@ -1400,13 +1434,25 @@ fn build_and_extract_extrinsic_round_trip() { #[test] fn content_type_from_byte_all_known() { assert_eq!(ContentType::from_byte(0x10).unwrap(), ContentType::Public); - assert_eq!(ContentType::from_byte(0x11).unwrap(), ContentType::Encrypted); + assert_eq!( + ContentType::from_byte(0x11).unwrap(), + ContentType::Encrypted + ); assert_eq!(ContentType::from_byte(0x12).unwrap(), ContentType::Thread); - assert_eq!(ContentType::from_byte(0x13).unwrap(), ContentType::ChannelCreate); + assert_eq!( + ContentType::from_byte(0x13).unwrap(), + ContentType::ChannelCreate + ); assert_eq!(ContentType::from_byte(0x14).unwrap(), ContentType::Channel); assert_eq!(ContentType::from_byte(0x15).unwrap(), ContentType::Group); - assert_eq!(ContentType::from_byte(0x18).unwrap(), ContentType::Application(0x18)); - assert_eq!(ContentType::from_byte(0x1F).unwrap(), ContentType::Application(0x1F)); + assert_eq!( + ContentType::from_byte(0x18).unwrap(), + ContentType::Application(0x18) + ); + assert_eq!( + ContentType::from_byte(0x1F).unwrap(), + ContentType::Application(0x1F) + ); } #[test] @@ -1556,11 +1602,20 @@ fn metadata_error_display_all_variants() { Error::UnknownStorageEntryType(99), Error::UnknownPrimitive(99), Error::InvalidOptionTag(99), - Error::NonSequential { got: 5, expected: 3 }, + Error::NonSequential { + got: 5, + expected: 3, + }, Error::TypeIdMissing(42), - Error::Shape { ctx: "foo", kind: "bar" }, + Error::Shape { + ctx: "foo", + kind: "bar", + }, Error::VariableWidth(7), - Error::StorageNotFound { pallet: "P".into(), entry: "E".into() }, + Error::StorageNotFound { + pallet: "P".into(), + entry: "E".into(), + }, Error::FieldNotFound { field: "f".into() }, Error::AccountInfoShort { need: 32, got: 16 }, ]; @@ -1587,10 +1642,7 @@ fn error_table_from_entries_and_humanize() { variant: "InsufficientBalance".into(), doc: String::new(), }; - let table = ErrorTable::from_entries([ - ((0, 0), entry_with_doc), - ((1, 0), entry_no_doc), - ]); + let table = ErrorTable::from_entries([((0, 0), entry_with_doc), ((1, 0), entry_no_doc)]); // humanize with doc let h = table.humanize(0, 0).unwrap(); assert!(h.contains("System::BadOrigin")); @@ -1598,7 +1650,10 @@ fn error_table_from_entries_and_humanize() { // humanize without doc — format is "Pallet::Variant" (no trailing ": doc") let h2 = table.humanize(1, 0).unwrap(); assert!(h2.contains("Balances::InsufficientBalance")); - assert!(!h2.contains(": "), "no-doc variant should not have ': ' suffix"); + assert!( + !h2.contains(": "), + "no-doc variant should not have ': ' suffix" + ); // unknown assert!(table.humanize(99, 99).is_none()); } @@ -1623,7 +1678,10 @@ fn error_table_iter() { #[test] fn storage_layout_decode_uint_valid() { use samp::metadata::StorageLayout; - let layout = StorageLayout { offset: 2, width: 4 }; + let layout = StorageLayout { + offset: 2, + width: 4, + }; let data = vec![0x00, 0x00, 0x42, 0x00, 0x00, 0x00, 0xFF]; let val = layout.decode_uint(&data).unwrap(); assert_eq!(val, 0x42); @@ -1632,10 +1690,16 @@ fn storage_layout_decode_uint_valid() { #[test] fn storage_layout_decode_uint_short_data() { use samp::metadata::StorageLayout; - let layout = StorageLayout { offset: 0, width: 8 }; + let layout = StorageLayout { + offset: 0, + width: 8, + }; let data = vec![0x01, 0x02]; let err = layout.decode_uint(&data).unwrap_err(); - assert!(matches!(err, samp::metadata::Error::AccountInfoShort { .. })); + assert!(matches!( + err, + samp::metadata::Error::AccountInfoShort { .. } + )); } // --- metadata.rs: Metadata::errors() --- @@ -1719,7 +1783,7 @@ fn extract_call_with_mortal_era() { let mut patched = bytes.to_vec(); // Set era to non-zero value (mortal era encoding: 2 bytes) patched[era_offset] = 0x40; // non-zero era first byte - // Insert a second era byte + // Insert a second era byte patched.insert(era_offset + 1, 0x00); // Update the compact length prefix let new_payload_len = patched.len() - prefix_len; @@ -1735,28 +1799,26 @@ fn extract_call_with_mortal_era() { let _ = result; } -// --- ss58.rs: decode with prefix >= 64 --- +// --- ss58.rs: one-byte and two-byte prefixes --- #[test] -fn ss58_decode_high_prefix_rejected() { - // Encode a valid address then re-encode the payload with a high prefix byte - // to trigger the Ss58PrefixUnsupported path in decode - use blake2::Digest; - let pk = [0xAAu8; 32]; - let prefix_byte: u8 = 64; // >= 64, rejected - let mut payload = Vec::with_capacity(35); - payload.push(prefix_byte); - payload.extend_from_slice(&pk); - let mut hasher = blake2::Blake2b512::new(); - hasher.update(b"SS58PRE"); - hasher.update(&payload); - let hash = hasher.finalize(); - payload.extend_from_slice(&hash[..2]); +fn ss58_vectors_round_trip_boundary_prefixes() { + let vectors = load_vectors(); + let pubkey = pubkey(&vectors.ss58.pubkey); + for case in vectors.ss58.cases { + let prefix = Ss58Prefix::new(case.prefix).unwrap(); + let encoded = pubkey.to_ss58(prefix); + assert_eq!(encoded.as_str(), case.address); + let decoded = Ss58Address::parse(&case.address).unwrap(); + assert_eq!(*decoded.pubkey(), pubkey); + assert_eq!(decoded.prefix(), prefix); + } +} - // Base58 encode manually via encode then decode - // We can't easily bs58 encode here, but we can test via the existing parse path - // Instead, just test that Ss58Prefix::new(64) fails - assert!(Ss58Prefix::new(64).is_err()); +#[test] +fn ss58_prefix_16383_valid_16384_invalid() { + assert!(Ss58Prefix::new(16_383).is_ok()); + assert!(Ss58Prefix::new(16_384).is_err()); } // ===== Coverage gap tests ===== @@ -2178,16 +2240,14 @@ fn decrypt_from_group_trial_aead_exhausted_fails() { assert!(encryption::decrypt_from_group(&content, &nonce, &wrong_scalar, None).is_err()); } -// --- ss58.rs: decode with prefix byte >= 64 (line 26) --- +// --- ss58.rs: decode with unsupported prefix marker --- #[test] -fn ss58_decode_prefix_64_in_payload() { - // Manually construct a base58-encoded payload where the first decoded byte is 64 - // The simplest way: encode an address with prefix 63 (valid), then try to decode - // a corrupted version where the prefix byte becomes 64 +fn ss58_decode_reserved_high_bit_prefix_marker() { + // Manually construct a base58-encoded payload with a reserved high-bit prefix marker. use blake2::Digest; let pk = [0xBB; 32]; - let prefix_byte: u8 = 64; + let prefix_byte: u8 = 128; let mut payload = vec![prefix_byte]; payload.extend_from_slice(&pk); let mut hasher = blake2::Blake2b512::new(); diff --git a/typescript/src/ss58.ts b/typescript/src/ss58.ts index aab4721..88c6303 100644 --- a/typescript/src/ss58.ts +++ b/typescript/src/ss58.ts @@ -65,15 +65,45 @@ function ss58Checksum(payload: Uint8Array): Uint8Array { return h.digest(); } +function encodePrefix(prefix: Ss58Prefix): Uint8Array { + const value = Ss58Prefix.get(prefix); + if (value < 64) return new Uint8Array([value]); + return new Uint8Array([ + ((value & 0b0000_0000_1111_1100) >> 2) | 0b0100_0000, + (value >> 8) | ((value & 0b0000_0000_0000_0011) << 6), + ]); +} + +function decodePrefix(decoded: Uint8Array): [number, number] { + const first = decoded[0]; + if (first === undefined) throw new SampError("ss58 too short"); + if ((first & 0b1000_0000) !== 0) { + throw new SampError(`ss58 prefix unsupported: ${first}`); + } + if ((first & 0b0100_0000) === 0) return [first, 1]; + const second = decoded[1]; + if (second === undefined) throw new SampError("ss58 too short"); + return [ + ((first & 0b0011_1111) << 2) | (second >> 6) | ((second & 0b0011_1111) << 8), + 2, + ]; +} + function encode(pubkey: Pubkey, prefix: Ss58Prefix): Ss58Address { - const payload = new Uint8Array(1 + 32); - payload[0] = Ss58Prefix.get(prefix); - payload.set(pubkey, 1); + const prefixBytes = encodePrefix(prefix); + const payload = new Uint8Array(prefixBytes.length + 32); + payload.set(prefixBytes, 0); + payload.set(pubkey, prefixBytes.length); const sum = ss58Checksum(payload); - const full = new Uint8Array(35); + const checksum0 = sum[0]; + const checksum1 = sum[1]; + if (checksum0 === undefined || checksum1 === undefined) { + throw new SampError("ss58 checksum unavailable"); + } + const full = new Uint8Array(payload.length + 2); full.set(payload, 0); - full[33] = sum[0] ?? 0; - full[34] = sum[1] ?? 0; + full[payload.length] = checksum0; + full[payload.length + 1] = checksum1; return Ss58Address.fromParts(bs58Encode(full), pubkey, prefix); } @@ -81,18 +111,18 @@ function parse(s: string): Ss58Address { const decoded = bs58Decode(s); if (decoded === null) throw new SampError("ss58 invalid base58"); if (decoded.length < 35) throw new SampError("ss58 too short"); - const prefixByte = decoded[0]; - if (prefixByte === undefined || prefixByte >= 64) { - throw new SampError(`ss58 prefix unsupported: ${prefixByte ?? -1}`); - } - const payload = decoded.subarray(0, 33); - const expected = decoded.subarray(33, 35); + const [prefixValue, prefixLen] = decodePrefix(decoded); + const pubkeyEnd = prefixLen + 32; + if (decoded.length < pubkeyEnd + 2) throw new SampError("ss58 too short"); + if (decoded.length !== pubkeyEnd + 2) throw new SampError("ss58 bad checksum"); + const payload = decoded.subarray(0, pubkeyEnd); + const expected = decoded.subarray(pubkeyEnd, pubkeyEnd + 2); const sum = ss58Checksum(payload); if (sum[0] !== expected[0] || sum[1] !== expected[1]) { throw new SampError("ss58 bad checksum"); } - const pubkey = Pubkey.fromBytes(decoded.slice(1, 33)); - const prefix = Ss58Prefix.from(prefixByte); + const pubkey = Pubkey.fromBytes(decoded.slice(prefixLen, pubkeyEnd)); + const prefix = Ss58Prefix.from(prefixValue); return Ss58Address.fromParts(s, pubkey, prefix); } diff --git a/typescript/src/types.ts b/typescript/src/types.ts index 008c3c2..35a4691 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -238,7 +238,7 @@ export const Ss58Prefix = { POLKADOT: 0 as Brand, KUSAMA: 2 as Brand, from(n: number): Ss58Prefix { - if (!Number.isInteger(n) || n < 0 || n > 63) { + if (!Number.isInteger(n) || n < 0 || n > 16_383) { throw new SampError(`ss58 prefix unsupported: ${n}`); } return n as Ss58Prefix; diff --git a/typescript/test/ss58.test.ts b/typescript/test/ss58.test.ts index 2e3daff..bef63bc 100644 --- a/typescript/test/ss58.test.ts +++ b/typescript/test/ss58.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { blake2b } from "@noble/hashes/blake2b"; import { Ss58Address, Ss58Prefix, Pubkey } from "../src/index.js"; const testPubkey = Pubkey.fromBytes( @@ -8,6 +11,13 @@ const testPubkey = Pubkey.fromBytes( 0x9a, 0x56, 0x84, 0xe7, 0xa5, 0x6d, 0xa2, 0x7d, ]), ); +const vectors = JSON.parse( + readFileSync(resolve(__dirname, "../../e2e/test-vectors.json"), "utf-8"), +); + +function h(s: string): Uint8Array { + return Uint8Array.from(Buffer.from(s.replace(/^0x/, ""), "hex")); +} describe("ss58", () => { it("encode + decode round trip (prefix 42)", () => { @@ -43,22 +53,39 @@ describe("ss58", () => { expect(() => Ss58Address.parse("")).toThrow("ss58 too short"); }); - it("prefix boundary: 63 valid, 64 rejected", () => { + it("prefix boundary: 63 and 64 valid, 16384 rejected", () => { const prefix63 = Ss58Prefix.from(63); const addr63 = Ss58Address.encode(testPubkey, prefix63); const parsed = Ss58Address.parse(addr63.asString()); expect(Ss58Prefix.get(parsed.prefix())).toBe(63); - expect(() => Ss58Prefix.from(64)).toThrow(); + const prefix64 = Ss58Prefix.from(64); + const addr64 = Ss58Address.encode(testPubkey, prefix64); + const parsed64 = Ss58Address.parse(addr64.asString()); + expect(Ss58Prefix.get(parsed64.prefix())).toBe(64); + + expect(Ss58Prefix.get(Ss58Prefix.from(16_383))).toBe(16_383); + expect(() => Ss58Prefix.from(16_384)).toThrow(); + }); + + it("matches shared boundary prefix vectors", () => { + const pk = Pubkey.fromBytes(h(vectors.ss58.pubkey)); + for (const c of vectors.ss58.cases as Array<{ prefix: number; address: string }>) { + const prefix = Ss58Prefix.from(c.prefix); + const addr = Ss58Address.encode(pk, prefix); + expect(addr.asString()).toBe(c.address); + const parsed = Ss58Address.parse(c.address); + expect(Buffer.from(parsed.pubkey())).toEqual(Buffer.from(pk)); + expect(Ss58Prefix.get(parsed.prefix())).toBe(c.prefix); + } }); - it("parse rejects address with prefix byte >= 64", () => { - // Manually construct a base58-encoded address with prefix byte 64 - // by encoding raw bytes [64, ...pubkey, checksum] - // The parse function should reject prefix byte >= 64 - const { blake2b } = require("@noble/hashes/blake2b"); + it("parse rejects reserved high-bit prefix marker", () => { + // Manually construct a base58-encoded address with prefix byte 128 + // by encoding raw bytes [128, ...pubkey, checksum] + // The parse function should reject the reserved high-bit marker. const payload = new Uint8Array(33); - payload[0] = 64; + payload[0] = 128; payload.set(testPubkey, 1); const SS58PRE = new TextEncoder().encode("SS58PRE"); const h = blake2b.create({ dkLen: 64 }); diff --git a/typescript/test/types.test.ts b/typescript/test/types.test.ts index ee7cd26..72ab7a6 100644 --- a/typescript/test/types.test.ts +++ b/typescript/test/types.test.ts @@ -254,10 +254,12 @@ describe("Ss58Prefix", () => { it("valid range works", () => { expect(Ss58Prefix.get(Ss58Prefix.from(0))).toBe(0); expect(Ss58Prefix.get(Ss58Prefix.from(63))).toBe(63); + expect(Ss58Prefix.get(Ss58Prefix.from(64))).toBe(64); + expect(Ss58Prefix.get(Ss58Prefix.from(16_383))).toBe(16_383); }); - it(">= 64 throws", () => { - expect(() => Ss58Prefix.from(64)).toThrow(SampError); + it("> 16383 throws", () => { + expect(() => Ss58Prefix.from(16_384)).toThrow(SampError); }); it("negative throws", () => { From 5aefa2c1e3253baabcd5d9023f5d4f4efb42f163 Mon Sep 17 00:00:00 2001 From: Maciej Kula Date: Wed, 20 May 2026 06:54:51 +0200 Subject: [PATCH 2/3] add SS58 coverage tests --- go/ss58_test.go | 24 +++++++++++++++++++++--- python/tests/test_ss58.py | 32 +++++++++++++++++++++++++++++++- rust/src/ss58.rs | 29 +++++++++++++++++++++++++++++ typescript/src/ss58.ts | 9 ++------- 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/go/ss58_test.go b/go/ss58_test.go index d7d652f..556aaec 100644 --- a/go/ss58_test.go +++ b/go/ss58_test.go @@ -66,9 +66,8 @@ func TestSs58DecodeNonASCII(t *testing.T) { } func TestSs58DecodeUnsupportedPrefix(t *testing.T) { - // Ensure malformed high-bit prefix markers fail without panicking. - _, err := Ss58AddressParse("2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") - require.Error(t, err) + _, _, err := ss58DecodePrefix([]byte{0b10000000}) + require.ErrorIs(t, err, ErrSs58PrefixUnsupported) } func TestBs58EncodeEmpty(t *testing.T) { @@ -85,6 +84,25 @@ func TestBs58DecodeInvalidCharacter(t *testing.T) { require.False(t, ok) } +func TestSs58DecodeTwoBytePrefixTooShort(t *testing.T) { + _, _, err := ss58DecodePrefix([]byte{0b01000000}) + require.ErrorIs(t, err, ErrSs58TooShort) +} + +func TestSs58DecodeTwoBytePayloadMissingChecksumByte(t *testing.T) { + raw := append([]byte{0b01000000, 0}, make([]byte, 33)...) + + _, err := Ss58AddressParse(bs58Encode(raw)) + require.ErrorIs(t, err, ErrSs58TooShort) +} + +func TestSs58DecodeExtraPayloadBytes(t *testing.T) { + raw := append([]byte{42}, make([]byte, 35)...) + + _, err := Ss58AddressParse(bs58Encode(raw)) + require.ErrorIs(t, err, ErrSs58BadChecksum) +} + func TestSs58PrefixBoundary(t *testing.T) { prefix63, err := Ss58PrefixNew(63) require.NoError(t, err) diff --git a/python/tests/test_ss58.py b/python/tests/test_ss58.py index 4ddaa4d..7f9456e 100644 --- a/python/tests/test_ss58.py +++ b/python/tests/test_ss58.py @@ -81,10 +81,40 @@ def test_ss58_decode_invalid_base58_char(): def test_ss58_decode_high_unicode(): with pytest.raises(SampError): - Ss58Address.parse("\u00ff" * 48) + Ss58Address.parse("\u0100" * 48) def test_ss58_encode_empty_data(): from samp.ss58 import _bs58_encode assert _bs58_encode(b"") == "" + + +def test_ss58_decode_rejects_two_byte_prefix_without_second_byte(): + from samp.ss58 import _decode_prefix + + with pytest.raises(SampError, match="too short"): + _decode_prefix(bytes([0b0100_0000])) + + +def test_ss58_decode_rejects_reserved_high_bit_prefix(): + from samp.ss58 import _decode_prefix + + with pytest.raises(SampError, match="prefix unsupported"): + _decode_prefix(bytes([0b1000_0000])) + + +def test_ss58_decode_rejects_two_byte_payload_missing_checksum_byte(): + from samp.ss58 import _bs58_encode + + raw = bytes([0b0100_0000, 0]) + bytes(32) + bytes([0]) + with pytest.raises(SampError, match="too short"): + Ss58Address.parse(_bs58_encode(raw)) + + +def test_ss58_decode_rejects_extra_payload_bytes(): + from samp.ss58 import _bs58_encode + + raw = bytes([42]) + bytes(32) + bytes(3) + with pytest.raises(SampError, match="bad checksum"): + Ss58Address.parse(_bs58_encode(raw)) diff --git a/rust/src/ss58.rs b/rust/src/ss58.rs index 9e62c5e..5bbbc19 100644 --- a/rust/src/ss58.rs +++ b/rust/src/ss58.rs @@ -137,3 +137,32 @@ fn bs58_encode(data: &[u8]) -> String { } result } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_prefix_rejects_truncated_two_byte_prefix() { + assert!(matches!( + decode_prefix(&[0b0100_0000]), + Err(SampError::Ss58TooShort) + )); + } + + #[test] + fn decode_rejects_extra_payload_bytes() { + let mut raw = vec![42u8]; + raw.extend_from_slice(&[1u8; 35]); + + assert!(matches!( + decode(&bs58_encode(&raw)), + Err(SampError::Ss58BadChecksum) + )); + } + + #[test] + fn bs58_encode_empty_is_empty() { + assert_eq!(bs58_encode(&[]), ""); + } +} diff --git a/typescript/src/ss58.ts b/typescript/src/ss58.ts index 88c6303..fd8deaf 100644 --- a/typescript/src/ss58.ts +++ b/typescript/src/ss58.ts @@ -95,15 +95,10 @@ function encode(pubkey: Pubkey, prefix: Ss58Prefix): Ss58Address { payload.set(prefixBytes, 0); payload.set(pubkey, prefixBytes.length); const sum = ss58Checksum(payload); - const checksum0 = sum[0]; - const checksum1 = sum[1]; - if (checksum0 === undefined || checksum1 === undefined) { - throw new SampError("ss58 checksum unavailable"); - } const full = new Uint8Array(payload.length + 2); full.set(payload, 0); - full[payload.length] = checksum0; - full[payload.length + 1] = checksum1; + full[payload.length] = sum[0]!; + full[payload.length + 1] = sum[1]!; return Ss58Address.fromParts(bs58Encode(full), pubkey, prefix); } From c1abf11583ffc28e1bba0bc31f8862affb8e2b29 Mon Sep 17 00:00:00 2001 From: Maciej Kula Date: Wed, 20 May 2026 07:49:51 +0200 Subject: [PATCH 3/3] cover SS58 parser edges --- go/ss58.go | 5 +- go/ss58_test.go | 18 ++++++++ typescript/src/ss58.ts | 6 +-- typescript/test/ss58.test.ts | 90 +++++++++++++++++++++--------------- 4 files changed, 75 insertions(+), 44 deletions(-) diff --git a/go/ss58.go b/go/ss58.go index a265a86..f3e8d8e 100644 --- a/go/ss58.go +++ b/go/ss58.go @@ -49,10 +49,7 @@ func ss58Decode(s string) (Ss58Address, error) { } var pk [32]byte copy(pk[:], decoded[prefixLen:pubkeyEnd]) - prefix, err := Ss58PrefixNew(prefixValue) - if err != nil { - return Ss58Address{}, err - } + prefix := Ss58Prefix{prefixValue} return Ss58Address{address: s, pubkey: Pubkey{pk}, prefix: prefix}, nil } diff --git a/go/ss58_test.go b/go/ss58_test.go index 556aaec..2a76359 100644 --- a/go/ss58_test.go +++ b/go/ss58_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "golang.org/x/crypto/blake2b" ) var testPubkey = PubkeyFromBytes([32]byte{ @@ -12,6 +13,15 @@ var testPubkey = PubkeyFromBytes([32]byte{ 0x9a, 0x56, 0x84, 0xe7, 0xa5, 0x6d, 0xa2, 0x7d, }) +func rawSs58Address(payload []byte) string { + h, _ := blake2b.New512(nil) + h.Write([]byte("SS58PRE")) + h.Write(payload) + sum := h.Sum(nil) + raw := append(append([]byte{}, payload...), sum[:2]...) + return bs58Encode(raw) +} + func TestSs58EncodeDecodeRoundTrip(t *testing.T) { addr := Ss58AddressEncode(testPubkey, Ss58SubstrateGeneric) parsed, err := Ss58AddressParse(addr.String()) @@ -70,6 +80,14 @@ func TestSs58DecodeUnsupportedPrefix(t *testing.T) { require.ErrorIs(t, err, ErrSs58PrefixUnsupported) } +func TestSs58ParseUnsupportedPrefix(t *testing.T) { + pk := testPubkey.Bytes() + payload := append([]byte{0b10000000}, pk[:]...) + + _, err := Ss58AddressParse(rawSs58Address(payload)) + require.ErrorIs(t, err, ErrSs58PrefixUnsupported) +} + func TestBs58EncodeEmpty(t *testing.T) { result := bs58Encode([]byte{}) require.Equal(t, "", result) diff --git a/typescript/src/ss58.ts b/typescript/src/ss58.ts index fd8deaf..23288f1 100644 --- a/typescript/src/ss58.ts +++ b/typescript/src/ss58.ts @@ -75,14 +75,12 @@ function encodePrefix(prefix: Ss58Prefix): Uint8Array { } function decodePrefix(decoded: Uint8Array): [number, number] { - const first = decoded[0]; - if (first === undefined) throw new SampError("ss58 too short"); + const first = decoded[0]!; if ((first & 0b1000_0000) !== 0) { throw new SampError(`ss58 prefix unsupported: ${first}`); } if ((first & 0b0100_0000) === 0) return [first, 1]; - const second = decoded[1]; - if (second === undefined) throw new SampError("ss58 too short"); + const second = decoded[1]!; return [ ((first & 0b0011_1111) << 2) | (second >> 6) | ((second & 0b0011_1111) << 8), 2, diff --git a/typescript/test/ss58.test.ts b/typescript/test/ss58.test.ts index bef63bc..2fb4f65 100644 --- a/typescript/test/ss58.test.ts +++ b/typescript/test/ss58.test.ts @@ -19,6 +19,45 @@ function h(s: string): Uint8Array { return Uint8Array.from(Buffer.from(s.replace(/^0x/, ""), "hex")); } +function bs58Encode(data: Uint8Array): string { + const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + const digits: number[] = [0]; + for (const b of data) { + let carry = b; + for (let i = 0; i < digits.length; i++) { + carry += (digits[i] ?? 0) * 256; + digits[i] = carry % 58; + carry = Math.floor(carry / 58); + } + while (carry > 0) { + digits.push(carry % 58); + carry = Math.floor(carry / 58); + } + } + let encoded = ""; + for (const b of data) { + if (b === 0) encoded += alphabet[0]; + else break; + } + for (let i = digits.length - 1; i >= 0; i--) { + encoded += alphabet[digits[i] ?? 0]; + } + return encoded; +} + +function ss58AddressFromPayload(payload: Uint8Array): string { + const ss58pre = new TextEncoder().encode("SS58PRE"); + const hasher = blake2b.create({ dkLen: 64 }); + hasher.update(ss58pre); + hasher.update(payload); + const sum = hasher.digest(); + const full = new Uint8Array(payload.length + 2); + full.set(payload, 0); + full[payload.length] = sum[0]!; + full[payload.length + 1] = sum[1]!; + return bs58Encode(full); +} + describe("ss58", () => { it("encode + decode round trip (prefix 42)", () => { const addr = Ss58Address.encode(testPubkey, Ss58Prefix.SUBSTRATE_GENERIC); @@ -81,45 +120,24 @@ describe("ss58", () => { }); it("parse rejects reserved high-bit prefix marker", () => { - // Manually construct a base58-encoded address with prefix byte 128 - // by encoding raw bytes [128, ...pubkey, checksum] - // The parse function should reject the reserved high-bit marker. const payload = new Uint8Array(33); payload[0] = 128; payload.set(testPubkey, 1); - const SS58PRE = new TextEncoder().encode("SS58PRE"); - const h = blake2b.create({ dkLen: 64 }); - h.update(SS58PRE); - h.update(payload); - const sum = h.digest(); - const full = new Uint8Array(35); - full.set(payload, 0); - full[33] = sum[0]; - full[34] = sum[1]; - // bs58 encode - const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - const digits: number[] = [0]; - for (const b of full) { - let carry = b; - for (let i = 0; i < digits.length; i++) { - carry += (digits[i] ?? 0) * 256; - digits[i] = carry % 58; - carry = Math.floor(carry / 58); - } - while (carry > 0) { - digits.push(carry % 58); - carry = Math.floor(carry / 58); - } - } - let encoded = ""; - for (const b of full) { - if (b === 0) encoded += ALPHABET[0]; - else break; - } - for (let i = digits.length - 1; i >= 0; i--) { - encoded += ALPHABET[digits[i] ?? 0]; - } - expect(() => Ss58Address.parse(encoded)).toThrow("ss58 prefix unsupported"); + expect(() => Ss58Address.parse(ss58AddressFromPayload(payload))).toThrow( + "ss58 prefix unsupported", + ); + }); + + it("parse rejects two-byte prefix payloads missing a checksum byte", () => { + const raw = new Uint8Array(35); + raw[0] = 0b0100_0000; + expect(() => Ss58Address.parse(bs58Encode(raw))).toThrow("ss58 too short"); + }); + + it("parse rejects extra payload bytes", () => { + const raw = new Uint8Array(36); + raw[0] = 42; + expect(() => Ss58Address.parse(bs58Encode(raw))).toThrow("ss58 bad checksum"); }); it("decode invalid base58 character throws", () => {