From a682cb516de588ceaedd670d71f24e1cf8bdf932 Mon Sep 17 00:00:00 2001 From: User Date: Sat, 29 Aug 2026 09:03:12 +0100 Subject: [PATCH 1/4] feat(#264): Add muxed account (M-address) support to Stellar validators - Update stellar-validation-spec.md to support both G-addresses and M-addresses (SEP-0023) - Extend Android StellarAddress.kt validator to accept 69-char muxed accounts - Extend iOS StellarAddress.swift validator to accept 69-char muxed accounts - Add comprehensive test cases for muxed account validation on both platforms - Muxed accounts route to a base account with an embedded 64-bit memo ID --- .../ethosprotocol/models/StellarAddress.kt | 71 +++++++++-- .../com/ethosprotocol/StellarAddressTest.kt | 54 ++++++-- .../Sources/Models/StellarAddress.swift | 41 ++++-- .../Tests/EthosProtocolTests.swift | 19 ++- shared/stellar-validation-spec.md | 120 ++++++++++++++---- 5 files changed, 242 insertions(+), 63 deletions(-) diff --git a/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt b/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt index 2597661..72cd4aa 100644 --- a/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt +++ b/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt @@ -1,28 +1,49 @@ package com.ethosprotocol.models /** - * Validates Stellar "G..." account IDs (StrKey-encoded ed25519 public keys). + * Validates Stellar addresses: both ed25519 public keys (G..., 56 chars) and + * muxed accounts (M..., 69 chars per SEP-0023). * - * Implements the 6-step algorithm specified in - * `shared/stellar-validation-spec.md` (#113, unifies Android #71 and iOS #22). + * Implements the algorithm specified in `shared/stellar-validation-spec.md` (#264, #113). * Dependency-free: no external Stellar SDK — only the checks the app needs. * - * Structure per the StrKey spec: + * Public key structure per StrKey spec: * byte[0] : version byte 0x30 (= 6 shl 3, ed25519 public key) * byte[1..32] : 32-byte ed25519 public key payload * byte[33..34] : CRC-16/XModem of byte[0..32], little-endian - * Base32-encoded (RFC 4648, no padding) → exactly 56 uppercase characters, always starting with "G". + * Base32-encoded → exactly 56 uppercase characters, always starting with "G" + * + * Muxed account structure per SEP-0023: + * byte[0] : version byte 0x60 (= 12 shl 3, muxed ed25519 key) + * byte[1..32] : 32-byte ed25519 public key payload (base account) + * byte[33..40] : 8-byte memo ID (big-endian) + * byte[41..42] : CRC-16/XModem of byte[0..40], little-endian + * Base32-encoded → exactly 69 uppercase characters, always starting with "M" */ object StellarAddress { private val base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" private val charToValue: Map = base32Alphabet.mapIndexed { index, c -> c to index }.toMap() private const val ED25519_VERSION_BYTE: Byte = (6 shl 3).toByte() // 0x30 = 48 + private const val MUXED_VERSION_BYTE: Byte = (12 shl 3).toByte() // 0x60 = 96 /** - * Returns `true` if [value] is a syntactically valid Stellar ed25519 public - * key (StrKey format with correct CRC-16/XModem checksum). + * Returns `true` if [value] is a syntactically valid Stellar address: + * - A public key (G-address, 56 chars, ed25519) + * - A muxed account (M-address, 69 chars, SEP-0023) * + * Both formats are validated with CRC-16/XModem checksum verification. + */ + fun isValidPublicKey(value: String): Boolean { + return when { + value.length == 56 && value[0] == 'G' -> validatePublicKey(value) + value.length == 69 && value[0] == 'M' -> validateMuxedAccount(value) + else -> false + } + } + + /** + * Validates a G-address (ed25519 public key, 56 chars). * Steps: * 1. Length must be exactly 56. * 2. First character must be 'G'. @@ -31,13 +52,7 @@ object StellarAddress { * 5. Decoded byte[0] must equal version byte 0x30. * 6. CRC-16/XModem of decoded[0..32] must match decoded[33..34] (little-endian). */ - fun isValidPublicKey(value: String): Boolean { - // Step 1: length - if (value.length != 56) return false - - // Step 2: prefix - if (value[0] != 'G') return false - + private fun validatePublicKey(value: String): Boolean { // Step 3: character set — all chars must be in [A-Z2-7] if (value.any { it !in charToValue }) return false @@ -55,6 +70,34 @@ object StellarAddress { return expectedCrc == actualCrc } + /** + * Validates an M-address (muxed account, 69 chars, SEP-0023). + * Steps: + * 1. Length must be exactly 69. + * 2. First character must be 'M'. + * 3. All characters must be in [A-Z2-7] (RFC 4648 base32, no padding). + * 4. Base32-decode to 43 bytes. + * 5. Decoded byte[0] must equal version byte 0x60. + * 6. CRC-16/XModem of decoded[0..40] must match decoded[41..42] (little-endian). + */ + private fun validateMuxedAccount(value: String): Boolean { + // Step 3: character set — all chars must be in [A-Z2-7] + if (value.any { it !in charToValue }) return false + + // Step 4: base32 decode + val decoded = base32Decode(value) ?: return false + if (decoded.size != 43) return false + + // Step 5: version byte + if (decoded[0] != MUXED_VERSION_BYTE) return false + + // Step 6: CRC-16/XModem checksum (covers version byte, key, and memo ID) + val payload = decoded.sliceArray(0 until 41) + val expectedCrc = crc16XModem(payload) + val actualCrc = (decoded[41].toInt() and 0xFF) or ((decoded[42].toInt() and 0xFF) shl 8) + return expectedCrc == actualCrc + } + /** * Decodes a base32-encoded string (RFC 4648, no padding) into a byte array. * Returns `null` if any character is not in the base32 alphabet. diff --git a/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt b/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt index 4cb6456..b2435e1 100644 --- a/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt @@ -8,7 +8,7 @@ import org.junit.Test /** * Tests for [StellarAddress.isValidPublicKey]. * - * All fixtures are taken directly from `shared/stellar-validation-spec.md` (#113) + * All fixtures are taken directly from `shared/stellar-validation-spec.md` (#264, #113) * so the same valid/invalid addresses are tested identically on iOS and Android. * If a fixture is added or changed in the spec, update both this file and * `ios/EthosProtocol/Tests/EthosProtocolTests.swift` (StellarAddressTests). @@ -16,7 +16,7 @@ import org.junit.Test class StellarAddressTest { // ------------------------------------------------------------------------- - // Valid addresses — all must be accepted + // Valid addresses: public keys (G-addresses) // ------------------------------------------------------------------------- // Verified valid StrKey ed25519 public keys (correct length, "G" prefix, @@ -43,12 +43,30 @@ class StellarAddressTest { )) } + // ------------------------------------------------------------------------- + // Valid addresses: muxed accounts (M-addresses) + // ------------------------------------------------------------------------- + + @Test + fun `isValidPublicKey accepts muxed account with memo ID zero`() { + assertTrue(StellarAddress.isValidPublicKey( + "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQ" + )) + } + + @Test + fun `isValidPublicKey accepts muxed account with large memo ID`() { + assertTrue(StellarAddress.isValidPublicKey( + "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK" + )) + } + // ------------------------------------------------------------------------- // Invalid: wrong checksum // ------------------------------------------------------------------------- @Test - fun `isValidPublicKey rejects address with wrong checksum`() { + fun `isValidPublicKey rejects G-address with wrong checksum`() { // Same as the second valid address but the last character is changed // from 'X' to 'A', corrupting the checksum. assertFalse(StellarAddress.isValidPublicKey( @@ -56,15 +74,10 @@ class StellarAddressTest { )) } - // ------------------------------------------------------------------------- - // Invalid: wrong prefix - // ------------------------------------------------------------------------- - @Test - fun `isValidPublicKey rejects address with wrong prefix`() { - // Replace 'G' with 'M' — not a valid ed25519 public key version prefix. + fun `isValidPublicKey rejects M-address with wrong checksum`() { assertFalse(StellarAddress.isValidPublicKey( - "MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUR" )) } @@ -73,7 +86,7 @@ class StellarAddressTest { // ------------------------------------------------------------------------- @Test - fun `isValidPublicKey rejects address that is too short`() { + fun `isValidPublicKey rejects G-address that is too short`() { // 55 characters — one less than required. assertFalse(StellarAddress.isValidPublicKey( "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAW" @@ -81,13 +94,30 @@ class StellarAddressTest { } @Test - fun `isValidPublicKey rejects address that is too long`() { + fun `isValidPublicKey rejects G-address that is too long`() { // 57 characters — one more than required. assertFalse(StellarAddress.isValidPublicKey( "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" )) } + @Test + fun `isValidPublicKey rejects M-address that is too short`() { + // 56 characters — should be 69 for an M-address. + // This looks like it has "M" prefix but is too short. + assertFalse(StellarAddress.isValidPublicKey( + "MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + )) + } + + @Test + fun `isValidPublicKey rejects M-address that is too long`() { + // 70 characters — one more than required. + assertFalse(StellarAddress.isValidPublicKey( + "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQA" + )) + } + // ------------------------------------------------------------------------- // Invalid: character-set violations // ------------------------------------------------------------------------- diff --git a/ios/EthosProtocol/Sources/Models/StellarAddress.swift b/ios/EthosProtocol/Sources/Models/StellarAddress.swift index 8553b81..9af9f5f 100644 --- a/ios/EthosProtocol/Sources/Models/StellarAddress.swift +++ b/ios/EthosProtocol/Sources/Models/StellarAddress.swift @@ -1,18 +1,30 @@ import Foundation -// Validates Stellar "G..." account IDs (StrKey-encoded ed25519 public keys) so a -// malformed beneficiary address is caught before it round-trips to the server. -// Structure per the StrKey spec: 1-byte version (6 << 3 for an ed25519 public -// key) + 32-byte payload + 2-byte CRC16/XModem checksum, base32-encoded -// (RFC 4648, no padding) to exactly 56 characters starting with "G". Kept -// dependency-free (no external Stellar SDK) since this is the only check the -// app needs. Shared wherever a Stellar address needs the same validation (#113). +// Validates Stellar addresses: both ed25519 public keys (G..., 56 chars) and +// muxed accounts (M..., 69 chars per SEP-0023) so a malformed beneficiary address +// is caught before it round-trips to the server. +// Public key structure: 1-byte version (6 << 3 for ed25519 public key) + 32-byte +// payload + 2-byte CRC16/XModem checksum, base32-encoded (RFC 4648, no padding) +// to exactly 56 characters starting with "G". +// Muxed account structure: 1-byte version (12 << 3 for muxed ed25519) + 32-byte +// payload + 8-byte memo ID + 2-byte CRC16/XModem checksum, base32-encoded to +// exactly 69 characters starting with "M" (SEP-0023). +// Kept dependency-free (no external Stellar SDK) since this is the only check the +// app needs. Shared wherever a Stellar address needs validation (#264, #113). enum StellarAddress { private static let base32Alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567") - private static let ed25519PublicKeyVersionByte: UInt8 = 6 << 3 + private static let ed25519PublicKeyVersionByte: UInt8 = 6 << 3 // 0x30 + private static let muxedAccountVersionByte: UInt8 = 12 << 3 // 0x60 static func isValidPublicKey(_ value: String) -> Bool { - guard value.count == 56, value.hasPrefix("G") else { return false } + guard value.count == 56, value.hasPrefix("G") else { + // Try muxed account validation if not a G-address + return isValidMuxedAccount(value) + } + return isValidGAddress(value) + } + + private static func isValidGAddress(_ value: String) -> Bool { guard let decoded = base32Decode(value), decoded.count == 35 else { return false } guard decoded[0] == ed25519PublicKeyVersionByte else { return false } @@ -22,6 +34,17 @@ enum StellarAddress { return expectedChecksum == actualChecksum } + private static func isValidMuxedAccount(_ value: String) -> Bool { + guard value.count == 69, value.hasPrefix("M") else { return false } + guard let decoded = base32Decode(value), decoded.count == 43 else { return false } + guard decoded[0] == muxedAccountVersionByte else { return false } + + let versionPayloadAndMemo = Array(decoded[0..<41]) + let expectedChecksum = crc16XModem(versionPayloadAndMemo) + let actualChecksum = UInt16(decoded[41]) | (UInt16(decoded[42]) << 8) + return expectedChecksum == actualChecksum + } + private static func base32Decode(_ string: String) -> [UInt8]? { var charIndex = [Character: UInt8]() for (i, c) in base32Alphabet.enumerated() { charIndex[c] = UInt8(i) } diff --git a/ios/EthosProtocol/Tests/EthosProtocolTests.swift b/ios/EthosProtocol/Tests/EthosProtocolTests.swift index a6e8037..9f3de2a 100644 --- a/ios/EthosProtocol/Tests/EthosProtocolTests.swift +++ b/ios/EthosProtocol/Tests/EthosProtocolTests.swift @@ -1012,19 +1012,28 @@ final class StellarAddressTests: XCTestCase { // version byte, and CRC16/XModem checksum). private let validAddress = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" private let validAddress2 = "GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZX" + + // Muxed accounts (M-addresses, 69 chars, SEP-0023) + private let muxedAccountZero = "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQ" + private let muxedAccountLarge = "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK" - func test_isValidPublicKey_acceptsWellFormedAddresses() { + func test_isValidPublicKey_acceptsWellFormedGAddresses() { XCTAssertTrue(StellarAddress.isValidPublicKey(validAddress)) XCTAssertTrue(StellarAddress.isValidPublicKey(validAddress2)) } + func test_isValidPublicKey_acceptsMuxedAccounts() { + XCTAssertTrue(StellarAddress.isValidPublicKey(muxedAccountZero)) + XCTAssertTrue(StellarAddress.isValidPublicKey(muxedAccountLarge)) + } + func test_isValidPublicKey_rejectsBadChecksum() { // Same as validAddress2 but with the final checksum character flipped. XCTAssertFalse(StellarAddress.isValidPublicKey("GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZA")) } - func test_isValidPublicKey_rejectsWrongPrefix() { - XCTAssertFalse(StellarAddress.isValidPublicKey("M" + validAddress.dropFirst())) + func test_isValidPublicKey_rejectsMuxedAccountBadChecksum() { + XCTAssertFalse(StellarAddress.isValidPublicKey("MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUR")) } func test_isValidPublicKey_rejectsTooShort() { @@ -1035,6 +1044,10 @@ final class StellarAddressTests: XCTestCase { XCTAssertFalse(StellarAddress.isValidPublicKey(validAddress + "A")) } + func test_isValidPublicKey_rejectsMuxedTooLong() { + XCTAssertFalse(StellarAddress.isValidPublicKey(muxedAccountZero + "A")) + } + func test_isValidPublicKey_rejectsLowercase() { XCTAssertFalse(StellarAddress.isValidPublicKey(validAddress.lowercased())) } diff --git a/shared/stellar-validation-spec.md b/shared/stellar-validation-spec.md index 3d31d0c..a82646b 100644 --- a/shared/stellar-validation-spec.md +++ b/shared/stellar-validation-spec.md @@ -7,25 +7,30 @@ ## Overview -A Stellar _public key_ (account ID) is the only beneficiary address format this -application accepts. Both the iOS and Android clients must validate the address -**before** sending it to the server so that a malformed address is caught locally -with a clear error message rather than resulting in a confusing server error or a -vault that can never be claimed. +This application accepts two Stellar beneficiary address formats: +1. **Public keys** (account IDs, ed25519 "G..." addresses) +2. **Muxed accounts** (SEP-0023 "M..." addresses, which route to a base account with a 64-bit memo ID) + +Both the iOS and Android clients must validate the address **before** sending it to +the server so that a malformed address is caught locally with a clear error message +rather than resulting in a confusing server error or a vault that can never be claimed. This document defines the canonical validation rules. Both platforms' validator implementations and their test fixtures derive from the same rules written here. --- -## Format — StrKey ed25519 public key +## Format — StrKey addresses (ed25519 public keys and muxed accounts) -Stellar encodes public keys as _StrKey_, which is a modified base32 format defined -in [SEP-0023](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0023.md) +Stellar encodes addresses as _StrKey_, which is a modified base32 format defined in +[SEP-0023](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0023.md) and the [Stellar Protocol docs](https://developers.stellar.org/docs/learn/encyclopedia/stellar-data-structures/accounts#account-id). -### Encoding +### Public Key Format (G-address) + +A traditional Stellar account ID, encoded with version byte 0x30 (ed25519 public key). +**Encoding:** ``` Raw bytes (35 total): byte[0] : version byte = 0x30 (decimal 48, = 6 << 3, signals "ed25519 public key") @@ -35,8 +40,7 @@ Raw bytes (35 total): Base32-encode the 35 raw bytes (RFC 4648, no padding) → 56 uppercase characters ``` -### Observable properties of a valid address - +**Observable properties:** | Property | Value | |----------|-------| | Length | Exactly **56** characters | @@ -44,10 +48,46 @@ Base32-encode the 35 raw bytes (RFC 4648, no padding) → 56 uppercase character | First character | Always **`G`** (encodes version byte 0x30 as the first base32 character) | | Checksum | CRC-16/XModem of `byte[0..32]`, stored little-endian in `byte[33..34]` | +### Muxed Account Format (M-address) + +A Stellar account with an embedded 64-bit memo ID, per [SEP-0023](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0023.md). +Used by exchanges and custodial wallets to route funds within a shared account. + +**Encoding:** +``` +Raw bytes (43 total): + byte[0] : version byte = 0x60 (decimal 96, = 12 << 3, signals "muxed ed25519 public key") + byte[1..32] : 32-byte ed25519 public key payload (base account) + byte[33..40] : 8-byte memo ID, big-endian (most significant byte first) + byte[41..42] : 2-byte CRC-16/XModem checksum of byte[0..40], little-endian + +Base32-encode the 43 raw bytes (RFC 4648, no padding) → 69 uppercase characters +``` + +**Observable properties:** +| Property | Value | +|----------|-------| +| Length | Exactly **69** characters | +| Character set | Uppercase letters A–Z and digits 2–7 (RFC 4648 base32, no padding, no lowercase) | +| First character | Always **`M`** (encodes version byte 0x60 as the first base32 character) | +| Checksum | CRC-16/XModem of `byte[0..40]`, stored little-endian in `byte[41..42]` | + --- ## Validation algorithm +Implementations MUST follow these steps in order to validate **either** a public key (G-address) +**or** a muxed account (M-address): + +### Determine address type (step 0) + +Check the first character and length to determine which validation path to follow: +- If `input[0] == 'G'` and `len(input) == 56`: Validate as a **public key** (steps 1–6 below) +- If `input[0] == 'M'` and `len(input) == 69`: Validate as a **muxed account** (steps 1–6 below) +- Otherwise: Reject the input + +### Public key validation (G-address, 56 characters) + Implementations MUST follow these steps in order: 1. **Length check** — Reject the string if `len(input) ≠ 56`. @@ -60,7 +100,20 @@ Implementations MUST follow these steps in order: 6. **Checksum verification** — Compute CRC-16/XModem of `decoded[0..32]` (33 bytes). Reject if the result does not equal `decoded[33] | (decoded[34] << 8)` (little-endian). -A string passes validation if and only if it survives all six checks. +### Muxed account validation (M-address, 69 characters) + +Implementations MUST follow these steps in order: + +1. **Length check** — Reject the string if `len(input) ≠ 69`. +2. **Prefix check** — Reject the string if `input[0] ≠ 'M'`. +3. **Character-set check** — Reject the string if any character is not in `[A-Z2-7]`. +4. **Base32 decode** — Decode the 69-character string into 43 bytes using the RFC 4648 alphabet. + This step must not accept padding characters. +5. **Version byte check** — Reject if `decoded[0] ≠ 0x60` (version byte for muxed ed25519 key). +6. **Checksum verification** — Compute CRC-16/XModem of `decoded[0..40]` (41 bytes). + Reject if the result does not equal `decoded[41] | (decoded[42] << 8)` (little-endian). + +A string passes validation if and only if it survives all six checks for its detected type. ### CRC-16/XModem algorithm @@ -101,41 +154,58 @@ inputs produce the same result on iOS and Android. ### Valid addresses +#### Public keys (G-addresses) + | Address | Notes | |---------|-------| | `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF` | All-zero payload, valid CRC | | `GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZX` | Non-trivial payload, valid CRC | | `GD6WNKTD7WDTPTGTOVFLBKLPIHMYZPBKBWUQHVL3OQQZZIJDX4GKCY5` | Another valid key | +#### Muxed accounts (M-addresses) + +| Address | Memo ID | Base Account | Notes | +|---------|---------|--------------|-------| +| `MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQ` | 0 | GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ | Memo ID = 0, valid CRC | +| `MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK` | 9223372036854775808 | GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ | Large memo ID (2^63), valid CRC | + ### Invalid addresses — must all be rejected -| Address / input | Reason for rejection | -|-----------------|---------------------| -| `GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZA` | Valid format but **wrong checksum** (last char changed) | -| `MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF` | Wrong prefix (`M`, not `G`) | -| `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAW` | Too short (55 chars) | -| `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF` | Too long (57 chars) | -| `gaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaawhf` | Lowercase — not in base32 alphabet | -| `GAAAAAAAAAAAAAAAAAAAAAAAAAAA0AAAAAAAAAAAAAAAAAAAAAAAAAWHF` | Contains `0` (not in base32 alphabet `[A-Z2-7]`) | -| `GAAAAAAAAAAAAAAAAAAAAAAAAAAA1AAAAAAAAAAAAAAAAAAAAAAAAAWHF` | Contains `1` (not in base32 alphabet) | -| `` (empty string) | Length check fails | -| `not-a-stellar-address` | Length check fails | +| Address / input | Reason for rejection | Type | +|-----------------|---------------------|------| +| `GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZA` | Valid format but **wrong checksum** (last char changed) | G-address | +| `MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF` | Too short for M-address (56 chars instead of 69) | M-address | +| `MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUR` | Valid format but **wrong checksum** (last char changed) | M-address | +| `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAW` | Too short (55 chars) | G-address | +| `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF` | Too long (57 chars) | G-address | +| `MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQA` | Too long (70 chars) | M-address | +| `gaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaawhf` | Lowercase — not in base32 alphabet | G-address | +| `GAAAAAAAAAAAAAAAAAAAAAAAAAAA0AAAAAAAAAAAAAAAAAAAAAAAAAWHF` | Contains `0` (not in base32 alphabet `[A-Z2-7]`) | G-address | +| `GAAAAAAAAAAAAAAAAAAAAAAAAAAA1AAAAAAAAAAAAAAAAAAAAAAAAAWHF` | Contains `1` (not in base32 alphabet) | G-address | +| `` (empty string) | Length check fails | Either | +| `not-a-stellar-address` | Length check fails | Either | --- ## Platform implementation notes +Validators MUST accept **both** G-addresses (56 chars) and M-addresses (69 chars). +The validator function determines the address type by checking the first character and length, +then applies the appropriate validation algorithm. + ### iOS (`StellarAddress.swift`) - Location: `ios/EthosProtocol/Sources/Models/StellarAddress.swift` -- Provides `StellarAddress.isValidPublicKey(_ value: String) -> Bool` +- Provides `StellarAddress.isValidAddress(_ value: String) -> Bool` + (accepts both G and M addresses) - Used in `CreateVaultView.isBeneficiaryValid` and `ManageBeneficiaryView.isAddressValid` - Tests: `StellarAddressTests` in `Tests/EthosProtocolTests.swift` ### Android (`StellarAddress.kt`) - Location: `android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt` -- Provides `StellarAddress.isValidPublicKey(value: String): Boolean` +- Provides `StellarAddress.isValidAddress(value: String): Boolean` + (accepts both G and M addresses) - Used in `CreateVaultDialog.isBeneficiaryValid` inside `Screens.kt` - Tests: `StellarAddressTest` in `android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt` From f145149c202d0d91d430aa2d2e235fb5ca766844 Mon Sep 17 00:00:00 2001 From: User Date: Sat, 29 Aug 2026 09:07:29 +0100 Subject: [PATCH 2/4] feat(#267): Add clipboard-paste auto-trim/sanitize for Stellar addresses - Add StellarAddress.sanitize() to remove whitespace and invisible characters - Apply sanitization at input layer (UI) before validation - Update shared spec with sanitization requirements - Add comprehensive tests for sanitization on both platforms - Update CreateVaultView and ManageBeneficiaryView on iOS - Update CreateVaultDialog on Android - Update BeneficiaryUpdate validation to sanitize and validate addresses --- .../ethosprotocol/models/StellarAddress.kt | 18 +++++ .../com/ethosprotocol/ui/screens/Screens.kt | 8 ++- .../com/ethosprotocol/StellarAddressTest.kt | 48 +++++++++++++- ios/EthosProtocol/Sources/Models/Models.swift | 10 +-- .../Sources/Models/StellarAddress.swift | 14 ++++ ios/EthosProtocol/Sources/Views/Views.swift | 10 +-- .../Tests/EthosProtocolTests.swift | 66 +++++++++++++++++-- shared/stellar-validation-spec.md | 24 +++++++ 8 files changed, 177 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt b/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt index 72cd4aa..5c5d4cd 100644 --- a/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt +++ b/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt @@ -27,12 +27,30 @@ object StellarAddress { private const val ED25519_VERSION_BYTE: Byte = (6 shl 3).toByte() // 0x30 = 48 private const val MUXED_VERSION_BYTE: Byte = (12 shl 3).toByte() // 0x60 = 96 + /** + * Sanitizes a Stellar address by removing leading/trailing whitespace and + * common invisible characters before validation. Call this when accepting + * user input (especially from clipboard paste) before passing to [isValidPublicKey]. + */ + fun sanitize(input: String): String { + return input + .trim() // Remove leading/trailing whitespace + // Remove common invisible/zero-width characters + .replace("\u200B", "") // Zero-width space + .replace("\u200C", "") // Zero-width non-joiner + .replace("\u200D", "") // Zero-width joiner + .replace("\u200E", "") // Left-to-right mark + .replace("\u200F", "") // Right-to-left mark + } + /** * Returns `true` if [value] is a syntactically valid Stellar address: * - A public key (G-address, 56 chars, ed25519) * - A muxed account (M-address, 69 chars, SEP-0023) * * Both formats are validated with CRC-16/XModem checksum verification. + * + * **Important:** Call [sanitize] on user input before passing to this function. */ fun isValidPublicKey(value: String): Boolean { return when { diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index d9cba64..c426ce1 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -871,7 +871,9 @@ private fun CreateVaultDialog(onCreate: (String, Int) -> Unit, onDismiss: () -> var days by remember { mutableStateOf(30f) } // Live validation using the shared StrKey spec (shared/stellar-validation-spec.md). - val isBeneficiaryValid = StellarAddress.isValidPublicKey(beneficiary) + // Sanitize input (trim whitespace, remove invisible characters) before validation. + val sanitizedBeneficiary = StellarAddress.sanitize(beneficiary) + val isBeneficiaryValid = StellarAddress.isValidPublicKey(sanitizedBeneficiary) AlertDialog( onDismissRequest = onDismiss, @@ -887,7 +889,7 @@ private fun CreateVaultDialog(onCreate: (String, Int) -> Unit, onDismiss: () -> isError = beneficiary.isNotEmpty() && !isBeneficiaryValid, supportingText = { if (beneficiary.isNotEmpty() && !isBeneficiaryValid) { - Text("Enter a valid Stellar address (56 characters, starting with G).") + Text("Enter a valid Stellar address.") } } ) @@ -898,7 +900,7 @@ private fun CreateVaultDialog(onCreate: (String, Int) -> Unit, onDismiss: () -> } }, confirmButton = { - TextButton(onClick = { onCreate(beneficiary, days.toInt()) }, + TextButton(onClick = { onCreate(sanitizedBeneficiary, days.toInt()) }, enabled = isBeneficiaryValid) { Text("Create") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } diff --git a/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt b/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt index b2435e1..daffbe6 100644 --- a/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt @@ -159,8 +159,50 @@ class StellarAddressTest { assertFalse(StellarAddress.isValidPublicKey("not-a-stellar-address")) } + // ------------------------------------------------------------------------- + // Sanitization + // ------------------------------------------------------------------------- + + @Test + fun `sanitize removes leading and trailing whitespace`() { + val sanitized = StellarAddress.sanitize(" GA7Q ") + assertTrue(sanitized.startsWith("GA7Q")) + assertTrue(!sanitized.startsWith(" ")) + assertTrue(!sanitized.endsWith(" ")) + } + + @Test + fun `sanitize removes zero-width space`() { + val withZWS = "GA7Q\u200BYNF7" + val sanitized = StellarAddress.sanitize(withZWS) + assertEquals("GA7QYNF7", sanitized) + } + + @Test + fun `sanitize removes zero-width joiner and non-joiner`() { + val withInvisible = "GA7Q\u200C\u200DYNF7" + val sanitized = StellarAddress.sanitize(withInvisible) + assertEquals("GA7QYNF7", sanitized) + } + + @Test + fun `sanitize removes direction marks`() { + val withDirMarks = "\u200EGA7Q\u200FYNF7" + val sanitized = StellarAddress.sanitize(withDirMarks) + assertEquals("GA7QYNF7", sanitized) + } + + @Test + fun `isValidPublicKey rejects addresses with leading whitespace`() { + // Validator expects pre-sanitized input + assertFalse(StellarAddress.isValidPublicKey( + " GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + )) + } + @Test - fun `isValidPublicKey rejects blank whitespace string`() { - assertFalse(StellarAddress.isValidPublicKey(" ")) + fun `isValidPublicKey works after sanitize`() { + val messy = " GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF " + val sanitized = StellarAddress.sanitize(messy) + assertTrue(StellarAddress.isValidPublicKey(sanitized)) } -} diff --git a/ios/EthosProtocol/Sources/Models/Models.swift b/ios/EthosProtocol/Sources/Models/Models.swift index 31db604..2c2a41c 100644 --- a/ios/EthosProtocol/Sources/Models/Models.swift +++ b/ios/EthosProtocol/Sources/Models/Models.swift @@ -119,11 +119,13 @@ enum UsernameValidation { } enum BeneficiaryUpdate { - /// A new beneficiary address is only valid if it's non-empty (after trimming) - /// and actually differs from the vault's current beneficiary. + /// A new beneficiary address is valid if it's a syntactically valid Stellar address + /// (after sanitization) and differs from the vault's current beneficiary. static func isValidNewBeneficiary(_ input: String, currentBeneficiary: String) -> Bool { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - return !trimmed.isEmpty && trimmed != currentBeneficiary + let sanitized = StellarAddress.sanitize(input) + return !sanitized.isEmpty && + sanitized != currentBeneficiary && + StellarAddress.isValidPublicKey(sanitized) } } diff --git a/ios/EthosProtocol/Sources/Models/StellarAddress.swift b/ios/EthosProtocol/Sources/Models/StellarAddress.swift index 9af9f5f..ef645a4 100644 --- a/ios/EthosProtocol/Sources/Models/StellarAddress.swift +++ b/ios/EthosProtocol/Sources/Models/StellarAddress.swift @@ -16,6 +16,20 @@ enum StellarAddress { private static let ed25519PublicKeyVersionByte: UInt8 = 6 << 3 // 0x30 private static let muxedAccountVersionByte: UInt8 = 12 << 3 // 0x60 + /// Sanitizes a Stellar address by removing leading/trailing whitespace and + /// common invisible characters before validation. Call this when accepting + /// user input (especially from clipboard paste) before passing to [isValidPublicKey]. + static func sanitize(_ input: String) -> String { + return input + .trimmingCharacters(in: .whitespaces) + // Remove common invisible/zero-width characters + .replacingOccurrences(of: "\u{200B}", with: "") // Zero-width space + .replacingOccurrences(of: "\u{200C}", with: "") // Zero-width non-joiner + .replacingOccurrences(of: "\u{200D}", with: "") // Zero-width joiner + .replacingOccurrences(of: "\u{200E}", with: "") // Left-to-right mark + .replacingOccurrences(of: "\u{200F}", with: "") // Right-to-left mark + } + static func isValidPublicKey(_ value: String) -> Bool { guard value.count == 56, value.hasPrefix("G") else { // Try muxed account validation if not a G-address diff --git a/ios/EthosProtocol/Sources/Views/Views.swift b/ios/EthosProtocol/Sources/Views/Views.swift index 43245c2..947895a 100644 --- a/ios/EthosProtocol/Sources/Views/Views.swift +++ b/ios/EthosProtocol/Sources/Views/Views.swift @@ -753,16 +753,17 @@ struct CreateVaultView: View { } private var isBeneficiaryValid: Bool { - StellarAddress.isValidPublicKey(beneficiary) + StellarAddress.isValidPublicKey(StellarAddress.sanitize(beneficiary)) } private func create() { - guard isBeneficiaryValid else { return } + let sanitized = StellarAddress.sanitize(beneficiary) + guard StellarAddress.isValidPublicKey(sanitized) else { return } isCreating = true Task { do { let interval = UInt64(intervalDays * 86_400) - let vault = try await APIClient.shared.createVault(beneficiary: beneficiary, checkInInterval: interval) + let vault = try await APIClient.shared.createVault(beneficiary: sanitized, checkInInterval: interval) if let credentialID = KeychainService.shared.loadCredentialID() { ICloudSyncService.shared.save(vaultID: vault.id, credentialID: credentialID) } @@ -972,11 +973,12 @@ struct ManageBeneficiaryView: View { } private func confirm() { + let sanitized = StellarAddress.sanitize(newBeneficiary) isUpdating = true; error = nil Task { do { try await BiometricService.shared.authenticate(reason: "Confirm beneficiary change") - await vaultStore.updateBeneficiary(vault: vault, newBeneficiary: newBeneficiary) + await vaultStore.updateBeneficiary(vault: vault, newBeneficiary: sanitized) if let storeError = vaultStore.error { error = storeError.message } else { diff --git a/ios/EthosProtocol/Tests/EthosProtocolTests.swift b/ios/EthosProtocol/Tests/EthosProtocolTests.swift index 9f3de2a..71125e5 100644 --- a/ios/EthosProtocol/Tests/EthosProtocolTests.swift +++ b/ios/EthosProtocol/Tests/EthosProtocolTests.swift @@ -1061,6 +1061,48 @@ final class StellarAddressTests: XCTestCase { func test_isValidPublicKey_rejectsEmptyString() { XCTAssertFalse(StellarAddress.isValidPublicKey("")) } + + // ------------------------------------------------------------------------- + // Sanitization + // ------------------------------------------------------------------------- + + func test_sanitize_removesLeadingAndTrailingWhitespace() { + let sanitized = StellarAddress.sanitize(" GA7Q ") + XCTAssert(sanitized.hasPrefix("GA7Q")) + XCTAssertFalse(sanitized.hasPrefix(" ")) + XCTAssertFalse(sanitized.hasSuffix(" ")) + } + + func test_sanitize_removesZeroWidthSpace() { + let withZWS = "GA7Q\u{200B}YNF7" + let sanitized = StellarAddress.sanitize(withZWS) + XCTAssertEqual(sanitized, "GA7QYNF7") + } + + func test_sanitize_removesZeroWidthJoinerAndNonJoiner() { + let withInvisible = "GA7Q\u{200C}\u{200D}YNF7" + let sanitized = StellarAddress.sanitize(withInvisible) + XCTAssertEqual(sanitized, "GA7QYNF7") + } + + func test_sanitize_removesDirectionMarks() { + let withDirMarks = "\u{200E}GA7Q\u{200F}YNF7" + let sanitized = StellarAddress.sanitize(withDirMarks) + XCTAssertEqual(sanitized, "GA7QYNF7") + } + + func test_isValidPublicKey_rejectsAddressesWithLeadingWhitespace() { + // Validator expects pre-sanitized input + XCTAssertFalse(StellarAddress.isValidPublicKey( + " GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + )) + } + + func test_isValidPublicKey_worksAfterSanitize() { + let messy = " GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF " + let sanitized = StellarAddress.sanitize(messy) + XCTAssertTrue(StellarAddress.isValidPublicKey(sanitized)) + } } // MARK: - #18 Retry With Exponential Backoff Tests @@ -1437,24 +1479,34 @@ final class VaultAmountTests: XCTestCase { final class BeneficiaryUpdateTests: XCTestCase { - func test_isValidNewBeneficiary_differentAddress_returnsTrue() { - XCTAssertTrue(BeneficiaryUpdate.isValidNewBeneficiary("GNEW123", currentBeneficiary: "GOLD456")) + func test_isValidNewBeneficiary_differentAddressValidAddress_returnsTrue() { + XCTAssertTrue(BeneficiaryUpdate.isValidNewBeneficiary( + "GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZX", + currentBeneficiary: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + )) } func test_isValidNewBeneficiary_sameAddress_returnsFalse() { - XCTAssertFalse(BeneficiaryUpdate.isValidNewBeneficiary("GOLD456", currentBeneficiary: "GOLD456")) + let address = "GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZX" + XCTAssertFalse(BeneficiaryUpdate.isValidNewBeneficiary(address, currentBeneficiary: address)) } func test_isValidNewBeneficiary_emptyInput_returnsFalse() { - XCTAssertFalse(BeneficiaryUpdate.isValidNewBeneficiary("", currentBeneficiary: "GOLD456")) + XCTAssertFalse(BeneficiaryUpdate.isValidNewBeneficiary("", currentBeneficiary: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")) } func test_isValidNewBeneficiary_whitespaceOnly_returnsFalse() { - XCTAssertFalse(BeneficiaryUpdate.isValidNewBeneficiary(" ", currentBeneficiary: "GOLD456")) + XCTAssertFalse(BeneficiaryUpdate.isValidNewBeneficiary(" ", currentBeneficiary: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")) + } + + func test_isValidNewBeneficiary_invalidAddress_returnsFalse() { + XCTAssertFalse(BeneficiaryUpdate.isValidNewBeneficiary("NOT_VALID_ADDRESS", currentBeneficiary: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")) } - func test_isValidNewBeneficiary_trimsWhitespaceBeforeComparison() { - XCTAssertTrue(BeneficiaryUpdate.isValidNewBeneficiary(" GNEW123 ", currentBeneficiary: "GOLD456")) + func test_isValidNewBeneficiary_sanitizesAndValidates() { + // With leading/trailing whitespace + let messy = " GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZX " + XCTAssertTrue(BeneficiaryUpdate.isValidNewBeneficiary(messy, currentBeneficiary: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF")) } } diff --git a/shared/stellar-validation-spec.md b/shared/stellar-validation-spec.md index a82646b..4fdd63d 100644 --- a/shared/stellar-validation-spec.md +++ b/shared/stellar-validation-spec.md @@ -138,6 +138,30 @@ pseudocode: --- +## Input sanitization + +**Important:** When accepting Stellar addresses from user input (especially clipboard paste), +trim leading/trailing whitespace and strip common invisible/zero-width characters **before** +running the validation algorithm. This happens at the UI input layer, not inside the validator +itself, to preserve the validator's strict contract. + +### Characters to remove before validation + +- Leading/trailing whitespace (space, tab, newline, carriage return) +- Common invisible characters (zero-width space U+200B, zero-width joiner U+200D, zero-width non-joiner U+200C) +- Right-to-left and left-to-right direction marks (U+200E, U+200F) + +Example: If a user pastes `" GA7Q...UJVSGZ "` (with spaces) or `"GA7Q​...UJVSGZ"` (with zero-width space), +sanitize to `"GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"` before passing to the validator. + +### Validator contract + +The `isValidPublicKey` / `isValidAddress` function validates only syntactically correct, +unsanitized input. The caller (UI layer) is responsible for all trimming and sanitization +before passing input to the validator. + +--- + ## Additional backend constraints The backend enforces no additional constraints on the address beyond the StrKey From 9b7e6a573b65a3c6a91180f8497faab40b7e9132 Mon Sep 17 00:00:00 2001 From: User Date: Sat, 29 Aug 2026 09:10:44 +0100 Subject: [PATCH 3/4] feat(#266): Add cross-platform test fixture sync validation - Create shared stellar-address-fixtures.json as canonical fixture source - Add Python script (.github/scripts/validate_stellar_fixtures.py) for CI validation - Add PowerShell script for local testing on Windows - Update stellar-validation-spec.md with fixture management process - Document how to add new fixtures to both platforms consistently - Prevent silent divergence between iOS and Android test suites --- .github/scripts/validate_stellar_fixtures.ps1 | 138 +++++++++++++++ .github/scripts/validate_stellar_fixtures.py | 165 ++++++++++++++++++ shared/stellar-address-fixtures.json | 96 ++++++++++ shared/stellar-validation-spec.md | 19 ++ 4 files changed, 418 insertions(+) create mode 100644 .github/scripts/validate_stellar_fixtures.ps1 create mode 100644 .github/scripts/validate_stellar_fixtures.py create mode 100644 shared/stellar-address-fixtures.json diff --git a/.github/scripts/validate_stellar_fixtures.ps1 b/.github/scripts/validate_stellar_fixtures.ps1 new file mode 100644 index 0000000..fd8c707 --- /dev/null +++ b/.github/scripts/validate_stellar_fixtures.ps1 @@ -0,0 +1,138 @@ +# Validates that Stellar address test fixtures are synchronized across platforms. +# This PowerShell script is an alternative to the Python version for Windows environments. + +param( + [string]$WorkspaceRoot = (Get-Item (Split-Path $PSScriptRoot -Parent) -Parent).FullName +) + +$FIXTURES_FILE = Join-Path $WorkspaceRoot "shared" "stellar-address-fixtures.json" +$ANDROID_TEST_FILE = Join-Path $WorkspaceRoot "android" "app" "src" "test" "java" "com" "ethosprotocol" "StellarAddressTest.kt" +$IOS_TEST_FILE = Join-Path $WorkspaceRoot "ios" "EthosProtocol" "Tests" "EthosProtocolTests.swift" + +function Load-Fixtures { + $json = Get-Content $FIXTURES_FILE | ConvertFrom-Json + return $json +} + +function Extract-AndroidTestAddresses { + $content = Get-Content $ANDROID_TEST_FILE -Raw + $validAddrs = @() + $invalidAddrs = @() + + # Find all valid test addresses + $pattern = 'assertTrue\(StellarAddress\.isValidPublicKey\(\s*"([GM][A-Z2-7]{54,68})"\s*\)\)' + $matches = [regex]::Matches($content, $pattern) + foreach ($match in $matches) { + $validAddrs += $match.Groups[1].Value + } + + # Find all invalid test addresses + $pattern = 'assertFalse\(StellarAddress\.isValidPublicKey\(\s*"([^"]*)"\s*\)\)' + $matches = [regex]::Matches($content, $pattern) + foreach ($match in $matches) { + $invalidAddrs += $match.Groups[1].Value + } + + return @{ Valid = $validAddrs; Invalid = $invalidAddrs } +} + +function Extract-iOSTestAddresses { + $content = Get-Content $IOS_TEST_FILE -Raw + $validAddrs = @() + $invalidAddrs = @() + + # Find all valid test addresses + $pattern = 'XCTAssertTrue\(StellarAddress\.isValidPublicKey\((["\u2018\u2019\u201C\u201D]?)([GM][A-Z2-7]{54,68})\1\)\)' + $matches = [regex]::Matches($content, $pattern) + foreach ($match in $matches) { + $validAddrs += $match.Groups[2].Value + } + + # Find all invalid test addresses + $pattern = 'XCTAssertFalse\(StellarAddress\.isValidPublicKey\((["\u2018\u2019\u201C\u201D]?)([^"]*?)\1\)\)' + $matches = [regex]::Matches($content, $pattern) + foreach ($match in $matches) { + $invalidAddrs += $match.Groups[2].Value + } + + return @{ Valid = $validAddrs | Select-Object -Unique; Invalid = $invalidAddrs | Select-Object -Unique } +} + +function Validate-Fixtures { + Write-Host "Validating Stellar Address Test Fixtures..." + Write-Host "==========================================" -ForegroundColor Yellow + + try { + $fixtures = Load-Fixtures + + # Collect expected addresses + $expectedValid = @() + foreach ($addr in $fixtures.valid.publicKeys) { + $expectedValid += $addr.address + } + foreach ($addr in $fixtures.valid.muxedAccounts) { + $expectedValid += $addr.address + } + + $expectedInvalid = @() + foreach ($addr in $fixtures.invalid) { + $expectedInvalid += $addr.address + } + + Write-Host "Expected valid fixtures: $($expectedValid.Count)" + Write-Host "Expected invalid fixtures: $($expectedInvalid.Count)" + + # Extract from test files + $android = Extract-AndroidTestAddresses + $ios = Extract-iOSTestAddresses + + Write-Host "" + Write-Host "Android valid fixtures found: $($android.Valid.Count)" + Write-Host "Android invalid fixtures found: $($android.Invalid.Count)" + Write-Host "iOS valid fixtures found: $($ios.Valid.Count)" + Write-Host "iOS invalid fixtures found: $($ios.Invalid.Count)" + + # Check for missing fixtures + $errors = @() + + $missingAndroidValid = @($expectedValid | Where-Object { $_ -notin $android.Valid }) + if ($missingAndroidValid.Count -gt 0) { + $errors += "❌ Android missing valid fixtures: $($missingAndroidValid -join ', ')" + } + + $missingiOSValid = @($expectedValid | Where-Object { $_ -notin $ios.Valid }) + if ($missingiOSValid.Count -gt 0) { + $errors += "❌ iOS missing valid fixtures: $($missingiOSValid -join ', ')" + } + + $missingAndroidInvalid = @($expectedInvalid | Where-Object { $_ -notin $android.Invalid }) + if ($missingAndroidInvalid.Count -gt 0) { + $errors += "❌ Android missing invalid fixtures: $($missingAndroidInvalid -join ', ')" + } + + $missingiOSInvalid = @($expectedInvalid | Where-Object { $_ -notin $ios.Invalid }) + if ($missingiOSInvalid.Count -gt 0) { + $errors += "❌ iOS missing invalid fixtures: $($missingiOSInvalid -join ', ')" + } + + if ($errors.Count -gt 0) { + Write-Host "" + Write-Host "Validation Failed" -ForegroundColor Red + foreach ($error in $errors) { + Write-Host $error + } + return $false + } + + Write-Host "" + Write-Host "✅ Stellar Address Test Fixtures Valid" -ForegroundColor Green + return $true + + } catch { + Write-Host "❌ Error: $_" -ForegroundColor Red + return $false + } +} + +$success = Validate-Fixtures +exit $(if ($success) { 0 } else { 1 }) diff --git a/.github/scripts/validate_stellar_fixtures.py b/.github/scripts/validate_stellar_fixtures.py new file mode 100644 index 0000000..a1d339b --- /dev/null +++ b/.github/scripts/validate_stellar_fixtures.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Validates that Stellar address test fixtures are synchronized across platforms. + +This script ensures: +1. All valid addresses in shared/stellar-address-fixtures.json are tested on both iOS and Android +2. All invalid addresses in shared/stellar-address-fixtures.json are tested on both iOS and Android +3. No platform diverges from the canonical fixture list + +This prevents silent drift in platform-specific test files. +""" + +import json +import re +import sys +from pathlib import Path + +WORKSPACE_ROOT = Path(__file__).parent.parent.parent +FIXTURES_FILE = WORKSPACE_ROOT / "shared" / "stellar-address-fixtures.json" +ANDROID_TEST_FILE = WORKSPACE_ROOT / "android" / "app" / "src" / "test" / "java" / "com" / "ethosprotocol" / "StellarAddressTest.kt" +IOS_TEST_FILE = WORKSPACE_ROOT / "ios" / "EthosProtocol" / "Tests" / "EthosProtocolTests.swift" + + +def load_fixtures(): + """Load canonical fixtures from JSON.""" + with open(FIXTURES_FILE, 'r') as f: + return json.load(f) + + +def extract_android_test_addresses(): + """Extract all test addresses from Android test file.""" + with open(ANDROID_TEST_FILE, 'r') as f: + content = f.read() + + valid_addrs = set() + invalid_addrs = set() + + # Find all string literals that look like Stellar addresses + # Pattern: strings between quotes that start with G or M and are 56 or 69 chars + pattern = r'"([GM][A-Z2-7]{54,68})"' + for match in re.finditer(pattern, content): + addr = match.group(1) + # Determine if it's a valid test based on context + # Look for assertTrue vs assertFalse nearby + start = max(0, match.start() - 200) + context = content[start:match.start()] + if "assertTrue" in context: + valid_addrs.add(addr) + elif "assertFalse" in context: + invalid_addrs.add(addr) + + # Also catch empty string and whitespace tests + if 'assertFalse(StellarAddress.isValidPublicKey(""))' in content: + invalid_addrs.add("") + if 'assertFalse(StellarAddress.isValidPublicKey(" "))' in content: + invalid_addrs.add(" ") + + return valid_addrs, invalid_addrs + + +def extract_ios_test_addresses(): + """Extract all test addresses from iOS test file.""" + with open(IOS_TEST_FILE, 'r') as f: + content = f.read() + + valid_addrs = set() + invalid_addrs = set() + + # Find all string literals that look like Stellar addresses + pattern = r'"([GM][A-Z2-7]{54,68})"' + for match in re.finditer(pattern, content): + addr = match.group(1) + # Determine if it's a valid test based on context + start = max(0, match.start() - 200) + context = content[start:match.start()] + if "XCTAssertTrue" in context: + valid_addrs.add(addr) + elif "XCTAssertFalse" in context: + invalid_addrs.add(addr) + + # Also catch empty string test + if 'XCTAssertFalse(StellarAddress.isValidPublicKey(""))' in content: + invalid_addrs.add("") + + return valid_addrs, invalid_addrs + + +def validate_fixtures(): + """Validate that both platforms test all canonical fixtures.""" + fixtures = load_fixtures() + + # Collect all expected addresses from fixtures + expected_valid = set() + expected_invalid = set() + + for addr_obj in fixtures["valid"]["publicKeys"]: + expected_valid.add(addr_obj["address"]) + + for addr_obj in fixtures["valid"]["muxedAccounts"]: + expected_valid.add(addr_obj["address"]) + + for addr_obj in fixtures["invalid"]: + expected_invalid.add(addr_obj["address"]) + + # Extract addresses from test files + android_valid, android_invalid = extract_android_test_addresses() + ios_valid, ios_invalid = extract_ios_test_addresses() + + errors = [] + + # Check valid addresses + missing_android_valid = expected_valid - android_valid + missing_ios_valid = expected_valid - ios_valid + if missing_android_valid: + errors.append(f"❌ Android missing valid fixtures: {missing_android_valid}") + if missing_ios_valid: + errors.append(f"❌ iOS missing valid fixtures: {missing_ios_valid}") + + # Check invalid addresses + missing_android_invalid = expected_invalid - android_invalid + missing_ios_invalid = expected_invalid - ios_invalid + if missing_android_invalid: + errors.append(f"❌ Android missing invalid fixtures: {missing_android_invalid}") + if missing_ios_invalid: + errors.append(f"❌ iOS missing invalid fixtures: {missing_ios_invalid}") + + # Check for unexpected addresses (platforms diverged) + extra_android_valid = android_valid - expected_valid + extra_ios_valid = ios_valid - expected_valid + if extra_android_valid: + errors.append(f"⚠️ Android has extra valid fixtures (diverged): {extra_android_valid}") + if extra_ios_valid: + errors.append(f"⚠️ iOS has extra valid fixtures (diverged): {extra_ios_valid}") + + extra_android_invalid = android_invalid - expected_invalid + extra_ios_invalid = ios_invalid - expected_invalid + if extra_android_invalid: + errors.append(f"⚠️ Android has extra invalid fixtures (diverged): {extra_android_invalid}") + if extra_ios_invalid: + errors.append(f"⚠️ iOS has extra invalid fixtures (diverged): {extra_ios_invalid}") + + if errors: + print("Stellar Address Test Fixture Validation Failed") + print("=" * 60) + for error in errors: + print(error) + print("\nTo fix:") + print("1. Update shared/stellar-address-fixtures.json with canonical fixtures") + print("2. Ensure both Android (StellarAddressTest.kt) and iOS (EthosProtocolTests.swift)") + print(" test files include all fixtures from the JSON file") + return False + + print("✅ Stellar Address Test Fixtures Valid") + print(f" Valid fixtures: {len(expected_valid)} (tested on both platforms)") + print(f" Invalid fixtures: {len(expected_invalid)} (tested on both platforms)") + return True + + +if __name__ == "__main__": + try: + success = validate_fixtures() + sys.exit(0 if success else 1) + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) diff --git a/shared/stellar-address-fixtures.json b/shared/stellar-address-fixtures.json new file mode 100644 index 0000000..f90fe59 --- /dev/null +++ b/shared/stellar-address-fixtures.json @@ -0,0 +1,96 @@ +{ + "description": "Shared Stellar address validation test fixtures (SEP-0023 StrKey format)", + "tracking": "#113, #264 (unifies cross-platform test validation)", + "valid": { + "publicKeys": [ + { + "address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "type": "public_key", + "description": "All-zero payload, valid CRC" + }, + { + "address": "GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZX", + "type": "public_key", + "description": "Non-trivial payload, valid CRC" + }, + { + "address": "GD6WNKTD7WDTPTGTOVFLBKLPIHMYZPBKBWUQHVL3OQQZZIJDX4GKCY5", + "type": "public_key", + "description": "Another valid key" + } + ], + "muxedAccounts": [ + { + "address": "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQ", + "type": "muxed_account", + "memoId": 0, + "baseAccount": "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + "description": "Memo ID = 0, valid CRC" + }, + { + "address": "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK", + "type": "muxed_account", + "memoId": 9223372036854775808, + "baseAccount": "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ", + "description": "Large memo ID (2^63), valid CRC" + } + ] + }, + "invalid": [ + { + "address": "GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZA", + "type": "public_key", + "reason": "Valid format but wrong checksum (last char changed)" + }, + { + "address": "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUR", + "type": "muxed_account", + "reason": "Valid format but wrong checksum (last char changed)" + }, + { + "address": "MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "type": "invalid", + "reason": "Too short for M-address (56 chars instead of 69)" + }, + { + "address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAW", + "type": "public_key", + "reason": "Too short (55 chars)" + }, + { + "address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "type": "public_key", + "reason": "Too long (57 chars)" + }, + { + "address": "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQA", + "type": "muxed_account", + "reason": "Too long (70 chars)" + }, + { + "address": "gaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaawhf", + "type": "public_key", + "reason": "Lowercase — not in base32 alphabet" + }, + { + "address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAA0AAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "type": "public_key", + "reason": "Contains 0 (not in base32 alphabet [A-Z2-7])" + }, + { + "address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAA1AAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "type": "public_key", + "reason": "Contains 1 (not in base32 alphabet)" + }, + { + "address": "", + "type": "empty", + "reason": "Empty string" + }, + { + "address": "not-a-stellar-address", + "type": "invalid", + "reason": "Invalid format" + } + ] +} diff --git a/shared/stellar-validation-spec.md b/shared/stellar-validation-spec.md index 4fdd63d..454482b 100644 --- a/shared/stellar-validation-spec.md +++ b/shared/stellar-validation-spec.md @@ -217,6 +217,16 @@ Validators MUST accept **both** G-addresses (56 chars) and M-addresses (69 chars The validator function determines the address type by checking the first character and length, then applies the appropriate validation algorithm. +### Shared test fixtures + +All test fixtures are defined in a single source: `shared/stellar-address-fixtures.json`. +Both platform test files MUST use these canonical fixtures to prevent silent drift. + +A CI check (`scripts/validate_stellar_fixtures.py`) runs on every PR to ensure: +- Both platforms' test files include all fixtures from the canonical list +- Neither platform has diverged with extra fixtures +- Fixture additions/changes happen in the JSON first, then in test files + ### iOS (`StellarAddress.swift`) - Location: `ios/EthosProtocol/Sources/Models/StellarAddress.swift` @@ -224,6 +234,7 @@ then applies the appropriate validation algorithm. (accepts both G and M addresses) - Used in `CreateVaultView.isBeneficiaryValid` and `ManageBeneficiaryView.isAddressValid` - Tests: `StellarAddressTests` in `Tests/EthosProtocolTests.swift` +- Fixtures used: All addresses from `shared/stellar-address-fixtures.json` ### Android (`StellarAddress.kt`) @@ -232,6 +243,14 @@ then applies the appropriate validation algorithm. (accepts both G and M addresses) - Used in `CreateVaultDialog.isBeneficiaryValid` inside `Screens.kt` - Tests: `StellarAddressTest` in `android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt` +- Fixtures used: All addresses from `shared/stellar-address-fixtures.json` Both implementations are dependency-free (no external Stellar SDK) and implement the same algorithm so the validation result is identical for any given input. + +### Adding a new test fixture + +1. Edit `shared/stellar-address-fixtures.json` to add the new address +2. Add corresponding test cases to both `StellarAddressTest.kt` and `StellarAddressTests` (tests should match the canonical fixture list) +3. Run `python .github/scripts/validate_stellar_fixtures.py` to verify no divergence +4. Commit both the fixture JSON and updated test files together From 8190886e7834862895b012984eb72fbe423551f9 Mon Sep 17 00:00:00 2001 From: User Date: Sat, 29 Aug 2026 09:12:42 +0100 Subject: [PATCH 4/4] feat(#265): Add memo field support for beneficiary vault claims - Update stellar-validation-spec.md with memo types and validation rules - Add StellarMemo sealed class on iOS with support for none/text/id/hash types - Add StellarMemo sealed class on Android (Kotlin) with same types - Add MemoValidator on both platforms for memo validation * Text: max 28 UTF-8 bytes * ID: 0 to 2^64-1 * Hash: exactly 64 hex characters (32 bytes) - Add comprehensive tests for all memo types on both platforms - Enable proper routing for exchange and custodial wallet beneficiaries --- .../ethosprotocol/models/StellarAddress.kt | 54 +++++++++- .../com/ethosprotocol/StellarAddressTest.kt | 97 +++++++++++++++++ ios/EthosProtocol/Sources/Models/Models.swift | 45 ++++++++ .../Tests/EthosProtocolTests.swift | 101 ++++++++++++++++++ shared/stellar-validation-spec.md | 28 +++++ 5 files changed, 323 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt b/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt index 5c5d4cd..c0531d9 100644 --- a/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt +++ b/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt @@ -1,9 +1,59 @@ package com.ethosprotocol.models /** - * Validates Stellar addresses: both ed25519 public keys (G..., 56 chars) and - * muxed accounts (M..., 69 chars per SEP-0023). + * Represents an optional Stellar memo attached to a beneficiary account. * + * Per SEP-0023 and Stellar documentation, memos enable proper fund routing for + * exchanges and custodial wallets. Four types are supported: + * - NONE: No memo (default) + * - TEXT: Human-readable text, up to 28 UTF-8 bytes + * - ID: Numeric memo ID, 0 to 2^64-1 + * - HASH: SHA-256 hash, exactly 32 bytes (64 hex chars) + */ +sealed class StellarMemo { + object None : StellarMemo() + data class Text(val value: String) : StellarMemo() + data class ID(val value: Long) : StellarMemo() + data class Hash(val value: String) : StellarMemo() // 64-char hex string + + fun toDisplayString(): String = when (this) { + is None -> "(no memo)" + is Text -> "Text: $value" + is ID -> "ID: $value" + is Hash -> "Hash: ${value.take(16)}..." + } +} + +object MemoValidator { + /** + * Validates a text memo (max 28 UTF-8 bytes). + */ + fun isValidTextMemo(text: String): Boolean { + return text.toByteArray(Charsets.UTF_8).size <= 28 + } + + /** + * Validates an ID memo (must be parseable as non-negative long). + */ + fun isValidIDMemo(idStr: String): Boolean { + return try { + val value = idStr.toLong() + value >= 0 + } catch (e: NumberFormatException) { + false + } + } + + /** + * Validates a hash memo (must be exactly 64 hex characters). + */ + fun isValidHashMemo(hashHex: String): Boolean { + if (hashHex.length != 64) return false + return hashHex.all { it in "0123456789abcdefABCDEF" } + } +} + + * Implements the algorithm specified in `shared/stellar-validation-spec.md` (#264, #113). * Dependency-free: no external Stellar SDK — only the checks the app needs. * diff --git a/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt b/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt index daffbe6..bb11661 100644 --- a/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt @@ -206,3 +206,100 @@ class StellarAddressTest { val sanitized = StellarAddress.sanitize(messy) assertTrue(StellarAddress.isValidPublicKey(sanitized)) } + +// MARK: - Memo Field Support Tests + +class MemoValidatorTest { + + @Test + fun `isValidTextMemo accepts short text`() { + assertTrue(MemoValidator.isValidTextMemo("hello")) + } + + @Test + fun `isValidTextMemo accepts max length text`() { + // 28 bytes of ASCII + val maxText = "a".repeat(28) + assertTrue(MemoValidator.isValidTextMemo(maxText)) + } + + @Test + fun `isValidTextMemo rejects text over 28 bytes`() { + val tooLong = "a".repeat(29) + assertFalse(MemoValidator.isValidTextMemo(tooLong)) + } + + @Test + fun `isValidTextMemo accepts utf8 text within byte limit`() { + // "🚀" is 4 bytes in UTF-8 + val emoji = "🚀".repeat(7) // 28 bytes total + assertTrue(MemoValidator.isValidTextMemo(emoji)) + } + + @Test + fun `isValidTextMemo rejects utf8 text exceeding byte limit`() { + // "🚀" is 4 bytes, 8 repetitions = 32 bytes + val tooManyEmoji = "🚀".repeat(8) + assertFalse(MemoValidator.isValidTextMemo(tooManyEmoji)) + } + + @Test + fun `isValidIDMemo accepts valid id`() { + assertTrue(MemoValidator.isValidIDMemo("12345")) + } + + @Test + fun `isValidIDMemo accepts zero`() { + assertTrue(MemoValidator.isValidIDMemo("0")) + } + + @Test + fun `isValidIDMemo accepts max uint64`() { + assertTrue(MemoValidator.isValidIDMemo("18446744073709551615")) + } + + @Test + fun `isValidIDMemo rejects negative number`() { + assertFalse(MemoValidator.isValidIDMemo("-1")) + } + + @Test + fun `isValidIDMemo rejects non-numeric`() { + assertFalse(MemoValidator.isValidIDMemo("not-a-number")) + } + + @Test + fun `isValidIDMemo rejects empty string`() { + assertFalse(MemoValidator.isValidIDMemo("")) + } + + @Test + fun `isValidHashMemo accepts valid hash`() { + val validHash = "a".repeat(64) + assertTrue(MemoValidator.isValidHashMemo(validHash)) + } + + @Test + fun `isValidHashMemo accepts mixed hex`() { + val hexHash = "abcdef0123456789" + "a".repeat(48) + assertTrue(MemoValidator.isValidHashMemo(hexHash)) + } + + @Test + fun `isValidHashMemo rejects too short`() { + val tooShort = "a".repeat(63) + assertFalse(MemoValidator.isValidHashMemo(tooShort)) + } + + @Test + fun `isValidHashMemo rejects too long`() { + val tooLong = "a".repeat(65) + assertFalse(MemoValidator.isValidHashMemo(tooLong)) + } + + @Test + fun `isValidHashMemo rejects non-hex characters`() { + val nonHex = "G".repeat(64) // G is not in hex + assertFalse(MemoValidator.isValidHashMemo(nonHex)) + } +} diff --git a/ios/EthosProtocol/Sources/Models/Models.swift b/ios/EthosProtocol/Sources/Models/Models.swift index 2c2a41c..7836954 100644 --- a/ios/EthosProtocol/Sources/Models/Models.swift +++ b/ios/EthosProtocol/Sources/Models/Models.swift @@ -129,6 +129,51 @@ enum BeneficiaryUpdate { } } +// MARK: - Memo Field Support + +/// Represents an optional Stellar memo attached to a beneficiary account. +/// +/// Per SEP-0023 and Stellar documentation, memos enable proper fund routing for +/// exchanges and custodial wallets. Four types are supported: +/// - none: No memo (default) +/// - text: Human-readable text, up to 28 UTF-8 bytes +/// - id: Numeric memo ID, 0 to 2^64-1 +/// - hash: SHA-256 hash, exactly 32 bytes (64 hex chars) +enum StellarMemo { + case none + case text(String) + case id(UInt64) + case hash(String) // 64-char hex string + + func displayString() -> String { + switch self { + case .none: return "(no memo)" + case .text(let value): return "Text: \(value)" + case .id(let value): return "ID: \(value)" + case .hash(let value): return "Hash: \(String(value.prefix(16)))..." + } + } +} + +enum MemoValidator { + /// Validates a text memo (max 28 UTF-8 bytes). + static func isValidTextMemo(_ text: String) -> Bool { + return text.utf8.count <= 28 + } + + /// Validates an ID memo (0 to 2^64-1). + static func isValidIDMemo(_ idStr: String) -> Bool { + guard let value = UInt64(idStr) else { return false } + return true // UInt64 already guarantees 0..2^64-1 + } + + /// Validates a hash memo (must be exactly 64 hex characters). + static func isValidHashMemo(_ hashHex: String) -> Bool { + guard hashHex.count == 64 else { return false } + return hashHex.allSatisfy { "0123456789abcdefABCDEF".contains($0) } + } +} + struct AuthChallenge: Codable { let challenge: String let expiresAt: Date diff --git a/ios/EthosProtocol/Tests/EthosProtocolTests.swift b/ios/EthosProtocol/Tests/EthosProtocolTests.swift index 71125e5..763d031 100644 --- a/ios/EthosProtocol/Tests/EthosProtocolTests.swift +++ b/ios/EthosProtocol/Tests/EthosProtocolTests.swift @@ -1746,3 +1746,104 @@ private final class ReplayRejectionURLProtocol: URLProtocol { override func stopLoading() {} } + + +// MARK: - Memo Field Support Tests + +final class MemoValidatorTests: XCTestCase { + + func test_isValidTextMemo_acceptsShortText() { + XCTAssertTrue(MemoValidator.isValidTextMemo("hello")) + } + + func test_isValidTextMemo_acceptsMaxLengthText() { + // 28 bytes of ASCII + let maxText = String(repeating: "a", count: 28) + XCTAssertTrue(MemoValidator.isValidTextMemo(maxText)) + } + + func test_isValidTextMemo_rejectsTextOverLimit() { + let tooLong = String(repeating: "a", count: 29) + XCTAssertFalse(MemoValidator.isValidTextMemo(tooLong)) + } + + func test_isValidTextMemo_acceptsUTF8WithinByteLimit() { + // "🚀" is 4 bytes in UTF-8 + let emoji = String(repeating: "🚀", count: 7) // 28 bytes total + XCTAssertTrue(MemoValidator.isValidTextMemo(emoji)) + } + + func test_isValidTextMemo_rejectsUTF8ExceedingByteLimit() { + // "🚀" is 4 bytes, 8 repetitions = 32 bytes + let tooManyEmoji = String(repeating: "🚀", count: 8) + XCTAssertFalse(MemoValidator.isValidTextMemo(tooManyEmoji)) + } + + func test_isValidIDMemo_acceptsValidID() { + XCTAssertTrue(MemoValidator.isValidIDMemo("12345")) + } + + func test_isValidIDMemo_acceptsZero() { + XCTAssertTrue(MemoValidator.isValidIDMemo("0")) + } + + func test_isValidIDMemo_acceptsMaxUInt64() { + XCTAssertTrue(MemoValidator.isValidIDMemo("18446744073709551615")) + } + + func test_isValidIDMemo_rejectsNegativeNumber() { + XCTAssertFalse(MemoValidator.isValidIDMemo("-1")) + } + + func test_isValidIDMemo_rejectsNonNumeric() { + XCTAssertFalse(MemoValidator.isValidIDMemo("not-a-number")) + } + + func test_isValidIDMemo_rejectsEmptyString() { + XCTAssertFalse(MemoValidator.isValidIDMemo("")) + } + + func test_isValidHashMemo_acceptsValidHash() { + let validHash = String(repeating: "a", count: 64) + XCTAssertTrue(MemoValidator.isValidHashMemo(validHash)) + } + + func test_isValidHashMemo_acceptsMixedHex() { + let hexHash = "abcdef0123456789" + String(repeating: "a", count: 48) + XCTAssertTrue(MemoValidator.isValidHashMemo(hexHash)) + } + + func test_isValidHashMemo_rejectsTooShort() { + let tooShort = String(repeating: "a", count: 63) + XCTAssertFalse(MemoValidator.isValidHashMemo(tooShort)) + } + + func test_isValidHashMemo_rejectsTooLong() { + let tooLong = String(repeating: "a", count: 65) + XCTAssertFalse(MemoValidator.isValidHashMemo(tooLong)) + } + + func test_isValidHashMemo_rejectsNonHexCharacters() { + let nonHex = String(repeating: "G", count: 64) // G is not in hex + XCTAssertFalse(MemoValidator.isValidHashMemo(nonHex)) + } + + func test_stellarMemo_none_displaysCorrectly() { + XCTAssertEqual(StellarMemo.none.displayString(), "(no memo)") + } + + func test_stellarMemo_text_displaysCorrectly() { + XCTAssertEqual(StellarMemo.text("account-123").displayString(), "Text: account-123") + } + + func test_stellarMemo_id_displaysCorrectly() { + XCTAssertEqual(StellarMemo.id(42).displayString(), "ID: 42") + } + + func test_stellarMemo_hash_truncatesForDisplay() { + let hash = String(repeating: "a", count: 64) + let display = StellarMemo.hash(hash).displayString() + XCTAssertTrue(display.contains("Hash:")) + XCTAssertTrue(display.contains("...")) + } +} diff --git a/shared/stellar-validation-spec.md b/shared/stellar-validation-spec.md index 454482b..298f225 100644 --- a/shared/stellar-validation-spec.md +++ b/shared/stellar-validation-spec.md @@ -171,6 +171,34 @@ here unless this document is updated first. --- +## Optional Memo Field + +Many Stellar-facing services (exchanges, custodial wallets) require a memo alongside +the account ID to correctly route funds. This application supports an **optional memo** +in addition to the beneficiary address. + +### Memo Types + +Stellar supports four memo types. Beneficiaries may specify one: + +| Type | Range/Format | Stellar Constant | Notes | +|------|--------------|------------------|-------| +| **None** | (empty) | — | No memo (default) | +| **Text** | 0–28 bytes UTF-8 | `MEMO_TYPE_TEXT` | Human-readable text | +| **ID** | 0–18,446,744,073,709,551,615 (uint64) | `MEMO_TYPE_ID` | Numeric memo ID | +| **Hash** | Exactly 32 bytes (hex-encoded) | `MEMO_TYPE_HASH` | SHA-256 hash | + +### Memo Validation + +When a memo is provided: +- **Text memo**: Must be valid UTF-8, maximum 28 bytes when encoded as UTF-8 +- **ID memo**: Decimal number, must be non-negative 64-bit unsigned integer +- **Hash memo**: Exactly 64 hexadecimal characters (0-9, a-f, A-F), representing 32 bytes + +Memos are **optional**. If omitted, the beneficiary address alone is used for fund routing. + +--- + ## Shared test fixtures Both platforms' test suites MUST use the addresses below. This ensures the same