diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3eff14 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# StarknetId Naming Contract + +This naming contract defines the Stark Naming System. It allows resolving a `stark` domain to a Starknet address or any other field. + +## Features + +- **Domain Resolution**: Resolve a `stark` domain to a Starknet address or any other field. +- **Native Resolver**: By default, `stark` names are attached to identities where the value associated with any field is stored. +- **Resolver Contracts**: Domain owners can delegate the resolution of their subdomains to resolver contracts. +- **Off-Chain Resolving**: Resolver contracts support reading off-chain data to resolve a name and a field to a target value. +- **On-Chain Resolving**: You can resolve a domain on-chain (and not a hash), allowing you to natively send money to a `.stark` domain instead of resolving off-chain before forging the actual transaction. +- **Optimized Encoding**: This feature forbids homograph attacks and allows for longer shortstrings. For more information, visit the [Encoding Documentation](https://docs.starknet.id/architecture/naming/encoding). + +## Ecosystem Support + +The Stark Naming System can be integrated into your dApp for seamless domain resolution. Here are some useful resources: + +- **Integration Guide**: To integrate the Stark Naming System into your dApp, please check the [Developer Documentation](https://docs.starknet.id/devs). +- **Subdomains**: To create subdomains and determine if you should use the native resolver built on top of identities or create your own contract, visit the [Subdomains Documentation](https://docs.starknet.id/devs/subdomains). +- **Off-Chain Resolver**: To see how you can create an off-chain resolver and access data from web3, check out the [CCIP Architecture Documentation](https://docs.starknet.id/architecture/ccip) and follow the [CCIP Tutorial](https://docs.starknet.id/architecture/ccip/tutorial) which shows how to use Notion to resolve your Stark subdomains. + +## Audits + +For additional trust and transparency, this contract has been audited by independent third-party security firms. You can view the audit reports below: + +- [Cairo Security Clan Audit](./audits/cairo_security_clan.pdf) +- [Subsix Audit](./audits/subsix.pdf) + +## How to Build/Test? + +This project was built using Scarb. + +### Building + +To build the project, run the following command: + +``` +scarb --release build +``` + +### Testing + +To run the tests, use the following command: + +``` +scarb test +``` + +For details on the identity contract, see the [StarknetID Identity Contract](https://github.com/starknet-id/identity). diff --git a/audits/cairo_security_clan.pdf b/audits/cairo_security_clan.pdf new file mode 100644 index 0000000..eed276d Binary files /dev/null and b/audits/cairo_security_clan.pdf differ diff --git a/audits/subsix.pdf b/audits/subsix.pdf new file mode 100644 index 0000000..1c6fdfc Binary files /dev/null and b/audits/subsix.pdf differ diff --git a/src/naming/asserts.cairo b/src/naming/asserts.cairo index 5196863..81cc689 100644 --- a/src/naming/asserts.cairo +++ b/src/naming/asserts.cairo @@ -37,7 +37,7 @@ impl AssertionsImpl of AssertionsTrait { assert(data.owner == 0 || data.expiry < now, 'unexpired domain'); // Verify expiration range - assert(days < 365 * 25, 'max purchase of 25 years'); + assert(days <= 365 * 25, 'max purchase of 25 years'); assert(days > 2 * 30, 'min purchase of 2 month'); return (hashed_domain, now, now + 86400 * days.into()); } @@ -60,6 +60,8 @@ impl AssertionsImpl of AssertionsTrait { let mut i: felt252 = 1; let stop = (domain.len() + 1).into(); let mut parent_key = 0; + // we start from the top domain and go down until we find you are the owner, + // reach the domain beginning or reach a key mismatch (reset parent domain) loop { assert(i != stop, 'you don\'t own this domain'); let i_gas_saver = i.try_into().unwrap(); diff --git a/src/naming/internal.cairo b/src/naming/internal.cairo index 573cde2..090926c 100644 --- a/src/naming/internal.cairo +++ b/src/naming/internal.cairo @@ -1,8 +1,6 @@ +use core::array::SpanTrait; use naming::{ - interface::{ - resolver::{IResolver, IResolverDispatcher, IResolverDispatcherTrait}, - referral::{IReferral, IReferralDispatcher, IReferralDispatcherTrait}, - }, + interface::referral::{IReferral, IReferralDispatcher, IReferralDispatcherTrait}, naming::main::{ Naming, Naming::{ @@ -51,24 +49,38 @@ impl InternalImpl of InternalTrait { }; } + // returns the custom resolver to use for a domain (0 if none) + // and the parent domain length. If one parent domain has + // reset its subdomains, it will break and return its length, + // otherwise the parent length would be 0. fn domain_to_resolver( - self: @Naming::ContractState, domain: Span, parent_start_id: u32 + self: @Naming::ContractState, mut domain: Span ) -> (ContractAddress, u32) { - if parent_start_id == domain.len() { - return (ContractAddressZeroable::zero(), 0); + let mut custom_resolver = ContractAddressZeroable::zero(); + let mut parent_length = 0; + let mut domain_parent_key = self._domain_data.read(self.hash_domain(domain)).parent_key; + loop { + if domain.len() == 1 { + break; + }; + // will fail on empty domain + let parent_domain = domain.slice(1, domain.len() - 1); + let hashed_parent_domain = self.hash_domain(parent_domain); + let parent_domain_data = self._domain_data.read(hashed_parent_domain); + if parent_domain_data.resolver.into() != 0 { + custom_resolver = parent_domain_data.resolver; + parent_length = parent_domain.len(); + break; + } + if domain_parent_key != parent_domain_data.key { + // custom_resolver is zero + parent_length = parent_domain.len(); + break; + } + domain = parent_domain; + domain_parent_key = parent_domain_data.parent_key; }; - - // hashing parent_domain - let hashed_domain = self - .hash_domain(domain.slice(parent_start_id, domain.len() - parent_start_id)); - - let domain_data = self._domain_data.read(hashed_domain); - - if domain_data.resolver.into() != 0 { - return (domain_data.resolver, parent_start_id); - } else { - return self.domain_to_resolver(domain, parent_start_id + 1); - } + (custom_resolver, parent_length) } fn pay_domain( @@ -106,7 +118,12 @@ impl InternalImpl of InternalTrait { // add sponsor commission if eligible if sponsor.into() != 0 { IReferralDispatcher { contract_address: self._referral_contract.read() } - .add_commission(discounted_price, sponsor, sponsored_addr: get_caller_address(), erc20_addr: erc20); + .add_commission( + discounted_price, + sponsor, + sponsored_addr: get_caller_address(), + erc20_addr: erc20 + ); } } @@ -141,45 +158,4 @@ impl InternalImpl of InternalTrait { ); } } - - // returns domain_hash (or zero) and its value for a specific field - fn resolve_util( - self: @Naming::ContractState, domain: Span, field: felt252, hint: Span - ) -> (felt252, felt252) { - let (resolver, parent_start) = self.domain_to_resolver(domain, 1); - if (resolver != ContractAddressZeroable::zero()) { - let resolver_res = IResolverDispatcher { contract_address: resolver } - .resolve(domain.slice(0, parent_start), field, hint); - if resolver_res == 0 { - let hashed_domain = self.hash_domain(domain); - return (0, hashed_domain); - } - return (0, resolver_res); - } else { - let hashed_domain = self.hash_domain(domain); - let domain_data = self._domain_data.read(hashed_domain); - // circuit breaker for root domain - ( - hashed_domain, - if (domain.len() == 1) { - IIdentityDispatcher { contract_address: self.starknetid_contract.read() } - .get_crosschecked_user_data(domain_data.owner, field) - // handle reset subdomains - } else { - // todo: optimize by changing the hash definition from H(b, a) to H(a, b) - let parent_key = self - ._domain_data - .read(self.hash_domain(domain.slice(1, domain.len() - 1))) - .key; - - if parent_key == domain_data.parent_key { - IIdentityDispatcher { contract_address: self.starknetid_contract.read() } - .get_crosschecked_user_data(domain_data.owner, field) - } else { - 0 - } - } - ) - } - } } diff --git a/src/naming/main.cairo b/src/naming/main.cairo index 24ec301..8e1e9ed 100644 --- a/src/naming/main.cairo +++ b/src/naming/main.cairo @@ -17,7 +17,8 @@ mod Naming { interface::{ naming::{INaming, INamingDispatcher, INamingDispatcherTrait}, pricing::{IPricing, IPricingDispatcher, IPricingDispatcherTrait}, - auto_renewal::{IAutoRenewal, IAutoRenewalDispatcher, IAutoRenewalDispatcherTrait} + auto_renewal::{IAutoRenewal, IAutoRenewalDispatcher, IAutoRenewalDispatcherTrait}, + resolver::{IResolver, IResolverDispatcher, IResolverDispatcherTrait} } }; use identity::interface::identity::{IIdentity, IIdentityDispatcher, IIdentityDispatcherTrait}; @@ -177,8 +178,23 @@ mod Naming { fn resolve( self: @ContractState, domain: Span, field: felt252, hint: Span ) -> felt252 { - let (_, value) = self.resolve_util(domain, field, hint); - value + let (resolver, parent_length) = self.domain_to_resolver(domain); + // if there is a resolver starting from the top + if (resolver != ContractAddressZeroable::zero()) { + IResolverDispatcher { contract_address: resolver } + .resolve(domain.slice(0, domain.len() - parent_length), field, hint) + } else { + let hashed_domain = self.hash_domain(domain); + let domain_data = self._domain_data.read(hashed_domain); + // if there was a reset subdomains starting from the top + if parent_length != 0 { + 0 + // otherwise, we just read the identity + } else { + IIdentityDispatcher { contract_address: self.starknetid_contract.read() } + .get_crosschecked_user_data(domain_data.owner, field) + } + } } // This functions allows to resolve a domain to a native address. Its output is designed @@ -187,27 +203,44 @@ mod Naming { fn domain_to_address( self: @ContractState, domain: Span, hint: Span ) -> ContractAddress { - // resolve must be performed first because it calls untrusted resolving contracts - let (hashed_domain, value) = self.resolve_util(domain, 'starknet', hint); - if value != 0 { - let addr: Option = value.try_into(); - return addr.unwrap(); - }; - let data = self._domain_data.read(hashed_domain); - if data.address.into() != 0 { - if domain.len() != 1 { - let parent_key = self - ._domain_data - .read(self.hash_domain(domain.slice(1, domain.len() - 1))) - .key; - if parent_key == data.parent_key { - return data.address; - }; - }; - return data.address; - }; - IIdentityDispatcher { contract_address: self.starknetid_contract.read() } - .owner_from_id(self.domain_to_id(domain)) + let (resolver, parent_length) = self.domain_to_resolver(domain); + // if there is a resolver starting from the top + if (resolver != ContractAddressZeroable::zero()) { + let addr: Option = IResolverDispatcher { + contract_address: resolver + } + .resolve(domain.slice(0, domain.len() - parent_length), 'starknet', hint) + .try_into(); + addr.unwrap() + } else { + // if there was a reset subdomains starting from the top + if parent_length != 0 { + ContractAddressZeroable::zero() + // otherwise we read the identity + } else { + let hashed_domain = self.hash_domain(domain); + let domain_data = self._domain_data.read(hashed_domain); + let identity_address = IIdentityDispatcher { + contract_address: self.starknetid_contract.read() + } + .get_crosschecked_user_data(domain_data.owner, 'starknet'); + if identity_address != 0 { + let addr: Option = identity_address.try_into(); + addr.unwrap() + } else { + if domain_data.address.into() != 0 { + // no need to check for keys as it was checked in domain_to_resolver + return domain_data.address; + } else { + // if no legacy address is found, it returns the identity owner + IIdentityDispatcher { + contract_address: self.starknetid_contract.read() + } + .owner_from_id(self.domain_to_id(domain)) + } + } + } + } } // This returns the stored DomainData associated to this domain @@ -221,22 +254,29 @@ mod Naming { } // This returns the identity (StarknetID) owning the domain - fn domain_to_id(self: @ContractState, domain: Span) -> u128 { + fn domain_to_id(self: @ContractState, mut domain: Span) -> u128 { let data = self._domain_data.read(self.hash_domain(domain)); // todo: revert when try catch are available if domain.len() == 0 { return 0; }; - if domain.len() != 1 { - let parent_key = self - ._domain_data - .read(self.hash_domain(domain.slice(1, domain.len() - 1))) - .key; - if parent_key != data.parent_key { - return 0; - }; + + let mut parent_key = data.parent_key; + let mut output = data.owner; + loop { + if domain.len() == 1 { + break; + } + let parent_domain = domain.slice(1, domain.len() - 1); + let parent_domain_data = self._domain_data.read(self.hash_domain(parent_domain)); + if parent_domain_data.key != parent_key { + output = 0; + break; + } + domain = parent_domain; + parent_key = parent_domain_data.parent_key; }; - data.owner + output } // This function allows to find which domain to use to display an account @@ -383,8 +423,8 @@ mod Naming { domain_data.expiry + 86400 * days.into() }; // 25*365 = 9125 - assert(new_expiry <= now + 86400 * 9125, 'purchase too long'); - assert(days >= 6 * 30, 'purchase too short'); + assert(new_expiry <= now + 365 * 25 * 86400, 'max purchase of 25 years'); + assert(days >= 6 * 30, 'min purchase of 6 months'); let data = DomainData { owner: domain_data.owner, @@ -456,9 +496,8 @@ mod Naming { } else { domain_data.expiry + 86400 * days.into() }; - // 25*365 = 9125 - assert(new_expiry <= now + 86400 * 9125, 'purchase too long'); - assert(days >= 6 * 30, 'purchase too short'); + assert(new_expiry <= now + 365 * 25 * 86400, 'max purchase of 25 years'); + assert(days >= 6 * 30, 'min purchase of 6 months'); let data = DomainData { owner: domain_data.owner, @@ -707,9 +746,7 @@ mod Naming { // ADMIN - fn set_expiry( - ref self: ContractState, root_domain: felt252, expiry: u64 - ) { + fn set_expiry(ref self: ContractState, root_domain: felt252, expiry: u64) { self.ownable.assert_only_owner(); let hashed_domain = self.hash_domain(array![root_domain].span()); let domain_data = self._domain_data.read(hashed_domain); diff --git a/src/tests/naming/test_abuses.cairo b/src/tests/naming/test_abuses.cairo index 7b83f22..ef0a32c 100644 --- a/src/tests/naming/test_abuses.cairo +++ b/src/tests/naming/test_abuses.cairo @@ -164,7 +164,7 @@ fn test_non_owner_cannot_transfer_domain() { #[test] #[available_gas(2000000000)] -#[should_panic(expected: ('purchase too short', 'ENTRYPOINT_FAILED'))] +#[should_panic(expected: ('min purchase of 6 months', 'ENTRYPOINT_FAILED'))] fn test_renewal_period_too_short() { // setup let (eth, pricing, identity, naming) = deploy(); @@ -190,7 +190,7 @@ fn test_renewal_period_too_short() { #[test] #[available_gas(2000000000)] -#[should_panic(expected: ('purchase too long', 'ENTRYPOINT_FAILED'))] +#[should_panic(expected: ('max purchase of 25 years', 'ENTRYPOINT_FAILED'))] fn test_renewal_period_too_long() { // setup let (eth, pricing, identity, naming) = deploy(); @@ -311,6 +311,71 @@ fn test_transfer_from_returns_false() { .buy(1, aller, 365, ContractAddressZeroable::zero(), ContractAddressZeroable::zero(), 0, 0); } +#[test] +#[available_gas(2000000000)] +fn test_use_reset_subdomains_multiple_levels() { + // setup + let (eth, pricing, identity, naming) = deploy(); + let alpha = contract_address_const::<0x123>(); + let bravo = contract_address_const::<0x456>(); + let charlie = contract_address_const::<0x789>(); + // In this example we will use utf-8 encoded strings like 'toto' which is not + // what is actually defined in the starknetid standard, it's just easier for testings + + // we mint the ids + set_contract_address(alpha); + identity.mint(1); + set_contract_address(bravo); + identity.mint(2); + set_contract_address(charlie); + identity.mint(3); + + // we check how much a domain costs + let (_, price) = pricing.compute_buy_price(5, 365); + + // we allow the naming to take our money + set_contract_address(alpha); + eth.approve(naming.contract_address, price); + + // we buy with no resolver, no sponsor, no discount and empty metadata + naming + .buy( + 1, 'ccccc', 365, ContractAddressZeroable::zero(), ContractAddressZeroable::zero(), 0, 0 + ); + + let root_domain = array!['ccccc'].span(); + let subdomain = array!['bbbbb', 'ccccc'].span(); + + // we transfer bb.cc.stark to id2 + naming.transfer_domain(subdomain, 2); + + // and make sure the owner has been updated + assert(naming.domain_to_id(subdomain) == 2, 'owner not updated correctly'); + + set_contract_address(bravo); + // we transfer aa.bb.cc.stark to id3 + let subsubdomain = array!['aaaaa', 'bbbbb', 'ccccc'].span(); + naming.transfer_domain(subsubdomain, 3); + // and make sure the owner has been updated + assert(naming.domain_to_id(subsubdomain) == 3, 'owner2 not updated correctly'); + + // now charlie should be able to create a subbsubsubdomain (example.aa.bb.cc.stark): + set_contract_address(charlie); + let subsubsubdomain = array!['example', 'aaaaa', 'bbbbb', 'ccccc'].span(); + naming.transfer_domain(subsubsubdomain, 4); + + // alpha resets subdomains of ccccc.stark + set_contract_address(alpha); + naming.reset_subdomains(root_domain); + + // ensure root domain still resolves + assert(naming.domain_to_id(root_domain) == 1, 'owner3 not updated correctly'); + // ensure the subdomain was reset + assert(naming.domain_to_id(subdomain) == 0, 'owner4 not updated correctly'); + // ensure the subsubdomain was reset + assert(naming.domain_to_id(subsubdomain) == 0, 'owner5 not updated correctly'); +} + #[test] #[available_gas(2000000000)] #[should_panic(expected: ('domain can\'t be empty', 'ENTRYPOINT_FAILED'))] @@ -334,5 +399,13 @@ fn test_buy_empty_domain() { // we buy with no resolver, no sponsor, no discount and empty metadata naming - .buy(1, empty_domain, 365, ContractAddressZeroable::zero(), ContractAddressZeroable::zero(), 0, 0); + .buy( + 1, + empty_domain, + 365, + ContractAddressZeroable::zero(), + ContractAddressZeroable::zero(), + 0, + 0 + ); } diff --git a/src/tests/naming/test_altcoin.cairo b/src/tests/naming/test_altcoin.cairo index d6f45b1..aa45a81 100644 --- a/src/tests/naming/test_altcoin.cairo +++ b/src/tests/naming/test_altcoin.cairo @@ -21,7 +21,6 @@ use naming::pricing::Pricing; use naming::naming::utils::UtilsImpl; use super::common::{deploy, deploy_stark}; use super::super::utils; -use core::debug::PrintTrait; use wadray::Wad; #[test] diff --git a/src/tests/naming/test_custom_resolver.cairo b/src/tests/naming/test_custom_resolver.cairo index 7381041..6d5cb3a 100644 --- a/src/tests/naming/test_custom_resolver.cairo +++ b/src/tests/naming/test_custom_resolver.cairo @@ -55,9 +55,7 @@ mod CustomResolver { fn test_custom_resolver() { // setup let (eth, pricing, identity, naming) = deploy(); - let custom_resolver = IERC20CamelDispatcher { - contract_address: utils::deploy(CustomResolver::TEST_CLASS_HASH, ArrayTrait::new()) - }; + let custom_resolver = utils::deploy(CustomResolver::TEST_CLASS_HASH, ArrayTrait::new()); let caller = contract_address_const::<0x123>(); set_contract_address(caller); @@ -73,13 +71,13 @@ fn test_custom_resolver() { // we allow the naming to take our money eth.approve(naming.contract_address, price); - // we buy with no resolver, no sponsor, no discount and empty metadata + // we buy with a custom resolver, no sponsor, no discount and empty metadata naming .buy( id, th0rgal, 365, - custom_resolver.contract_address, + custom_resolver, ContractAddressZeroable::zero(), 0, 0 @@ -92,7 +90,6 @@ fn test_custom_resolver() { assert(naming.domain_to_address(domain, array![].span()) == caller, 'wrong domain target'); let domain = array![1, 2, 3, th0rgal].span(); - let new_target = contract_address_const::<0x6>(); // let's try the resolving assert(naming.resolve(domain, 'starknet', array![].span()) == 1 + 2 + 3, 'wrong target');