diff --git a/.gitignore b/.gitignore index 58379de3e..b9ecc5875 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ target/ .vscode/ .idea/ *.iml +\#*\# +.\#* # Auto-gen .cargo-ok diff --git a/Cargo.lock b/Cargo.lock index 791c2ed64..369cfd6b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,7 +1,5 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 - [[package]] name = "anyhow" version = "1.0.52" @@ -69,6 +67,16 @@ dependencies = [ "syn", ] +[[package]] +name = "cosmwasm-schema" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be11bdd8a6e7c0f7d4d8b9fc00850b5a2a2ef5f059e4bda2841224ea78d13677" +dependencies = [ + "schemars", + "serde_json", +] + [[package]] name = "cosmwasm-schema" version = "1.0.0-beta4" @@ -202,11 +210,24 @@ dependencies = [ "serde", ] +[[package]] +name = "cw2981-royalties" +version = "0.10.0" +dependencies = [ + "cosmwasm-schema 0.16.3", + "cosmwasm-std", + "cw721", + "cw721-base", + "schemars", + "serde", + "thiserror", +] + [[package]] name = "cw721" version = "0.11.0" dependencies = [ - "cosmwasm-schema", + "cosmwasm-schema 1.0.0-beta4", "cosmwasm-std", "cw-utils", "schemars", @@ -217,7 +238,7 @@ dependencies = [ name = "cw721-base" version = "0.11.0" dependencies = [ - "cosmwasm-schema", + "cosmwasm-schema 1.0.0-beta4", "cosmwasm-std", "cw-storage-plus", "cw-utils", @@ -232,7 +253,7 @@ dependencies = [ name = "cw721-fixed-price" version = "0.1.0" dependencies = [ - "cosmwasm-schema", + "cosmwasm-schema 1.0.0-beta4", "cosmwasm-std", "cosmwasm-storage", "cw-storage-plus", @@ -250,7 +271,7 @@ dependencies = [ name = "cw721-metadata-onchain" version = "0.11.0" dependencies = [ - "cosmwasm-schema", + "cosmwasm-schema 1.0.0-beta4", "cosmwasm-std", "cw721", "cw721-base", @@ -591,9 +612,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b69f9a4c9740d74c5baa3fd2e547f9525fa8088a8a958e0ca2409a514e33f5fa" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer", "cfg-if", @@ -635,9 +656,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" -version = "1.0.84" +version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecb2e6da8ee5eb9a61068762a32fa9619cc591ceb055b3687f4cd4051ec2e06b" +checksum = "a684ac3dcd8913827e18cd09a68384ee66c1de24157e3c556c9ab16d85695fb7" dependencies = [ "proc-macro2", "quote", diff --git a/contracts/cw2981-royalties/.cargo/config b/contracts/cw2981-royalties/.cargo/config new file mode 100644 index 000000000..7d1a066c8 --- /dev/null +++ b/contracts/cw2981-royalties/.cargo/config @@ -0,0 +1,5 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +wasm-debug = "build --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/cw2981-royalties/Cargo.toml b/contracts/cw2981-royalties/Cargo.toml new file mode 100644 index 000000000..9690faea6 --- /dev/null +++ b/contracts/cw2981-royalties/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "cw2981-royalties" +version = "0.10.0" +authors = ["Alex Lynham "] +edition = "2018" +description = "Basic implementation of royalties for cw721 NFTs with token level royalties" +license = "Apache-2.0" +repository = "https://github.com/CosmWasm/cw-nfts" +homepage = "https://cosmwasm.com" +documentation = "https://docs.cosmwasm.com" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "artifacts/*", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cw721 = { path = "../../packages/cw721", version = "0.11" } +cw721-base = { path = "../cw721-base", version = "0.11", features = ["library"] } +cosmwasm-std = { version = "1.0.0-beta" } +schemars = "0.8.1" +serde = { version = "1.0.103", default-features = false, features = ["derive"] } +thiserror = { version = "1.0.23" } + +[dev-dependencies] +cosmwasm-schema = { version = "0.16.0" } diff --git a/contracts/cw2981-royalties/NOTICE b/contracts/cw2981-royalties/NOTICE new file mode 100644 index 000000000..784a65a20 --- /dev/null +++ b/contracts/cw2981-royalties/NOTICE @@ -0,0 +1,14 @@ +CW-2981 Token-level Royalties +Copyright (C) 2021 Envoy Labs Limited + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/contracts/cw2981-royalties/README.md b/contracts/cw2981-royalties/README.md new file mode 100644 index 000000000..3e66b3130 --- /dev/null +++ b/contracts/cw2981-royalties/README.md @@ -0,0 +1,65 @@ +# CW-2981 Token-level Royalties + +An example of porting EIP-2981 to implement royalties at a token mint level. + +Builds on top of the metadata pattern in `cw721-metadata-onchain`. + +All of the CW-721 logic and behaviour you would expect for an NFT is implemented as normal, but additionally at mint time, royalty information can be attached to a token. + +Exposes two new query message types that can be called: + +```rust +// Should be called on sale to see if royalties are owed +// by the marketplace selling the NFT. +// See https://eips.ethereum.org/EIPS/eip-2981 +RoyaltyInfo { + token_id: String, + // the denom of this sale must also be the denom returned by RoyaltiesInfoResponse + sale_price: Uint128, +}, +// Called against the contract to signal that CW-2981 is implemented +CheckRoyalties {}, +``` + +The responses are: + +```rust +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct RoyaltiesInfoResponse { + pub address: String, + // Note that this must be the same denom as that passed in to RoyaltyInfo + // rounding up or down is at the discretion of the implementer + pub royalty_amount: Uint128, +} + +/// Shows if the contract implements royalties +/// if royalty_payments is true, marketplaces should pay them +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct CheckRoyaltiesResponse { + pub royalty_payments: bool, +} +``` + + +To set this information, new meta fields are available on mint: + +```rust + /// specify whether royalties are set on this token + pub royalty_payments: bool, + /// This is how much the minter takes as a cut when sold + pub royalty_percentage: Option, + /// The payment address, may be different to or the same + /// as the minter addr + /// question: how do we validate this? + pub royalty_payment_address: Option, +``` + +Note that the `royalty_payment_address` could of course be a single address, a multisig, or a DAO. + +## A note on CheckRoyalties + +For this contract, there's nothing to check. This hook is expected to be present to check if the contract does implement CW2981 and signal that on sale royalties should be checked. With the implementation at token level it should always return true because it's up to the token. + +Of course contracts that extend this can determine their own behaviour and replace this function if they have more complex behaviour (for example, you could maintain a secondary index of which tokens actually have royalties). + +In this super simple case that isn't necessary. diff --git a/contracts/cw2981-royalties/examples/schema.rs b/contracts/cw2981-royalties/examples/schema.rs new file mode 100644 index 000000000..38bc736b4 --- /dev/null +++ b/contracts/cw2981-royalties/examples/schema.rs @@ -0,0 +1,42 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, export_schema_with_title, remove_schemas, schema_for}; + +use cw721::{ + AllNftInfoResponse, ContractInfoResponse, NftInfoResponse, NumTokensResponse, + OperatorsResponse, OwnerOfResponse, TokensResponse, +}; +use cw721_base::{ExecuteMsg, Extension, InstantiateMsg, MinterResponse, QueryMsg}; + +use cw2981_royalties::msg::{CheckRoyaltiesResponse, Cw2981QueryMsg, RoyaltiesInfoResponse}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema_with_title(&schema_for!(ExecuteMsg), &out_dir, "ExecuteMsg"); + export_schema(&schema_for!(QueryMsg), &out_dir); + export_schema_with_title( + &schema_for!(AllNftInfoResponse), + &out_dir, + "AllNftInfoResponse", + ); + export_schema(&schema_for!(OperatorsResponse), &out_dir); + export_schema(&schema_for!(ContractInfoResponse), &out_dir); + export_schema(&schema_for!(MinterResponse), &out_dir); + export_schema_with_title( + &schema_for!(NftInfoResponse), + &out_dir, + "NftInfoResponse", + ); + export_schema(&schema_for!(NumTokensResponse), &out_dir); + export_schema(&schema_for!(OwnerOfResponse), &out_dir); + export_schema(&schema_for!(TokensResponse), &out_dir); + export_schema(&schema_for!(Cw2981QueryMsg), &out_dir); + export_schema(&schema_for!(RoyaltiesInfoResponse), &out_dir); + export_schema(&schema_for!(CheckRoyaltiesResponse), &out_dir); +} diff --git a/contracts/cw2981-royalties/schema/all_nft_info_response.json b/contracts/cw2981-royalties/schema/all_nft_info_response.json new file mode 100644 index 000000000..bfc334bf7 --- /dev/null +++ b/contracts/cw2981-royalties/schema/all_nft_info_response.json @@ -0,0 +1,155 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AllNftInfoResponse", + "type": "object", + "required": [ + "access", + "info" + ], + "properties": { + "access": { + "description": "Who can transfer the token", + "allOf": [ + { + "$ref": "#/definitions/OwnerOfResponse" + } + ] + }, + "info": { + "description": "Data on the token itself,", + "allOf": [ + { + "$ref": "#/definitions/NftInfoResponse_for_Nullable_Empty" + } + ] + } + }, + "definitions": { + "Approval": { + "type": "object", + "required": [ + "expires", + "spender" + ], + "properties": { + "expires": { + "description": "When the Approval expires (maybe Expiration::never)", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "spender": { + "description": "Account that can transfer/send the token", + "type": "string" + } + } + }, + "Empty": { + "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)", + "type": "object" + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object" + } + }, + "additionalProperties": false + } + ] + }, + "NftInfoResponse_for_Nullable_Empty": { + "type": "object", + "properties": { + "extension": { + "description": "You can add any custom metadata here when you extend cw721-base", + "anyOf": [ + { + "$ref": "#/definitions/Empty" + }, + { + "type": "null" + } + ] + }, + "token_uri": { + "description": "Universal resource identifier for this NFT Should point to a JSON file that conforms to the ERC721 Metadata JSON Schema", + "type": [ + "string", + "null" + ] + } + } + }, + "OwnerOfResponse": { + "type": "object", + "required": [ + "approvals", + "owner" + ], + "properties": { + "approvals": { + "description": "If set this address is approved to transfer/send the token as well", + "type": "array", + "items": { + "$ref": "#/definitions/Approval" + } + }, + "owner": { + "description": "Owner of the token", + "type": "string" + } + } + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/cw2981-royalties/schema/check_royalties_response.json b/contracts/cw2981-royalties/schema/check_royalties_response.json new file mode 100644 index 000000000..4f2fd2080 --- /dev/null +++ b/contracts/cw2981-royalties/schema/check_royalties_response.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CheckRoyaltiesResponse", + "description": "Shows if the contract implements royalties if royalty_payments is true, marketplaces should pay them", + "type": "object", + "required": [ + "royalty_payments" + ], + "properties": { + "royalty_payments": { + "type": "boolean" + } + } +} diff --git a/contracts/cw2981-royalties/schema/contract_info_response.json b/contracts/cw2981-royalties/schema/contract_info_response.json new file mode 100644 index 000000000..a16712589 --- /dev/null +++ b/contracts/cw2981-royalties/schema/contract_info_response.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ContractInfoResponse", + "type": "object", + "required": [ + "name", + "symbol" + ], + "properties": { + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + } + } +} diff --git a/contracts/cw2981-royalties/schema/cw2981_query_msg.json b/contracts/cw2981-royalties/schema/cw2981_query_msg.json new file mode 100644 index 000000000..083b2806d --- /dev/null +++ b/contracts/cw2981-royalties/schema/cw2981_query_msg.json @@ -0,0 +1,259 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Cw2981QueryMsg", + "oneOf": [ + { + "description": "Should be called on sale to see if royalties are owed by the marketplace selling the NFT, if CheckRoyalties returns true See https://eips.ethereum.org/EIPS/eip-2981", + "type": "object", + "required": [ + "royalty_info" + ], + "properties": { + "royalty_info": { + "type": "object", + "required": [ + "sale_price", + "token_id" + ], + "properties": { + "sale_price": { + "$ref": "#/definitions/Uint128" + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Called against contract to determine if this NFT implements royalties. Should return a boolean as part of CheckRoyaltiesResponse - default can simply be true if royalties are implemented at token level (i.e. always check on sale)", + "type": "object", + "required": [ + "check_royalties" + ], + "properties": { + "check_royalties": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "description": "Return the owner of the given token, error if token does not exist Return type: OwnerOfResponse", + "type": "object", + "required": [ + "owner_of" + ], + "properties": { + "owner_of": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "include_expired": { + "description": "unset or false will filter out expired approvals, you must set to true to see them", + "type": [ + "boolean", + "null" + ] + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "List all operators that can access all of the owner's tokens. Return type: `OperatorsResponse`", + "type": "object", + "required": [ + "all_operators" + ], + "properties": { + "all_operators": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "include_expired": { + "description": "unset or false will filter out expired approvals, you must set to true to see them", + "type": [ + "boolean", + "null" + ] + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Total number of tokens issued", + "type": "object", + "required": [ + "num_tokens" + ], + "properties": { + "num_tokens": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "description": "With MetaData Extension. Returns top-level metadata about the contract: `ContractInfoResponse`", + "type": "object", + "required": [ + "contract_info" + ], + "properties": { + "contract_info": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "description": "With MetaData Extension. Returns metadata about one particular token, based on *ERC721 Metadata JSON Schema* but directly from the contract: `NftInfoResponse`", + "type": "object", + "required": [ + "nft_info" + ], + "properties": { + "nft_info": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "With MetaData Extension. Returns the result of both `NftInfo` and `OwnerOf` as one query as an optimization for clients: `AllNftInfo`", + "type": "object", + "required": [ + "all_nft_info" + ], + "properties": { + "all_nft_info": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "include_expired": { + "description": "unset or false will filter out expired approvals, you must set to true to see them", + "type": [ + "boolean", + "null" + ] + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "With Enumerable extension. Returns all tokens owned by the given address, [] if unset. Return type: TokensResponse.", + "type": "object", + "required": [ + "tokens" + ], + "properties": { + "tokens": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "With Enumerable extension. Requires pagination. Lists all token_ids controlled by the contract. Return type: TokensResponse.", + "type": "object", + "required": [ + "all_tokens" + ], + "properties": { + "all_tokens": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/cw2981-royalties/schema/execute_msg.json b/contracts/cw2981-royalties/schema/execute_msg.json new file mode 100644 index 000000000..5cdf6603f --- /dev/null +++ b/contracts/cw2981-royalties/schema/execute_msg.json @@ -0,0 +1,310 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "description": "This is like Cw721ExecuteMsg but we add a Mint command for an owner to make this stand-alone. You will likely want to remove mint and use other control logic in any contract that inherits this.", + "oneOf": [ + { + "description": "Transfer is a base message to move a token to another account without triggering actions", + "type": "object", + "required": [ + "transfer_nft" + ], + "properties": { + "transfer_nft": { + "type": "object", + "required": [ + "recipient", + "token_id" + ], + "properties": { + "recipient": { + "type": "string" + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Send is a base message to transfer a token to a contract and trigger an action on the receiving contract.", + "type": "object", + "required": [ + "send_nft" + ], + "properties": { + "send_nft": { + "type": "object", + "required": [ + "contract", + "msg", + "token_id" + ], + "properties": { + "contract": { + "type": "string" + }, + "msg": { + "$ref": "#/definitions/Binary" + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Allows operator to transfer / send the token from the owner's account. If expiration is set, then this allowance has a time/height limit", + "type": "object", + "required": [ + "approve" + ], + "properties": { + "approve": { + "type": "object", + "required": [ + "spender", + "token_id" + ], + "properties": { + "expires": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "spender": { + "type": "string" + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Remove previously granted Approval", + "type": "object", + "required": [ + "revoke" + ], + "properties": { + "revoke": { + "type": "object", + "required": [ + "spender", + "token_id" + ], + "properties": { + "spender": { + "type": "string" + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Allows operator to transfer / send any token from the owner's account. If expiration is set, then this allowance has a time/height limit", + "type": "object", + "required": [ + "approve_all" + ], + "properties": { + "approve_all": { + "type": "object", + "required": [ + "operator" + ], + "properties": { + "expires": { + "anyOf": [ + { + "$ref": "#/definitions/Expiration" + }, + { + "type": "null" + } + ] + }, + "operator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Remove previously granted ApproveAll permission", + "type": "object", + "required": [ + "revoke_all" + ], + "properties": { + "revoke_all": { + "type": "object", + "required": [ + "operator" + ], + "properties": { + "operator": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Mint a new NFT, can only be called by the contract minter", + "type": "object", + "required": [ + "mint" + ], + "properties": { + "mint": { + "$ref": "#/definitions/MintMsg_for_Nullable_Empty" + } + }, + "additionalProperties": false + }, + { + "description": "Burn an NFT the sender has access to", + "type": "object", + "required": [ + "burn" + ], + "properties": { + "burn": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + } + ], + "definitions": { + "Binary": { + "description": "Binary is a wrapper around Vec to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec", + "type": "string" + }, + "Empty": { + "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)", + "type": "object" + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object" + } + }, + "additionalProperties": false + } + ] + }, + "MintMsg_for_Nullable_Empty": { + "type": "object", + "required": [ + "owner", + "token_id" + ], + "properties": { + "extension": { + "description": "Any custom extension used by this contract", + "anyOf": [ + { + "$ref": "#/definitions/Empty" + }, + { + "type": "null" + } + ] + }, + "owner": { + "description": "The owner of the newly minter NFT", + "type": "string" + }, + "token_id": { + "description": "Unique ID of the NFT", + "type": "string" + }, + "token_uri": { + "description": "Universal resource identifier for this NFT Should point to a JSON file that conforms to the ERC721 Metadata JSON Schema", + "type": [ + "string", + "null" + ] + } + } + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/cw2981-royalties/schema/instantiate_msg.json b/contracts/cw2981-royalties/schema/instantiate_msg.json new file mode 100644 index 000000000..b024c82c1 --- /dev/null +++ b/contracts/cw2981-royalties/schema/instantiate_msg.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "minter", + "name", + "symbol" + ], + "properties": { + "minter": { + "description": "The minter is the only one who can create new NFTs. This is designed for a base NFT that is controlled by an external program or contract. You will likely replace this with custom logic in custom NFTs", + "type": "string" + }, + "name": { + "description": "Name of the NFT contract", + "type": "string" + }, + "symbol": { + "description": "Symbol of the NFT contract", + "type": "string" + } + } +} diff --git a/contracts/cw2981-royalties/schema/minter_response.json b/contracts/cw2981-royalties/schema/minter_response.json new file mode 100644 index 000000000..a20e0d767 --- /dev/null +++ b/contracts/cw2981-royalties/schema/minter_response.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MinterResponse", + "description": "Shows who can mint these tokens", + "type": "object", + "required": [ + "minter" + ], + "properties": { + "minter": { + "type": "string" + } + } +} diff --git a/contracts/cw2981-royalties/schema/nft_info_response.json b/contracts/cw2981-royalties/schema/nft_info_response.json new file mode 100644 index 000000000..e6bf1d405 --- /dev/null +++ b/contracts/cw2981-royalties/schema/nft_info_response.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NftInfoResponse", + "type": "object", + "properties": { + "extension": { + "description": "You can add any custom metadata here when you extend cw721-base", + "anyOf": [ + { + "$ref": "#/definitions/Empty" + }, + { + "type": "null" + } + ] + }, + "token_uri": { + "description": "Universal resource identifier for this NFT Should point to a JSON file that conforms to the ERC721 Metadata JSON Schema", + "type": [ + "string", + "null" + ] + } + }, + "definitions": { + "Empty": { + "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)", + "type": "object" + } + } +} diff --git a/contracts/cw2981-royalties/schema/num_tokens_response.json b/contracts/cw2981-royalties/schema/num_tokens_response.json new file mode 100644 index 000000000..4647c23aa --- /dev/null +++ b/contracts/cw2981-royalties/schema/num_tokens_response.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NumTokensResponse", + "type": "object", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } +} diff --git a/contracts/cw2981-royalties/schema/operators_response.json b/contracts/cw2981-royalties/schema/operators_response.json new file mode 100644 index 000000000..53703072c --- /dev/null +++ b/contracts/cw2981-royalties/schema/operators_response.json @@ -0,0 +1,97 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OperatorsResponse", + "type": "object", + "required": [ + "operators" + ], + "properties": { + "operators": { + "type": "array", + "items": { + "$ref": "#/definitions/Approval" + } + } + }, + "definitions": { + "Approval": { + "type": "object", + "required": [ + "expires", + "spender" + ], + "properties": { + "expires": { + "description": "When the Approval expires (maybe Expiration::never)", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "spender": { + "description": "Account that can transfer/send the token", + "type": "string" + } + } + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object" + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/cw2981-royalties/schema/owner_of_response.json b/contracts/cw2981-royalties/schema/owner_of_response.json new file mode 100644 index 000000000..1258d671f --- /dev/null +++ b/contracts/cw2981-royalties/schema/owner_of_response.json @@ -0,0 +1,103 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OwnerOfResponse", + "type": "object", + "required": [ + "approvals", + "owner" + ], + "properties": { + "approvals": { + "description": "If set this address is approved to transfer/send the token as well", + "type": "array", + "items": { + "$ref": "#/definitions/Approval" + } + }, + "owner": { + "description": "Owner of the token", + "type": "string" + } + }, + "definitions": { + "Approval": { + "type": "object", + "required": [ + "expires", + "spender" + ], + "properties": { + "expires": { + "description": "When the Approval expires (maybe Expiration::never)", + "allOf": [ + { + "$ref": "#/definitions/Expiration" + } + ] + }, + "spender": { + "description": "Account that can transfer/send the token", + "type": "string" + } + } + }, + "Expiration": { + "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)", + "oneOf": [ + { + "description": "AtHeight will expire when `env.block.height` >= height", + "type": "object", + "required": [ + "at_height" + ], + "properties": { + "at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + { + "description": "AtTime will expire when `env.block.time` >= time", + "type": "object", + "required": [ + "at_time" + ], + "properties": { + "at_time": { + "$ref": "#/definitions/Timestamp" + } + }, + "additionalProperties": false + }, + { + "description": "Never will never expire. Used to express the empty variant", + "type": "object", + "required": [ + "never" + ], + "properties": { + "never": { + "type": "object" + } + }, + "additionalProperties": false + } + ] + }, + "Timestamp": { + "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```", + "allOf": [ + { + "$ref": "#/definitions/Uint64" + } + ] + }, + "Uint64": { + "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/cw2981-royalties/schema/query_msg.json b/contracts/cw2981-royalties/schema/query_msg.json new file mode 100644 index 000000000..cd3a95619 --- /dev/null +++ b/contracts/cw2981-royalties/schema/query_msg.json @@ -0,0 +1,285 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Return the owner of the given token, error if token does not exist Return type: OwnerOfResponse", + "type": "object", + "required": [ + "owner_of" + ], + "properties": { + "owner_of": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "include_expired": { + "description": "unset or false will filter out expired approvals, you must set to true to see them", + "type": [ + "boolean", + "null" + ] + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Return operator that can access all of the owner's tokens. Return type: `ApprovalResponse`", + "type": "object", + "required": [ + "approval" + ], + "properties": { + "approval": { + "type": "object", + "required": [ + "spender", + "token_id" + ], + "properties": { + "include_expired": { + "type": [ + "boolean", + "null" + ] + }, + "spender": { + "type": "string" + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Return approvals that a token has Return type: `ApprovalsResponse`", + "type": "object", + "required": [ + "approvals" + ], + "properties": { + "approvals": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "include_expired": { + "type": [ + "boolean", + "null" + ] + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "List all operators that can access all of the owner's tokens Return type: `OperatorsResponse`", + "type": "object", + "required": [ + "all_operators" + ], + "properties": { + "all_operators": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "include_expired": { + "description": "unset or false will filter out expired items, you must set to true to see them", + "type": [ + "boolean", + "null" + ] + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "Total number of tokens issued", + "type": "object", + "required": [ + "num_tokens" + ], + "properties": { + "num_tokens": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "description": "With MetaData Extension. Returns top-level metadata about the contract: `ContractInfoResponse`", + "type": "object", + "required": [ + "contract_info" + ], + "properties": { + "contract_info": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "description": "With MetaData Extension. Returns metadata about one particular token, based on *ERC721 Metadata JSON Schema* but directly from the contract: `NftInfoResponse`", + "type": "object", + "required": [ + "nft_info" + ], + "properties": { + "nft_info": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "With MetaData Extension. Returns the result of both `NftInfo` and `OwnerOf` as one query as an optimization for clients: `AllNftInfo`", + "type": "object", + "required": [ + "all_nft_info" + ], + "properties": { + "all_nft_info": { + "type": "object", + "required": [ + "token_id" + ], + "properties": { + "include_expired": { + "description": "unset or false will filter out expired approvals, you must set to true to see them", + "type": [ + "boolean", + "null" + ] + }, + "token_id": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "description": "With Enumerable extension. Returns all tokens owned by the given address, [] if unset. Return type: TokensResponse.", + "type": "object", + "required": [ + "tokens" + ], + "properties": { + "tokens": { + "type": "object", + "required": [ + "owner" + ], + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "owner": { + "type": "string" + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false + }, + { + "description": "With Enumerable extension. Requires pagination. Lists all token_ids controlled by the contract. Return type: TokensResponse.", + "type": "object", + "required": [ + "all_tokens" + ], + "properties": { + "all_tokens": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "minter" + ], + "properties": { + "minter": { + "type": "object" + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/cw2981-royalties/schema/royalties_info_response.json b/contracts/cw2981-royalties/schema/royalties_info_response.json new file mode 100644 index 000000000..fc2c7bd93 --- /dev/null +++ b/contracts/cw2981-royalties/schema/royalties_info_response.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RoyaltiesInfoResponse", + "type": "object", + "required": [ + "address", + "royalty_amount" + ], + "properties": { + "address": { + "type": "string" + }, + "royalty_amount": { + "$ref": "#/definitions/Uint128" + } + }, + "definitions": { + "Uint128": { + "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```", + "type": "string" + } + } +} diff --git a/contracts/cw2981-royalties/schema/tokens_response.json b/contracts/cw2981-royalties/schema/tokens_response.json new file mode 100644 index 000000000..b8e3d75b5 --- /dev/null +++ b/contracts/cw2981-royalties/schema/tokens_response.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TokensResponse", + "type": "object", + "required": [ + "tokens" + ], + "properties": { + "tokens": { + "description": "Contains all token_ids in lexicographical ordering If there are more than `limit`, use `start_from` in future queries to achieve pagination.", + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/contracts/cw2981-royalties/src/lib.rs b/contracts/cw2981-royalties/src/lib.rs new file mode 100644 index 000000000..be2b5d6d1 --- /dev/null +++ b/contracts/cw2981-royalties/src/lib.rs @@ -0,0 +1,252 @@ +pub mod msg; +pub mod query; + +use cosmwasm_std::to_binary; +pub use query::{check_royalties, query_royalties_info}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::msg::Cw2981QueryMsg; +use cosmwasm_std::Empty; +use cw721_base::Cw721Contract; +pub use cw721_base::{ContractError, InstantiateMsg, MintMsg, MinterResponse}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug, Default)] +pub struct Trait { + pub display_type: Option, + pub trait_type: String, + pub value: String, +} + +// see: https://docs.opensea.io/docs/metadata-standards +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug, Default)] +pub struct Metadata { + pub image: Option, + pub image_data: Option, + pub external_url: Option, + pub description: Option, + pub name: Option, + pub attributes: Option>, + pub background_color: Option, + pub animation_url: Option, + pub youtube_url: Option, + /// This is how much the minter takes as a cut when sold + /// royalties are owed on this token if it is Some + pub royalty_percentage: Option, + /// The payment address, may be different to or the same + /// as the minter addr + /// question: how do we validate this? + pub royalty_payment_address: Option, +} + +pub type Extension = Option; + +pub type MintExtension = Option; + +pub type Cw2981Contract<'a> = Cw721Contract<'a, Extension, Empty>; +pub type ExecuteMsg = cw721_base::ExecuteMsg; + +#[cfg(not(feature = "library"))] +pub mod entry { + use super::*; + + use cosmwasm_std::entry_point; + use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; + + #[entry_point] + pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, + ) -> StdResult { + Cw2981Contract::default().instantiate(deps, env, info, msg) + } + + #[entry_point] + pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, + ) -> Result { + Cw2981Contract::default().execute(deps, env, info, msg) + } + + #[entry_point] + pub fn query(deps: Deps, env: Env, msg: Cw2981QueryMsg) -> StdResult { + match msg { + Cw2981QueryMsg::RoyaltyInfo { + token_id, + sale_price, + } => to_binary(&query_royalties_info(deps, token_id, sale_price)?), + Cw2981QueryMsg::CheckRoyalties {} => to_binary(&check_royalties(deps)?), + _ => Cw2981Contract::default().query(deps, env, msg.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::msg::{CheckRoyaltiesResponse, RoyaltiesInfoResponse}; + + use cosmwasm_std::{from_binary, Uint128}; + + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cw721::Cw721Query; + + const CREATOR: &str = "creator"; + + #[test] + fn use_metadata_extension() { + let mut deps = mock_dependencies(); + let contract = Cw2981Contract::default(); + + let info = mock_info(CREATOR, &[]); + let init_msg = InstantiateMsg { + name: "SpaceShips".to_string(), + symbol: "SPACE".to_string(), + minter: CREATOR.to_string(), + }; + entry::instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let token_id = "Enterprise"; + let mint_msg = MintMsg { + token_id: token_id.to_string(), + owner: "john".to_string(), + token_uri: Some("https://starships.example.com/Starship/Enterprise.json".into()), + extension: Some(Metadata { + description: Some("Spaceship with Warp Drive".into()), + name: Some("Starship USS Enterprise".to_string()), + ..Metadata::default() + }), + }; + let exec_msg = ExecuteMsg::Mint(mint_msg.clone()); + entry::execute(deps.as_mut(), mock_env(), info, exec_msg).unwrap(); + + let res = contract.nft_info(deps.as_ref(), token_id.into()).unwrap(); + assert_eq!(res.token_uri, mint_msg.token_uri); + assert_eq!(res.extension, mint_msg.extension); + } + + #[test] + fn check_royalties_response() { + let mut deps = mock_dependencies(); + let _contract = Cw2981Contract::default(); + + let info = mock_info(CREATOR, &[]); + let init_msg = InstantiateMsg { + name: "SpaceShips".to_string(), + symbol: "SPACE".to_string(), + minter: CREATOR.to_string(), + }; + entry::instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let token_id = "Enterprise"; + let mint_msg = MintMsg { + token_id: token_id.to_string(), + owner: "john".to_string(), + token_uri: Some("https://starships.example.com/Starship/Enterprise.json".into()), + extension: Some(Metadata { + description: Some("Spaceship with Warp Drive".into()), + name: Some("Starship USS Enterprise".to_string()), + ..Metadata::default() + }), + }; + let exec_msg = ExecuteMsg::Mint(mint_msg); + entry::execute(deps.as_mut(), mock_env(), info, exec_msg).unwrap(); + + let expected = CheckRoyaltiesResponse { + royalty_payments: true, + }; + let res = check_royalties(deps.as_ref()).unwrap(); + assert_eq!(res, expected); + + // also check the longhand way + let query_msg = Cw2981QueryMsg::CheckRoyalties {}; + let query_res: CheckRoyaltiesResponse = + from_binary(&entry::query(deps.as_ref(), mock_env(), query_msg).unwrap()).unwrap(); + assert_eq!(query_res, expected); + } + + #[test] + fn check_token_royalties() { + let mut deps = mock_dependencies(); + + let info = mock_info(CREATOR, &[]); + let init_msg = InstantiateMsg { + name: "SpaceShips".to_string(), + symbol: "SPACE".to_string(), + minter: CREATOR.to_string(), + }; + entry::instantiate(deps.as_mut(), mock_env(), info.clone(), init_msg).unwrap(); + + let token_id = "Enterprise"; + let mint_msg = MintMsg { + token_id: token_id.to_string(), + owner: "JeanLuc".to_string(), + token_uri: Some("https://starships.example.com/Starship/Enterprise.json".into()), + extension: Some(Metadata { + description: Some("Spaceship with Warp Drive".into()), + name: Some("Starship USS Enterprise".to_string()), + royalty_payment_address: Some("JeanLuc".to_string()), + royalty_percentage: Some(10), + ..Metadata::default() + }), + }; + let exec_msg = ExecuteMsg::Mint(mint_msg.clone()); + entry::execute(deps.as_mut(), mock_env(), info.clone(), exec_msg).unwrap(); + + let expected = RoyaltiesInfoResponse { + address: mint_msg.owner, + royalty_amount: Uint128::new(10), + }; + let res = + query_royalties_info(deps.as_ref(), token_id.to_string(), Uint128::new(100)).unwrap(); + assert_eq!(res, expected); + + // also check the longhand way + let query_msg = Cw2981QueryMsg::RoyaltyInfo { + token_id: token_id.to_string(), + sale_price: Uint128::new(100), + }; + let query_res: RoyaltiesInfoResponse = + from_binary(&entry::query(deps.as_ref(), mock_env(), query_msg).unwrap()).unwrap(); + assert_eq!(query_res, expected); + + // check for rounding down + // which is the default behaviour + let voyager_token_id = "Voyager"; + let second_mint_msg = MintMsg { + token_id: voyager_token_id.to_string(), + owner: "Janeway".to_string(), + token_uri: Some("https://starships.example.com/Starship/Voyager.json".into()), + extension: Some(Metadata { + description: Some("Spaceship with Warp Drive".into()), + name: Some("Starship USS Voyager".to_string()), + royalty_payment_address: Some("Janeway".to_string()), + royalty_percentage: Some(4), + ..Metadata::default() + }), + }; + let voyager_exec_msg = ExecuteMsg::Mint(second_mint_msg.clone()); + entry::execute(deps.as_mut(), mock_env(), info, voyager_exec_msg).unwrap(); + + // 43 x 0.04 (i.e., 4%) should be 1.72 + // we expect this to be rounded down to 1 + let voyager_expected = RoyaltiesInfoResponse { + address: second_mint_msg.owner, + royalty_amount: Uint128::new(1), + }; + + let res = query_royalties_info( + deps.as_ref(), + voyager_token_id.to_string(), + Uint128::new(43), + ) + .unwrap(); + assert_eq!(res, voyager_expected); + } +} diff --git a/contracts/cw2981-royalties/src/msg.rs b/contracts/cw2981-royalties/src/msg.rs new file mode 100644 index 000000000..f33ea6bf2 --- /dev/null +++ b/contracts/cw2981-royalties/src/msg.rs @@ -0,0 +1,141 @@ +use cosmwasm_std::Uint128; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use cw721_base::msg::QueryMsg as CW721QueryMsg; + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +#[serde(rename_all = "snake_case")] +pub enum Cw2981QueryMsg { + /// Should be called on sale to see if royalties are owed + /// by the marketplace selling the NFT, if CheckRoyalties + /// returns true + /// See https://eips.ethereum.org/EIPS/eip-2981 + RoyaltyInfo { + token_id: String, + // the denom of this sale must also be the denom returned by RoyaltiesInfoResponse + // this was originally implemented as a Coin + // however that would mean you couldn't buy using CW20s + // as CW20 is just mapping of addr -> balance + sale_price: Uint128, + }, + /// Called against contract to determine if this NFT + /// implements royalties. Should return a boolean as part of + /// CheckRoyaltiesResponse - default can simply be true + /// if royalties are implemented at token level + /// (i.e. always check on sale) + CheckRoyalties {}, + /// Return the owner of the given token, error if token does not exist + /// Return type: OwnerOfResponse + OwnerOf { + token_id: String, + /// unset or false will filter out expired approvals, you must set to true to see them + include_expired: Option, + }, + /// List all operators that can access all of the owner's tokens. + /// Return type: `OperatorsResponse` + AllOperators { + owner: String, + /// unset or false will filter out expired approvals, you must set to true to see them + include_expired: Option, + start_after: Option, + limit: Option, + }, + /// Total number of tokens issued + NumTokens {}, + + /// With MetaData Extension. + /// Returns top-level metadata about the contract: `ContractInfoResponse` + ContractInfo {}, + /// With MetaData Extension. + /// Returns metadata about one particular token, based on *ERC721 Metadata JSON Schema* + /// but directly from the contract: `NftInfoResponse` + NftInfo { token_id: String }, + /// With MetaData Extension. + /// Returns the result of both `NftInfo` and `OwnerOf` as one query as an optimization + /// for clients: `AllNftInfo` + AllNftInfo { + token_id: String, + /// unset or false will filter out expired approvals, you must set to true to see them + include_expired: Option, + }, + + /// With Enumerable extension. + /// Returns all tokens owned by the given address, [] if unset. + /// Return type: TokensResponse. + Tokens { + owner: String, + start_after: Option, + limit: Option, + }, + /// With Enumerable extension. + /// Requires pagination. Lists all token_ids controlled by the contract. + /// Return type: TokensResponse. + AllTokens { + start_after: Option, + limit: Option, + }, +} + +impl From for CW721QueryMsg { + fn from(msg: Cw2981QueryMsg) -> CW721QueryMsg { + match msg { + Cw2981QueryMsg::OwnerOf { + token_id, + include_expired, + } => CW721QueryMsg::OwnerOf { + token_id, + include_expired, + }, + Cw2981QueryMsg::AllOperators { + owner, + include_expired, + start_after, + limit, + } => CW721QueryMsg::AllOperators { + owner, + include_expired, + start_after, + limit, + }, + Cw2981QueryMsg::NumTokens {} => CW721QueryMsg::NumTokens {}, + Cw2981QueryMsg::ContractInfo {} => CW721QueryMsg::ContractInfo {}, + Cw2981QueryMsg::NftInfo { token_id } => CW721QueryMsg::NftInfo { token_id }, + Cw2981QueryMsg::AllNftInfo { + token_id, + include_expired, + } => CW721QueryMsg::AllNftInfo { + token_id, + include_expired, + }, + Cw2981QueryMsg::Tokens { + owner, + start_after, + limit, + } => CW721QueryMsg::Tokens { + owner, + start_after, + limit, + }, + Cw2981QueryMsg::AllTokens { start_after, limit } => { + CW721QueryMsg::AllTokens { start_after, limit } + } + _ => panic!("cannot covert {:?} to CW721QueryMsg", msg), + } + } +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct RoyaltiesInfoResponse { + pub address: String, + // Note that this must be the same denom as that passed in to RoyaltyInfo + // rounding up or down is at the discretion of the implementer + pub royalty_amount: Uint128, +} + +/// Shows if the contract implements royalties +/// if royalty_payments is true, marketplaces should pay them +#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)] +pub struct CheckRoyaltiesResponse { + pub royalty_payments: bool, +} diff --git a/contracts/cw2981-royalties/src/query.rs b/contracts/cw2981-royalties/src/query.rs new file mode 100644 index 000000000..53c20589f --- /dev/null +++ b/contracts/cw2981-royalties/src/query.rs @@ -0,0 +1,47 @@ +use crate::msg::{CheckRoyaltiesResponse, RoyaltiesInfoResponse}; +use crate::Cw2981Contract; +use cosmwasm_std::{Decimal, Deps, StdResult, Uint128}; + +/// NOTE: default behaviour here is to round down +/// EIP2981 specifies that the rounding behaviour is at the discretion of the implementer +pub fn query_royalties_info( + deps: Deps, + token_id: String, + sale_price: Uint128, +) -> StdResult { + let contract = Cw2981Contract::default(); + let token_info = contract.tokens.load(deps.storage, &token_id)?; + + let royalty_percentage = match token_info.extension { + Some(ref ext) => match ext.royalty_percentage { + Some(percentage) => Decimal::percent(percentage), + None => Decimal::percent(0), + }, + None => Decimal::percent(0), + }; + let royalty_from_sale_price = sale_price * royalty_percentage; + + let royalty_address = match token_info.extension { + Some(ext) => match ext.royalty_payment_address { + Some(addr) => addr, + None => String::from(""), + }, + None => String::from(""), + }; + + Ok(RoyaltiesInfoResponse { + address: royalty_address, + royalty_amount: royalty_from_sale_price, + }) +} + +/// As our default implementation here specifies royalties at token level +/// and not at contract level, it is therefore logically true that +/// on sale, every token managed by this contract should be checked +/// to see if royalties are owed, and to whom. If you are importing +/// this logic, you may want a custom implementation here +pub fn check_royalties(_deps: Deps) -> StdResult { + Ok(CheckRoyaltiesResponse { + royalty_payments: true, + }) +}