Skip to content

Latest commit

 

History

History
137 lines (103 loc) · 9.19 KB

File metadata and controls

137 lines (103 loc) · 9.19 KB

Closes #268


Dynamic NFT Metadata Contract (dynamic_metadata)

Summary

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.

Issue coverage

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}"

Acceptance Criteria

  • 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> and Vec<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.

Files

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`)

Architecture

Core structs

  • ContractConfigadmin, migrator, oracle, image_base_uri, schema_version
  • TokenMetadatatoken_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, frozen
  • OwnershipRecordowner, event: OwnershipEvent, at, previous_owner
  • MetadataVersionversion, snapshot: TokenMetadata, changed_by, reason, changed_at
  • TraitEvolutionRuleid, metric, threshold, target_trait_name, target_trait_value, target_level, new_rarity
  • TriggerRuleid, trigger_type (metric|ownership_count|time), metric_key, threshold, action_type (add_trait|add_numeric_trait|bump_level), action_param, action_value

Storage

  • 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 rule Maps.

Events

TokenMinted, TokenTransferred, TokenBurned, MetadataUpdated, TraitEvolved, MetadataFrozen.

Authorization

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.

Key implementation notes

  • set_performance_metric semantics — only updates performance_score by 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 emit TokenBurned. Owners call is_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 a concat primitive.
  • String comparisonString::as_str() is unavailable on soroban_sdk::String in SDK 21.x, so trigger / action type matching goes through a str_eq(env, &s, b"literal") helper that does byte equality via copy_into_slice.

Testing

cd contracts/dynamic_metadata
cargo test

Tests cover:

  1. Initialization — happy path, double-init, schema validation, base-URI validation, pause flag round-trip, unauthorized pause.
  2. Mint — initial metadata fields, owner-token-index, eager image URI cache, version snapshot on mint, pause-block of mint.
  3. Ownership history — Transfer, Burn (and is_burned), unauthorized transfer rejection.
  4. Metadata updates — owner update, admin update (with sentinel-skip semantics), stranger panic, add/remove string trait, add/increment numeric trait.
  5. Trait overflow — 32 unique keys + 33rd panics with TooManyTraits.
  6. Oracle metrics — set replaces, add accumulates, performance_score mirrors metric, non-oracle panics.
  7. Trait evolution — fires above threshold, doesn't fire below, only highest-thresholded rule fires, manual evolve_traits works, unknown rule removal panics.
  8. Trigger rules — metric fires add_trait, metric fires bump_level, ownership_count fires after enough distinct owners, invalid trigger/action panics, removal.
  9. Versioning — every mutate appends a version, snapshot carries the pre-change snapshot, transfer creates a version, full migration creates 3 versions.
  10. Freezing — blocks update, blocks trait add, blocks burn, allows metric reads; unfreeze restores write access.
  11. Image URI — exact format match, recompute on level-up, base URI change updates URI.
  12. Migration — bumps version + writes post-state + 2 snapshots + emitted event, role check, schema direction check.
  13. Role switching — admin can swap oracle/migrator; previous role is locked out, new role is admitted.
  14. End-to-end — mint + transfer + oracle-driven rule firing + migration in one flow.

Risks / Followups

  • apply_trait_evolution currently reads metadata.performance_score regardless of the rule's metric field; the field is reserved as a human-readable label. A per-metric lookup (by name) is a natural follow-up.
  • count_distinct_owners counts ownership events, not unique addresses. This is generally what you want but worth documenting.
  • Cached image URIs become stale across update_image_base_uri writes (Soroban has no enumeration). Consumers should expect a single lazy recomputation on the next read.

Checklist

  • Implements all 10 acceptance criteria from #268
  • Comprehensive tests (36 cases, 200+ assertions)
  • Clean Rust, follows existing dynamic_nft / nft_upgrade patterns
  • Uses soroban-sdk = 21.0.0 (workspace dep)
  • Both cdylib + rlib crate 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

Verification locally

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.