Audience: contributors adding or reviewing input-handling code.
This document describes every place in the workspace where a raw caller-supplied string or byte sequence is transformed into a normalised form before storage or comparison. It is the authoritative reference for "what does the contract do with my input?" questions.
- Tags — casefold and charset validation
- Currency codes — trim, uppercase, and default
- External references — charset validation (case-preserving)
- Migration snapshot checksum — byte-order and input ordering
- What is explicitly not normalised
Where: remitwise-common/src/lib.rs — canonicalize_tags()
Called by: bill_payments (validate_and_normalize_tags) and savings_goals (validate_and_normalize_tags).
| Step | Behaviour |
|---|---|
| Empty batch | panic!("Tags cannot be empty") — callers must supply at least one tag. |
| Length per tag | Must be 1..=32 bytes (TAG_MAX_LEN = 32). Zero or >32 → panic!("Tag must be between 1 and 32 characters"). |
ASCII uppercase A-Z |
Silently folded to lowercase (byte += b'a' - b'A'). |
| Allowed charset after folding | [a-z0-9\-_]. Any other byte invokes the caller-supplied on_invalid_char closure (contracts pass panic_with_error!(env, InvalidTagContent)). |
| Output order | Preserved; the function does not deduplicate. ["Travel", "travel"] → ["travel", "travel"]. Deduplication is the caller's responsibility. |
| Idempotency | Applying canonicalize_tags to already-canonical tags is a no-op. |
// In bill_payments or savings_goals (illustrative — not for direct use)
let raw: Vec<String> = vec!["Travel", "FIRE", "long-term"];
// After canonicalize_tags: ["travel", "fire", "long-term"]The TagIndex storage key in savings_goals is keyed on (Address, canonicalized_tag), so
lookups must pass the tag through canonicalize_tags before querying storage.
| Input | Reason |
|---|---|
"my goal" |
ASCII space (0x20) |
"user@domain" |
@ |
"goal.2025" |
. |
"#savings" |
# |
"urgent!" |
! |
Where: bill_payments/src/lib.rs — validate_and_normalize_currency()
Used in: create_bill (strict path). normalize_currency is a legacy wrapper that silently falls back to "XLM" on error; new code should call validate_and_normalize_currency directly.
| Input | Output | Error? |
|---|---|---|
"" (empty) |
"XLM" |
No — empty defaults to XLM |
" " (spaces only) |
"XLM" |
No — whitespace-only defaults to XLM |
"xlm" |
"XLM" |
No |
"UsDc" |
"USDC" |
No |
" XLM " |
"XLM" |
No — leading/trailing ASCII spaces stripped |
Length > MAX_CURRENCY_LEN (10) |
— | InvalidCurrency |
| Any non-alphabetic character after trim (digit, symbol, space mid-string) | — | InvalidCurrency |
- Empty check — zero-length string → return
"XLM". - Length check — reject if
len > 10. - Trim — strip leading and trailing ASCII space bytes (
0x20). If nothing remains → return"XLM". - Charset check — every byte in the trimmed slice must satisfy
b.is_ascii_alphabetic()(A-Zora-z). Anything else →Err(InvalidCurrency). - Uppercase — each byte receives
b.to_ascii_uppercase().
// bill_payments contract — internal helper (Soroban env required)
// Input: " ngn "
// After trim: "ngn"
// After charset check: ok (all alpha)
// After uppercase: "NGN"
// Stored as: "NGN"External references (external_ref) link on-chain entities to off-chain records in
billing or insurance systems. They are not normalised for case; the exact
caller-supplied casing is stored and compared verbatim.
Where: bill_payments/src/lib.rs
| Rule | Value |
|---|---|
| Minimum length | 1 byte (MIN_EXTERNAL_REF_LEN) |
| Maximum length | 64 bytes (MAX_EXTERNAL_REF_LEN) |
| Allowed bytes | ASCII alphanumeric, -, _, ., : |
| Case treatment | Preserved as-is ("for reconciliation fidelity") |
| Uniqueness | Per-owner index enforces no two bills owned by the same address share the same external_ref. |
// Accepted: "BILL-EXT-123", "inv_2025:001", "A.B.C"
// Rejected: "bill ref" (space), "ref@host" (@), "" (empty), <65-byte string> (too long)Where: insurance/src/lib.rs
| Rule | Value |
|---|---|
| Maximum length | MAX_EXT_REF_LEN (constant defined in insurance/src/lib.rs) |
| Empty string | Rejected (InvalidExternalRef) |
| Case treatment | Preserved as-is |
Note: insurance
external_refacceptsOption<String>;Noneis passed through without validation.
Where: data_migration/src/lib.rs — ExportSnapshot::checksum_for_parts()
Context: off-chain tooling only; the data_migration crate is not #![no_std] and uses std + serde_json.
The checksum binds three inputs concatenated in this exact order:
SHA-256(
version_le_bytes // u32 schema version as 4 little-endian bytes
|| format_utf8_bytes // format tag string as raw UTF-8 ("json", "binary", "csv", "encrypted")
|| canonical_payload_json // serde_json serialisation of the payload (see below)
)
Encoding choices:
| Field | Encoding | Rationale |
|---|---|---|
version |
u32::to_le_bytes() |
Little-endian; consistent across the codebase |
format |
UTF-8 bytes of the lowercase label | No length prefix; boundary is implicit because version is a fixed 4 bytes |
payload |
serde_json::to_vec (compact, no pretty-print) |
Deterministic across runs on the same data |
canonical_payload_bytes serialises the payload variant into a JSON object with a single top-level key:
| Variant | JSON shape |
|---|---|
RemittanceSplit |
{ "RemittanceSplit": { … } } |
SavingsGoals |
{ "SavingsGoals": { … } } |
Generic(BTreeMap<…>) |
{ "Generic": { … } } |
The Generic variant uses a BTreeMap, so object keys are always emitted in
sorted (lexicographic) order by serde_json. This guarantees byte-identical
serialisation across independent re-exports of the same snapshot.
Older snapshots use a rolling 64-bit wrapping sum over the same three fields
(in the same order and with the same little-endian encoding for version),
emitted as a decimal string. The verify_checksum function accepts either
algorithm; ChecksumAlgorithm::Sha256 is the default for new exports.
| Input | Behaviour |
|---|---|
Goal names (savings_goals) |
Stored as-is; no case transformation. |
Bill names (bill_payments) |
Stored as-is; no case transformation. |
Policy names (insurance) |
Stored as-is; no case transformation. |
external_ref |
Case-preserved by design (see §3). |
Stellar addresses (Address) |
Validated by the Soroban SDK, not by contract code. |
Orchestrator operation symbol |
Compared as a Symbol (Soroban value type); no string normalisation. |
- Tag storage key convention —
docs/storage-key-naming-conventions.md - Tagging feature overview —
TAGGING_FEATURE.md - Currency validation issue summary —
SC-015_CURRENCY_VALIDATION.md - Migration binary format stability —
docs/binary-format-stability.md - Migration data format —
docs/migration-formats.md