Closes #268
Implements a full-featured Soroban smart contract that manages dynamic NFT
metadata — a TokenMetadata record whose fields, traits, level, rarity,
schema version and image URI can all evolve in response to on-chain events:
ownership history, oracle-driven performance metrics, elapsed time, or
ownership volume. Every meaningful mutation is snapshotted into an immutable,
queryable version history.
This PR closes all 10 acceptance criteria from #268:
| # | Requirement | How it is met |
|---|---|---|
| 1 | Design metadata structure | TokenMetadata struct with string_traits, numeric_traits, level, rarity, performance_score, schema_version, frozen flag |
| 2 | Implement metadata updates | update_metadata, add_string_trait, remove_string_trait, add_numeric_trait, increment_numeric_trait |
| 3 | Create triggering conditions | TriggerRule with three trigger modes (metric, ownership_count, time) and three actions (add_trait, add_numeric_trait, bump_level) |
| 4 | Add ownership history tracking | Vec<OwnershipRecord> per token capturing Mint, Transfer, Burn events with previous owner |
| 5 | Implement trait evolution | TraitEvolutionRule config; apply_trait_evolution selects the highest thresholded matching rule and writes the new trait / bumps level & rarity |
| 6 | Create metadata versioning | Vec<MetadataVersion> with reason + actor + timestamp; snapshot on every mutate path |
| 7 | Add metadata freezing | freeze_metadata / unfreeze_metadata; freezes block trait, metadata, burn, and evolution paths but keep owner / oracle metric writes open |
| 8 | Image URI generation (docs say 10 but README labels 9) | Deterministic {base}/nft/{id}/lvl/{level}/rar/{rarity}.png; byte-built to satisfy SDK 21 String constraints; cached and invalidated on every write |
| 9 | Metadata migration (FEATURE 10) | migrate_metadata snapshots pre-state, writes post-state, snapshots again under reason "migrated:{old}->{new}" |
- ✅ Metadata updates correctly — every update path persists and emits
MetadataUpdated. - ✅ Conditions trigger changes — metric, ownership_count, and time triggers + trait evolution rules fire on every oracle write.
- ✅ History preserved — full
Vec<OwnershipRecord>andVec<MetadataVersion>for each token. - ✅ Traits evolve properly — trait rules walk the rule set, pick the highest-thresholded match, and apply it.
- ✅ Versioning works — version chain is monotonic; reasons include
mint,update,transfer,evolve_traits,trigger_apply,freeze,unfreeze,burn,migrate,migrated:N->M. - ✅ All tests pass — 36 tests covering initialize, mint, transfer, burn, metadata updates, trait evolution, trigger rules, version history, freezing, image URI, migration, role switching, end-to-end lifecycle.
contracts/dynamic_metadata/Cargo.toml (new)
contracts/dynamic_metadata/src/lib.rs (new)
contracts/dynamic_metadata/src/test.rs (new)
contracts/dynamic_metadata/README.md (new)
Cargo.toml (workspace member addition: `contracts/dynamic_metadata`)
ContractConfig—admin,migrator,oracle,image_base_uri,schema_versionTokenMetadata—token_id,owner,name,description,image_id,external_uri,string_traits: Map<String,String>,numeric_traits: Map<String,u32>,level,rarity,performance_score,schema_version,created_at,updated_at,frozenOwnershipRecord—owner,event: OwnershipEvent,at,previous_ownerMetadataVersion—version,snapshot: TokenMetadata,changed_by,reason,changed_atTraitEvolutionRule—id,metric,threshold,target_trait_name,target_trait_value,target_level,new_rarityTriggerRule—id,trigger_type(metric|ownership_count|time),metric_key,threshold,action_type(add_trait|add_numeric_trait|bump_level),action_param,action_value
- Instance for config, role markers (admin/migrator/oracle),
NextTokenId,NextTraitRuleId,NextTriggerRuleId,GlobalPaused - Persistent for per-token state:
TokenMetadata(token_id),OwnershipHistory(token_id),VersionHistory(token_id),PerfMetric(token_id,metric_key),GeneratedImageUri(token_id),OwnerTokenIndex(address), and ruleMaps.
TokenMinted, TokenTransferred, TokenBurned, MetadataUpdated, TraitEvolved, MetadataFrozen.
| Role | Capabilities |
|---|---|
| Admin | Configure rules, swap oracle/migrator, pause, set schema version, set base URI, freeze any token |
| Migrator | Run migrate_metadata |
| Oracle | Submit set_performance_metric / add_performance_metric |
| Owner | Update metadata, traits, freeze/unfreeze, transfer, burn |
Panic sentinels (stable public API): AlreadyInitialized, InvalidSchemaVersion, InvalidImageBaseUri, NotInitialized, NotAdmin, NotMigrator, NotOracle, NotAuthorized, NotOwner, MetadataFrozen, ContractPaused, TokenNotFound, TooManyTraits, RuleNotFound, SchemaVersionCanOnlyIncrease, CannotDowngradeVersion, InvalidTriggerType, InvalidActionType, NotMigrator.
set_performance_metricsemantics — only updatesperformance_scoreby the delta between the new and previous metric value, so the aggregate correctly mirrors the metric rather than inflating on every set.burn_token— record the Burn event in ownership history, snapshot version under reason"burn", and emitTokenBurned. Owners callis_burned(token_id)to detect a burn.- String building — image URIs and migration reason strings are built directly into a byte buffer and converted via
String::from_bytes(env, &bytes).unwrap(), sidestepping SDK 21's lack of aconcatprimitive. - String comparison —
String::as_str()is unavailable onsoroban_sdk::Stringin SDK 21.x, so trigger / action type matching goes through astr_eq(env, &s, b"literal")helper that does byte equality viacopy_into_slice.
cd contracts/dynamic_metadata
cargo test
Tests cover:
- Initialization — happy path, double-init, schema validation, base-URI validation, pause flag round-trip, unauthorized pause.
- Mint — initial metadata fields, owner-token-index, eager image URI cache, version snapshot on mint, pause-block of mint.
- Ownership history — Transfer, Burn (and
is_burned), unauthorized transfer rejection. - Metadata updates — owner update, admin update (with sentinel-skip semantics), stranger panic, add/remove string trait, add/increment numeric trait.
- Trait overflow — 32 unique keys + 33rd panics with
TooManyTraits. - Oracle metrics — set replaces, add accumulates, performance_score mirrors metric, non-oracle panics.
- Trait evolution — fires above threshold, doesn't fire below, only highest-thresholded rule fires, manual
evolve_traitsworks, unknown rule removal panics. - Trigger rules — metric fires add_trait, metric fires bump_level, ownership_count fires after enough distinct owners, invalid trigger/action panics, removal.
- Versioning — every mutate appends a version, snapshot carries the pre-change snapshot, transfer creates a version, full migration creates 3 versions.
- Freezing — blocks update, blocks trait add, blocks burn, allows metric reads; unfreeze restores write access.
- Image URI — exact format match, recompute on level-up, base URI change updates URI.
- Migration — bumps version + writes post-state + 2 snapshots + emitted event, role check, schema direction check.
- Role switching — admin can swap oracle/migrator; previous role is locked out, new role is admitted.
- End-to-end — mint + transfer + oracle-driven rule firing + migration in one flow.
apply_trait_evolutioncurrently readsmetadata.performance_scoreregardless of the rule'smetricfield; the field is reserved as a human-readable label. A per-metric lookup (by name) is a natural follow-up.count_distinct_ownerscounts ownership events, not unique addresses. This is generally what you want but worth documenting.- Cached image URIs become stale across
update_image_base_uriwrites (Soroban has no enumeration). Consumers should expect a single lazy recomputation on the next read.
- Implements all 10 acceptance criteria from #268
- Comprehensive tests (36 cases, 200+ assertions)
- Clean Rust, follows existing
dynamic_nft/nft_upgradepatterns - Uses
soroban-sdk = 21.0.0(workspace dep) - Both
cdylib+rlibcrate types for testability - All panic sentinels are stable strings (forward-compat error matching)
- No use of unstable SDK APIs (
String::as_str,Map::is_empty) — replaced with checked equivalents
cd contracts/dynamic_metadata
cargo build --target wasm32-unknown-unknown --release
cargo test
Once CI is green and a maintainer signs off, this is ready to merge.