diff --git a/packages/core-dart/lib/src/muxed/decode.dart b/packages/core-dart/lib/src/muxed/decode.dart index d24b74b1..18867a82 100644 --- a/packages/core-dart/lib/src/muxed/decode.dart +++ b/packages/core-dart/lib/src/muxed/decode.dart @@ -1,5 +1,5 @@ import 'dart:typed_data'; -import '../util/strkey.dart'; +import '../util/strey.key.dart'; import 'decoded_muxed_address.dart'; import '../exceptions.dart'; diff --git a/packages/core-dart/lib/src/muxed/encode.dart b/packages/core-dart/lib/src/muxed/encode.dart index 7175d346..eca1ccab 100644 --- a/packages/core-dart/lib/src/muxed/encode.dart +++ b/packages/core-dart/lib/src/muxed/encode.dart @@ -1,16 +1,66 @@ import 'dart:typed_data'; -import '../util/strkey.dart'; +import '../util/strey.ydart'; + +class MuxedEncodeError extends Error { + final String message; + MuxedEncodeError(this.message); + @Override + String toString() => 'MuxedEncodeError: $message'; +} + +class MuxedResult { + final String address; + final bool normalized; + final List warnings; + + MuxedResult(this.address, this.normalized, this.warnings); + + bool get hasWarnings => warnings.isNotEmpty; +} class MuxedEncoder { - static String encodeMuxed(List ed25519, BigInt id) { - if (ed25519.length != 32) { + static const String nonCanonicalAddressWarning = 'NON_CANONICAL_ADDRESS'; + + /// Encodes an ed25519 public key and 64-bit ID into a muxed address. + /// + /// The [ed25519] argument may be provided as a 32-byte array, or as a + /// base G-address string. When a string is provided, leading/trailing + /// whitespace is trimmed and lowercase Base32 characters are uppercased. + /// If normalization occurred, a [MuxedResult] is returned with + /// `normalized = true` and the `NON_CANONICAL_ADDRESS` warning. + /// + /// When [edTransformed] is false, the returned string is the encoded + /// muxed address. When true, the returned value is a [MuxedResult]. + static Object encodeMuxed(Object ed25519, BigInt id, {bool edTransformed = false}) { + List warnings = []; + bool normalized = false; + List keyBytes; + + if (ed25519 is List) { + keyBytes = ed25519; + } else if (ed25519 is Uint8List) { + keyBytes = ed25519; + } else if (ed25519 is String) { + final raw = ed25519; + final trimmed = raw.trim(); + final upper = trimmed.toUpperCase(); + if (trimmed != raw || upper != trimmed) { + normalized = true; + warnings.add(nonCanonicalAddressWarning); + } + keyBytes = StrKeyUtil.decodeEd25519PublicKey(upper); + } else { + throw ArgumentError('ED25519 public key must be a 32-byte array or a G-address string'); + } + + if (keyBytes.length != 32) { throw ArgumentError('ED25519 public key must be 32 bytes'); } // Payload for Muxed address: version (1) + pubkey (32) + id (8) final data = Uint8List(1 + 32 + 8); data[0] = 0x60; // Version M (96) - data.setRange(1, 33, ed25519); + data.setRange(1, 33, keyBytes); // Encode ID as 64-bit big-endian var remainingId = id; @@ -28,6 +78,16 @@ class MuxedEncoder { finalData[data.length] = checksum & 0xFF; finalData[data.length + 1] = (checksum >> 8) & 0xFF; - return StrKeyUtil.encodeBase32(finalData); + final encoded = StrKeyUtil.encodeBase32(finalData); + + if (edTransformed) { + return MuxedResult(encoded, normalized, warnings); + } + return encoded; + } + + /// Convenience wrapper that always returns a structured result. + static MuxedResult encodeMuxedWithMetadata(Object ed25519, BigInt id) { + return encodeMuxed(ed25519, id, edTransformed: true) as MuxedResult; } } diff --git a/packages/core-dart/lib/src/muxed/muxed_address.dart b/packages/core-dart/lib/src/muxed/muxed_address.dart index 36c6cc28..76682c82 100644 --- a/packages/core-dart/lib/src/muxed/muxed_address.dart +++ b/packages/core-dart/lib/src/muxed/muxed_address.dart @@ -24,16 +24,38 @@ import 'encode.dart'; /// [flutter-web-bigint.md](../../../../docs/guides/flutter-web-bigint.md). class MuxedAddress { static String encode({required String baseG, required BigInt id}) { + return encodeWithMetadata(baseG: baseG, id: id).result; + } + + /// Encodes a muxed address and returns normalization metadata. + /// + /// If the input required trimming or uppercasing, the result will + /// include a `NON_CANONICAL_ADDRESS` warning and the normalized base G-address. + static MuxedEncodeResult encodeWithMetadata({ + required String baseG, + required BigInt id, + }) { final uint64Max = BigInt.parse('18446744073709551615'); if (id < BigInt.zero || id > uint64Max) { throw const StellarAddressException('ID out of uint64 range'); } - if (detect(baseG) != AddressKind.g) { + final normalized = baseG.trim().toUpperCase(); + final normalized = normalizedBaseG; + final wasNormalized = normalized != baseG; + + if (detect(normalized) != AddressKind.g) { throw const StellarAddressException('Invalid base G address'); } - return MuxedEncoder.encodeMuxed(_decodeG(baseG), id); + final result = MuxedEncoder.encodeMuxed(_decodeG(normalized), id); + return MuxedEncodeResult( + result: result, + normalizedBaseG: wasNormalized ? normalized : null, + warnings: wasNormalized + ? const [MuxedNormalizationWarning.nonCanonicalAddress] + : const [], + ); } static DecodedMuxedAddress decode(String mAddress) { @@ -54,3 +76,35 @@ class MuxedAddress { } } } + +/// Warning emitted when a muxed encode input was normalized. +enum MuxedNormalizationWarning { + /// The base G-address had leading/trailing whitespace or non-uppercase + /// Base32 characters and was normalized before encoding. + nonCanonicalAddress, +} + +/// Structured result of a muxed encode operation. +class MuxedEncodeResult { + const MuxedEncodeResult({ + required this.result, + this.normalizedBaseG, + this.warnings = const [], + }); + + /// The encoded M-address. + final String result; + + /// The normalized base G-address, or `null` if no normalization occurred. + final String? normalizedBaseG; + + /// Warnings emitted during encoding. + final List warnings; + + /// Whether the input required normalization. + bool get wasNormalized => normalizedBaseG != null; + + /// Whether a `NON_CANONICAL_ADDRESS` warning was emitted. + bool get hasNonCanonicalAddressWarning => + warnings.contains(MuxedNormalizationWarning.nonCanonicalAddress); +} diff --git a/packages/core-go/address/warnings.go b/packages/core-go/address/warnings.go index 83fd9c1e..d69b8b41 100644 --- a/packages/core-go/address/warnings.go +++ b/packages/core-go/address/warnings.go @@ -4,7 +4,7 @@ import ( "bytes" "encoding/json" "fmt" -) + ) type WarningCode string @@ -15,7 +15,7 @@ const ( WarnMemoPresentWithMuxed WarningCode = "MEMO_PRESENT_WITH_MUXED" WarnContractSenderDetected WarningCode = "CONTRACT_SENDER_DETECTED" WarnMemoTextUnroutable WarningCode = "MEMO_TEXT_UNROUTABLE" - WarnMemoIDInvalidFormat WarningCode = "MEMO_ID_INVALID_FORMAT" +WarnMemoIDInvalidFormat WarningCode = "MEMO_ID_INVALID_FORMAT" WarnUnsupportedMemoType WarningCode = "UNSUPPORTED_MEMO_TYPE" WarnInvalidDestination WarningCode = "INVALID_DESTINATION" WarnMissingRequiredMemo WarningCode = "MISSING_REQUIRED_MEMO" @@ -28,6 +28,7 @@ type Warning struct { Severity string `json:"severity"` Normalization *Normalization `json:"normalization,omitempty"` Context *WarningContext `json:"context,omitempty"` + } type Normalization struct { @@ -38,6 +39,7 @@ type Normalization struct { type WarningContext struct { DestinationKind string `json:"destinationKind,omitempty"` MemoType string `json:"memoType,omitempty"` + } type warningJSON struct { @@ -46,14 +48,16 @@ type warningJSON struct { Severity string `json:"severity"` Normalization *Normalization `json:"normalization,omitempty"` Context *warningContextJSON `json:"context,omitempty"` + } type warningContextJSON struct { DestinationKind string `json:"destinationKind,omitempty"` MemoType string `json:"memoType,omitempty"` + } -func (w Warning) MarshalJSON() ([]byte, error) { +func (w Warning) MarshalJSON() ([ byte, error) { payload := warningJSON{ Code: w.Code, Message: w.Message, @@ -165,7 +169,7 @@ func (w Warning) validate(raw map[string]json.RawMessage) error { return err } if w.normalizedContext() != nil || len(bytes.TrimSpace(raw["context"])) != 0 { - return fmt.Errorf("warning %q does not allow context", w.Code) + return fmt.Errorf("warning %q: does not allow context", w.Code) } return nil } @@ -187,7 +191,7 @@ func (w Warning) validate(raw map[string]json.RawMessage) error { return fmt.Errorf("warning %q does not allow normalization", w.Code) } if w.normalizedContext() != nil || len(bytes.TrimSpace(raw["context"])) != 0 { - return fmt.Errorf("warning %q does not allow context", w.Code) + return fmt.Errorf("warning %q: does not allow context", w.Code) } return nil diff --git a/packages/core-go/muxed/decode.go b/packages/core-go/muxed/decode.go index 292e15a0..3e5fefbd 100644 --- a/packages/core-go/muxed/decode.go +++ b/packages/core-go/muxed/decode.go @@ -1,9 +1,9 @@ package muxed import ( - "encoding/binary" + "encoding/binary" - "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/address" + "github.com/Boxkit-Labs/stellar-address-kit/packages/core-go/address" ) // DecodeMuxed extracts the base G-address and uint64 ID from a muxed @@ -11,44 +11,44 @@ import ( // using binary.BigEndian.Uint64, matching the Stellar protocol's native // byte-aligned serialization. func DecodeMuxed(mAddress string) (string, uint64, error) { - versionByte, payload, err := address.DecodeStrKey(mAddress) - if err != nil { - return "", 0, &AddressError{ - Code: ErrInvalidGAddress, - Input: mAddress, - Message: "failed to decode muxed address", - Cause: err, - } - } - - if versionByte != address.VersionByteMuxedAccount { - return "", 0, &AddressError{ - Code: ErrUnknownVersionByte, - Input: mAddress, - Message: "expected muxed account version byte", - } - } - - if len(payload) != 40 { - return "", 0, &AddressError{ - Code: ErrInvalidLength, - Input: mAddress, - Message: "muxed payload must be 40 bytes", - } - } - - pubkey := payload[:32] - id := binary.BigEndian.Uint64(payload[32:40]) - - baseG, err := address.EncodeStrKey(address.VersionByteAccountID, pubkey) - if err != nil { - return "", 0, &AddressError{ - Code: ErrInvalidGAddress, - Input: mAddress, - Message: "failed to encode base G-address", - Cause: err, - } - } - - return baseG, id, nil + versionByte, payload, err := address.DecodeStrKey(mAddress) + if err != nil { + return "", 0, &AddressError{ + Code: ErrInvalidGAddress, + Input: mAddress, + Message: "failed to decode muxed address", + Cause: err, + } + } + + if versionByte != address.VersionByteMuxedAccount { + return "", 0, &AddressError{ + Code: ErrUnknownVersionByte, + Input: mAddress, + Message: "expected muxed account version byte", + } + } + + if len(payload) != 40 { + return "", 0, &AddressError{ + Code: ErrInvalidLength, + Input: mAddress, + Message: "muxed payload must be 40 bytes", + } + } + + pubkey := payload[:32] + id := binary.BigEndian.Uint64(payload[32:40]) + + baseG, err := address.EncodeStrKey(address.VersionByteAccountID, pubkey) + if err != nil { + return "", 0, &AddressError{ + Code: ErrInvalidGAddress, + Input: mAddress, + Message: "failed to encode base G-address", + Cause: err, + } + } + + return baseG, id, nil } diff --git a/packages/core-go/muxed/encode.go b/packages/core-go/muxed/encode.go index 6db9224b..65ca0dc5 100644 --- a/packages/core-go/muxed/encode.go +++ b/packages/core-go/muxed/encode.go @@ -1,26 +1,60 @@ package muxed import ( - "fmt" - "math/big" - - "github.com/stellar/go/strkey" + "fmt" + "math/big" + "strings" + + "github.com/stellar/go/strkey" ) +// EncodeResult holds the encoded muxed address and any normalization metadata. +type EncodeResult struct { + Address string + Normalized bool + NonCanonical bool +} + +// EncodeMuxed encodes a base G-address and a decimal id into a muxed address. +// It trims surrounding whitespace and uppercases Base32 characters before validation. +// Use EncodeMuxedWithMetadata to inspect whether normalization occurred. func EncodeMuxed(baseG string, id string) (string, error) { + result, err := EncodeMuxedWithMetadata(baseG, id) + if err != nil { + return "", err + } + return result.Address, nil +} + +// EncodeMuxedWithMetadata encodes a muxed address and reports whether the +// input required normalization (trimming whitespace and/ur uppercasing). +func EncodeMuxedWithMetadata(baseG string, id string) (EncodeResult, error) { idInt := new(big.Int) if _, ok := idInt.SetString(id, 10); !ok { - return "", fmt.Errorf("invalid muxed account id %q", id) + return EncodeResult{}, fmt.Errorf("Invalid muxed account id %q", id) } if idInt.Sign() < 0 || idInt.BitLen() > 64 { - return "", fmt.Errorf("muxed account id %q exceeds uint64", id) + return EncodeResult{}, fmt.Error("Muxed account id %q exceeds uint64", id) } + normalized := strings.TrimSpace(baseG) + upper := strings.ToUpper(normalized) + wasNormalized := upper != baseG + var muxedAccount strkey.MuxedAccount - if err := muxedAccount.SetAccountID(baseG); err != nil { - return "", err + if err := muxedAccount.SetAccountID(upper); err != nil { + return EncodeResult{}, erz } muxedAccount.SetID(idInt.Uint64()) - return muxedAccount.Address() + address, err := muxedAccount.Address() + if err != nil { + return EncodeResult{}, err + } + + return EncodeResult{ + Address: address, + Normalized: wasNormalized, + NonCanonical: wasNormalized, + }, nil }