diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fd3c74e..4087010 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -77,6 +77,7 @@ jobs: "examples/src11-security-information", "examples/src12-contract-factory", "examples/src14-simple-proxy", + "examples/src15-offchain-metadata", "examples/src20-native-asset", ] diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c1b6f..b2d5834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Description of the upcoming release here. ### Added - [#152](https://github.com/FuelLabs/sway-standards/pull/152) Adds inline documentation examples to the SRC-6 standard. +- [#159](https://github.com/FuelLabs/sway-standards/pull/159) Adds the SRC-15 standard. ### Changed diff --git a/docs/spell-check-custom-words.txt b/docs/spell-check-custom-words.txt index 592616b..74620cc 100644 --- a/docs/spell-check-custom-words.txt +++ b/docs/spell-check-custom-words.txt @@ -268,3 +268,6 @@ SetDecimalsEvent UpdateTotalSupplyEvent Onchain onchain +Offchain +offchain +MetadataEvent diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index caa2f15..a42d26c 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,4 +14,5 @@ - [SRC-12: Contract Factory](./src-12-contract-factory.md) - [SRC-13: Soulbound Address](./src-13-soulbound-address.md) - [SRC-14: Simple Upgradeable Contract](./src-14-simple-upgradeable-proxies.md) +- [SRC-15: Offchain Asset Metadata](./src-15-offchain-asset-metadata.md) - [SRC-20: Native Asset](./src-20-native-asset.md) diff --git a/docs/src/index.md b/docs/src/index.md index 770dbfd..a1aee6a 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -42,6 +42,7 @@ use standards::src20::SRC20; - [SRC-9; Metadata Keys Standard](./src-9-metadata-keys.md) is used to store standardized metadata keys for [Native Assets](https://docs.fuel.network/docs/sway/blockchain-development/native_assets) in combination with the SRC-7 standard. - [SRC-6; Vault Standard](./src-6-vault.md) defines the implementation of a standard API for asset vaults developed in Sway. - [SRC-13; Soulbound Address](./src-13-soulbound-address.md) defines the implementation of a soulbound address. +- [SRC-15; Offchain Asset Metadata Standard](./src-15-offchain-asset-metadata.md) is used to associated metadata with [Native Assets](https://docs.fuel.network/docs/sway/blockchain-development/native_assets) offchain. ### Security and Access Control diff --git a/docs/src/src-15-offchain-asset-metadata.md b/docs/src/src-15-offchain-asset-metadata.md new file mode 100644 index 0000000..068209b --- /dev/null +++ b/docs/src/src-15-offchain-asset-metadata.md @@ -0,0 +1,73 @@ +# SRC-15: Off-Chain Native Asset Metadata + +The following standard attempts to define arbitrary metadata for any [Native Asset](https://docs.fuel.network/docs/sway/blockchain-development/native_assets) that is not required by other contracts onchain, in a stateless manner. Any contract that implements the SRC-15 standard MUST implement the [SRC-20](./src-20-native-asset.md) standard. + +## Motivation + +The SRC-15 standard seeks to enable data-rich assets on the Fuel Network while maintaining a stateless solution. All metadata queries are done off-chain using the indexer. + +## Prior Art + +The SRC-7 standard exists prior to the SRC-15 standard and is a stateful solution. The SRC-15 builds off the SRC-7 standard by using the `Metadata` enum however provides a stateless solution. + +The use of generic metadata was originally found in the Sway-Lib's [NFT Library](https://github.com/FuelLabs/sway-libs/tree/v0.12.0/libs/nft) which did not use Fuel's [Native Assets](https://docs.fuel.network/docs/sway/blockchain-development/native_assets). This library has since been deprecated. + +A previous definition for a metadata standard was written in the original edit of the now defunct [SRC-721](https://github.com/FuelLabs/sway-standards/issues/2). This has since been replaced with the [SRC-20](./src-20-native-asset.md) standard as `SubId` was introduced to enable multiple assets to be minted from a single contract. + +## Specification + +### Metadata Type + +The `Metadata` enum from the SRC-7 standard is also used to represent the metadata in the SRC-15 standard. + +### Logging + +The following logs MUST be implemented and emitted to follow the SRC-15 standard. Logging MUST be emitted from the contract which minted the asset. + +#### SRC15MetadataEvent + +The `SRC15MetadataEvent` MUST be emitted at least once for each distinct piece of metadata. The latest emitted `SRC15MetadataEvent` is determined to be the current metadata. + +There SHALL be the following fields in the `SRC15MetadataEvent` struct: + +* `asset`: The `asset` field SHALL be used for the corresponding `AssetId` for the metadata. +* `metadata`: The `metadata` field SHALL be used for the corresponding `Metadata` which represents the metadata of the asset. + +Example: + +```sway +pub struct SRC15MetadataEvent { + pub asset: AssetId, + pub metadata: Metadata, +} +``` + +## Rationale + +The SRC-15 standard allows for data-rich assets in a stateless manner by associating an asset with some metadata that may later be fetched by the indexer. + +## Backwards Compatibility + +This standard is compatible with Fuel's [Native Assets](https://docs.fuel.network/docs/sway/blockchain-development/native_assets) and the [SRC-20](./src-20-native-asset.md) standard. This standard is also compatible with the SRC-7 standard which defines a stateful solution. It also maintains compatibility with existing standards in other ecosystems. + +## Security Considerations + +When indexing for SRC-15 metadata, developers should confirm that the contract that emitted the `SRC15MetadataEvent` is also the contract that minted the asset that the metadata associates with. Additionally, restrictions via access control on who may emit the Metadata should be considered. + +## Example Implementation + +### Single Native Asset + +Example of the SRC-15 implementation where metadata exists for only a single asset with one `SubId`. + +```sway +{{#include ../examples/src15-offchain-metadata/single_asset/src/single_asset.sw}} +``` + +### Multi Native Asset + +Example of the SRC-15 implementation where metadata exists for multiple assets with differing `SubId` values. + +```sway +{{#include ../examples/src15-offchain-metadata/multi_asset/src/multi_asset.sw}} +``` diff --git a/examples/src15-offchain-metadata/Forc.toml b/examples/src15-offchain-metadata/Forc.toml new file mode 100644 index 0000000..c75476c --- /dev/null +++ b/examples/src15-offchain-metadata/Forc.toml @@ -0,0 +1,2 @@ +[workspace] +members = ["single_asset", "multi_asset"] diff --git a/examples/src15-offchain-metadata/multi_asset/Forc.toml b/examples/src15-offchain-metadata/multi_asset/Forc.toml new file mode 100644 index 0000000..5eee9ec --- /dev/null +++ b/examples/src15-offchain-metadata/multi_asset/Forc.toml @@ -0,0 +1,8 @@ +[project] +authors = ["Fuel Labs "] +entry = "multi_asset.sw" +license = "Apache-2.0" +name = "multi_src15_asset" + +[dependencies] +standards = { path = "../../../standards" } diff --git a/examples/src15-offchain-metadata/multi_asset/src/multi_asset.sw b/examples/src15-offchain-metadata/multi_asset/src/multi_asset.sw new file mode 100644 index 0000000..96dac73 --- /dev/null +++ b/examples/src15-offchain-metadata/multi_asset/src/multi_asset.sw @@ -0,0 +1,123 @@ +contract; + +use standards::{ + src15::{ + SRC15MetadataEvent, + }, + src20::{ + SetDecimalsEvent, + SetNameEvent, + SetSymbolEvent, + SRC20, + TotalSupplyEvent, + }, + src7::{ + Metadata, + }, +}; + +use std::{hash::Hash, storage::storage_string::*, string::String}; + +// In this example, all assets minted from this contract have the same decimals, name, and symbol +configurable { + /// The decimals of every asset minted by this contract. + DECIMALS: u8 = 0u8, + /// The name of every asset minted by this contract. + NAME: str[7] = __to_str_array("MyAsset"), + /// The symbol of every asset minted by this contract. + SYMBOL: str[5] = __to_str_array("MYAST"), + /// The metadata for the "social:x" key. + SOCIAL_X: str[12] = __to_str_array("fuel_network"), + /// The metadata for the "site:forum" key. + SITE_FORUM: str[27] = __to_str_array("https://forum.fuel.network/"), +} + +storage { + /// The total number of distinguishable assets this contract has minted. + total_assets: u64 = 0, + /// The total supply of a particular asset. + total_supply: StorageMap = StorageMap {}, +} + +abi EmitSRC15Events { + #[storage(read)] + fn emit_src15_events(asset: AssetId, svg_image: String, health_attribute: u64); +} + +impl EmitSRC15Events for Contract { + #[storage(read)] + fn emit_src15_events(asset: AssetId, svg_image: String, health_attribute: u64) { + // NOTE: There are no checks for if the caller has permissions to emit the metadata + // NOTE: Nothing is stored in storage and there is no method to retrieve the configurables. + + // If this asset does not exist, revert + if storage.total_supply.get(asset).try_read().is_none() { + revert(0); + } + + let metadata_1 = Metadata::String(String::from_ascii_str(from_str_array(SOCIAL_X))); + let metadata_2 = Metadata::String(String::from_ascii_str(from_str_array(SITE_FORUM))); + let metadata_3 = Metadata::String(svg_image); + let metadata_4 = Metadata::Int(health_attribute); + + SRC15MetadataEvent::new(asset, metadata_1).log(); + SRC15MetadataEvent::new(asset, metadata_2).log(); + SRC15MetadataEvent::new(asset, metadata_3).log(); + SRC15MetadataEvent::new(asset, metadata_4).log(); + } +} + +// SRC15 extends SRC20, so this must be included +impl SRC20 for Contract { + #[storage(read)] + fn total_assets() -> u64 { + storage.total_assets.read() + } + + #[storage(read)] + fn total_supply(asset: AssetId) -> Option { + storage.total_supply.get(asset).try_read() + } + + #[storage(read)] + fn name(asset: AssetId) -> Option { + match storage.total_supply.get(asset).try_read() { + Some(_) => Some(String::from_ascii_str(from_str_array(NAME))), + None => None, + } + } + + #[storage(read)] + fn symbol(asset: AssetId) -> Option { + match storage.total_supply.get(asset).try_read() { + Some(_) => Some(String::from_ascii_str(from_str_array(SYMBOL))), + None => None, + } + } + + #[storage(read)] + fn decimals(asset: AssetId) -> Option { + match storage.total_supply.get(asset).try_read() { + Some(_) => Some(DECIMALS), + None => None, + } + } +} + +abi EmitSRC20Data { + fn emit_src20_data(asset: AssetId, total_supply: u64); +} + +impl EmitSRC20Data for Contract { + fn emit_src20_data(asset: AssetId, supply: u64) { + // NOTE: There are no checks for if the caller has permissions to update the metadata + let sender = msg_sender().unwrap(); + let name = Some(String::from_ascii_str(from_str_array(NAME))); + let symbol = Some(String::from_ascii_str(from_str_array(SYMBOL))); + + SetNameEvent::new(asset, name, sender).log(); + SetSymbolEvent::new(asset, symbol, sender).log(); + SetDecimalsEvent::new(asset, DECIMALS, sender).log(); + TotalSupplyEvent::new(asset, supply, sender).log(); + } +} diff --git a/examples/src15-offchain-metadata/single_asset/Forc.toml b/examples/src15-offchain-metadata/single_asset/Forc.toml new file mode 100644 index 0000000..c3566cc --- /dev/null +++ b/examples/src15-offchain-metadata/single_asset/Forc.toml @@ -0,0 +1,8 @@ +[project] +authors = ["Fuel Labs "] +entry = "single_asset.sw" +license = "Apache-2.0" +name = "single_src15_asset" + +[dependencies] +standards = { path = "../../../standards" } diff --git a/examples/src15-offchain-metadata/single_asset/src/single_asset.sw b/examples/src15-offchain-metadata/single_asset/src/single_asset.sw new file mode 100644 index 0000000..ea9d9e8 --- /dev/null +++ b/examples/src15-offchain-metadata/single_asset/src/single_asset.sw @@ -0,0 +1,118 @@ +contract; + +use standards::{ + src15::{ + SRC15MetadataEvent, + }, + src20::{ + SetDecimalsEvent, + SetNameEvent, + SetSymbolEvent, + SRC20, + TotalSupplyEvent, + }, + src7::{ + Metadata, + }, +}; + +use std::string::String; + +configurable { + /// The total supply of coins for the asset minted by this contract. + TOTAL_SUPPLY: u64 = 100_000_000, + /// The decimals of the asset minted by this contract. + DECIMALS: u8 = 9u8, + /// The name of the asset minted by this contract. + NAME: str[7] = __to_str_array("MyAsset"), + /// The symbol of the asset minted by this contract. + SYMBOL: str[5] = __to_str_array("MYTKN"), + /// The metadata for the "social:x" key. + SOCIAL_X: str[12] = __to_str_array("fuel_network"), + /// The metadata for the "site:forum" key. + SITE_FORUM: str[27] = __to_str_array("https://forum.fuel.network/"), + /// The metadata for the "attr:health" key. + ATTR_HEALTH: u64 = 100, +} + +abi EmitSRC15Events { + fn emit_src15_events(); +} + +impl EmitSRC15Events for Contract { + fn emit_src15_events() { + // NOTE: There are no checks for if the caller has permissions to emit the metadata. + // NOTE: Nothing is stored in storage and there is no method to retrieve the configurables. + let asset = AssetId::default(); + let metadata_1 = Metadata::String(String::from_ascii_str(from_str_array(SOCIAL_X))); + let metadata_2 = Metadata::String(String::from_ascii_str(from_str_array(SITE_FORUM))); + let metadata_3 = Metadata::Int(ATTR_HEALTH); + + SRC15MetadataEvent::new(asset, metadata_1).log(); + SRC15MetadataEvent::new(asset, metadata_2).log(); + SRC15MetadataEvent::new(asset, metadata_3).log(); + } +} + +// SRC15 extends SRC20, so this must be included +impl SRC20 for Contract { + #[storage(read)] + fn total_assets() -> u64 { + 1 + } + + #[storage(read)] + fn total_supply(asset: AssetId) -> Option { + if asset == AssetId::default() { + Some(TOTAL_SUPPLY) + } else { + None + } + } + + #[storage(read)] + fn name(asset: AssetId) -> Option { + if asset == AssetId::default() { + Some(String::from_ascii_str(from_str_array(NAME))) + } else { + None + } + } + + #[storage(read)] + fn symbol(asset: AssetId) -> Option { + if asset == AssetId::default() { + Some(String::from_ascii_str(from_str_array(SYMBOL))) + } else { + None + } + } + + #[storage(read)] + fn decimals(asset: AssetId) -> Option { + if asset == AssetId::default() { + Some(DECIMALS) + } else { + None + } + } +} + +abi EmitSRC20Events { + fn emit_src20_events(); +} + +impl EmitSRC20Events for Contract { + fn emit_src20_events() { + // Metadata that is stored as a configurable must be emitted once. + let asset = AssetId::default(); + let sender = msg_sender().unwrap(); + let name = Some(String::from_ascii_str(from_str_array(NAME))); + let symbol = Some(String::from_ascii_str(from_str_array(SYMBOL))); + + SetNameEvent::new(asset, name, sender).log(); + SetSymbolEvent::new(asset, symbol, sender).log(); + SetDecimalsEvent::new(asset, DECIMALS, sender).log(); + TotalSupplyEvent::new(asset, TOTAL_SUPPLY, sender).log(); + } +} diff --git a/standards/src/src15.sw b/standards/src/src15.sw new file mode 100644 index 0000000..7facc3e --- /dev/null +++ b/standards/src/src15.sw @@ -0,0 +1,104 @@ +library; + +use ::src7::Metadata; + +/// The required event to be emitted for the SRC-15 standard. +pub struct SRC15MetadataEvent { + /// The asset for which metadata is associated with. + pub asset: AssetId, + /// The Metadata of the SRC-15 event. + pub metadata: Metadata, +} + +impl core::ops::Eq for SRC15MetadataEvent { + fn eq(self, other: Self) -> bool { + self.asset == other.asset && self.metadata == other.metadata + } +} + +impl SRC15MetadataEvent { + /// Returns a new `SRC15MetadataEvent` event. + /// + /// # Arguments + /// + /// * `asset`: [AssetId] - The asset for which metadata is set. + /// * `metadata`: [Option] - The Metadata that is set. + /// + /// # Returns + /// + /// * [SRC15MetadataEvent] - The new `SRC15MetadataEvent` event. + /// + /// # Examples + /// + /// ```sway + /// use standards::{src7::Metadata, src15::SRC15MetadataEvent}; + /// + /// fn foo(asset: AssetId, metadata: Metadata) { + /// let my_src15_metadata_event = SRC15MetadataEvent::new(asset, metadata); + /// assert(my_src15_metadata_event.asset == asset); + /// assert(my_src15_metadata_event.metadata == metadata); + /// } + /// ``` + pub fn new(asset: AssetId, metadata: Metadata) -> Self { + Self { + asset, + metadata, + } + } + + /// Returns the asset of the `SRC15MetadataEvent` event. + /// + /// # Returns + /// + /// * [AssetId] - The asset for the event. + /// + /// # Examples + /// + /// ```sway + /// use standards::{src7::Metadata, src15::SRC15MetadataEvent}; + /// + /// fn foo(asset: AssetId, metadata: Metadata) { + /// let my_src15_metadata_event = SRC15MetadataEvent::new(asset, metadata); + /// assert(my_src15_metadata_event.asset() == asset); + /// } + /// ``` + pub fn asset(self) -> AssetId { + self.asset + } + + /// Returns the metadata of the `SRC15MetadataEvent` event. + /// + /// # Returns + /// + /// * [Option] - The metadata for the event. + /// + /// # Examples + /// + /// ```sway + /// use standards::{src7::Metadata, src15::SRC15MetadataEvent}; + /// + /// fn foo(asset: AssetId, metadata: Metadata) { + /// let my_src15_metadata_event = SRC15MetadataEvent::new(asset, metadata); + /// assert(my_src15_metadata_event.metadata() == metadata); + /// } + /// ``` + pub fn metadata(self) -> Metadata { + self.metadata + } + + /// Logs the `SRC15MetadataEvent`. + /// + /// # Examples + /// + /// ```sway + /// use standards::{src7::Metadata, src15::SRC15MetadataEvent}; + /// + /// fn foo(asset: AssetId, metadata: Metadata) { + /// let my_event = SRC15MetadataEvent::new(asset, metadata); + /// my_event.log(); + /// } + /// ``` + pub fn log(self) { + log(self); + } +} diff --git a/standards/src/standards.sw b/standards/src/standards.sw index 0fea34b..67698b1 100644 --- a/standards/src/standards.sw +++ b/standards/src/standards.sw @@ -8,4 +8,5 @@ pub mod src10; pub mod src11; pub mod src12; pub mod src14; +pub mod src15; pub mod src20;