From 19a41ac5620480d6e12717b07fb82dfd5f060b78 Mon Sep 17 00:00:00 2001 From: Maurice Bagalwa <57249405+MauriceBagalwa@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:56:32 +0300 Subject: [PATCH] [FEAT]: land SafHandle v1 contract, tests, tooling --- .cargo/config.toml | 17 + .gitignore | 5 +- CHANGELOG.md | 24 +- Cargo.toml | 28 + README.md | 47 +- artifacts/checksums.txt | 1 + config/mainnet.json | 1 - config/testnet.json | 7 +- contracts/safhandle/Cargo.toml | 30 ++ contracts/safhandle/schema/raw/execute.json | 114 ++++ .../safhandle/schema/raw/instantiate.json | 32 ++ contracts/safhandle/schema/raw/migrate.json | 18 + contracts/safhandle/schema/raw/query.json | 105 ++++ .../schema/raw/response_to_config.json | 44 ++ .../schema/raw/response_to_get_address.json | 22 + .../schema/raw/response_to_handles.json | 15 + .../schema/raw/response_to_name_record.json | 33 ++ .../schema/raw/response_to_resolve_name.json | 14 + contracts/safhandle/schema/safhandle.json | 405 ++++++++++++++ contracts/safhandle/src/bin/schema.rs | 11 + contracts/safhandle/src/contract.rs | 493 ++++++++++++++++++ contracts/safhandle/src/error.rs | 45 ++ contracts/safhandle/src/helpers.rs | 20 + contracts/safhandle/src/lib.rs | 8 + contracts/safhandle/src/msg.rs | 100 ++++ contracts/safhandle/src/state.rs | 52 ++ contracts/safhandle/src/validation.rs | 200 +++++++ contracts/safhandle/tests/integration.rs | 406 +++++++++++++++ docs/ANTI_SQUATTING.md | 11 +- docs/ARCHITECTURE.md | 20 +- docs/CLI_COMMANDS.md | 372 +++++++++++++ docs/CONTRACT_API.md | 77 ++- docs/DEPLOYMENT.md | 10 +- docs/FEES_AND_GOVERNANCE.md | 14 +- docs/HOW_IT_WORKS.md | 16 +- docs/MIGRATION.md | 39 +- docs/NAME_RULES.md | 32 +- docs/PHONE_LINKING.md | 40 +- docs/README.md | 2 +- docs/ROADMAP.md | 32 +- docs/SECURITY_MODEL.md | 11 +- docs/STATE_SCHEMA.md | 36 +- schema/raw/execute.json | 114 ++++ schema/raw/instantiate.json | 32 ++ schema/raw/migrate.json | 18 + schema/raw/query.json | 105 ++++ schema/raw/response_to_config.json | 44 ++ schema/raw/response_to_get_address.json | 22 + schema/raw/response_to_handles.json | 15 + schema/raw/response_to_name_record.json | 33 ++ schema/raw/response_to_resolve_name.json | 14 + schema/safhandle.json | 405 ++++++++++++++ scripts/deploy-testnet.sh | 137 +++++ scripts/verify-repo.test.mjs | 2 +- 54 files changed, 3783 insertions(+), 167 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 Cargo.toml create mode 100644 artifacts/checksums.txt create mode 100644 contracts/safhandle/Cargo.toml create mode 100644 contracts/safhandle/schema/raw/execute.json create mode 100644 contracts/safhandle/schema/raw/instantiate.json create mode 100644 contracts/safhandle/schema/raw/migrate.json create mode 100644 contracts/safhandle/schema/raw/query.json create mode 100644 contracts/safhandle/schema/raw/response_to_config.json create mode 100644 contracts/safhandle/schema/raw/response_to_get_address.json create mode 100644 contracts/safhandle/schema/raw/response_to_handles.json create mode 100644 contracts/safhandle/schema/raw/response_to_name_record.json create mode 100644 contracts/safhandle/schema/raw/response_to_resolve_name.json create mode 100644 contracts/safhandle/schema/safhandle.json create mode 100644 contracts/safhandle/src/bin/schema.rs create mode 100644 contracts/safhandle/src/contract.rs create mode 100644 contracts/safhandle/src/error.rs create mode 100644 contracts/safhandle/src/helpers.rs create mode 100644 contracts/safhandle/src/lib.rs create mode 100644 contracts/safhandle/src/msg.rs create mode 100644 contracts/safhandle/src/state.rs create mode 100644 contracts/safhandle/src/validation.rs create mode 100644 contracts/safhandle/tests/integration.rs create mode 100644 docs/CLI_COMMANDS.md create mode 100644 schema/raw/execute.json create mode 100644 schema/raw/instantiate.json create mode 100644 schema/raw/migrate.json create mode 100644 schema/raw/query.json create mode 100644 schema/raw/response_to_config.json create mode 100644 schema/raw/response_to_get_address.json create mode 100644 schema/raw/response_to_handles.json create mode 100644 schema/raw/response_to_name_record.json create mode 100644 schema/raw/response_to_resolve_name.json create mode 100644 schema/safhandle.json create mode 100755 scripts/deploy-testnet.sh diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e5ca550 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,17 @@ +# Wasm build flags, scoped to the wasm target only (native `cargo test` is +# unaffected): +# * link-arg=--import-undefined — emit CosmWasm host fns (db_read, +# addr_validate, ...) as wasm imports instead of failing on undefined +# symbols. Needed because rustc 1.96/LLVM 22 no longer marks them itself. +# * target-feature=-... — disable post-MVP wasm extensions that +# recent rustc enables by default but the CosmWasm VM rejects +# (bulk-memory, reference-types, multivalue). Keeps the artifact loadable +# on-chain. The Docker optimizer applies the same restriction. +[target.wasm32-unknown-unknown] +rustflags = [ + "-C", "link-arg=--import-undefined", + "-C", "target-feature=-bulk-memory,-reference-types,-multivalue", +] + +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown -p safhandle" diff --git a/.gitignore b/.gitignore index 6ad3212..f97f5f3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,12 +12,15 @@ artifacts/*.wasm !deployment/.env.*.example deployment/state/ -# Editor and OS +# Editor, OS, and local agent tooling .DS_Store .idea/ .vscode/ *.swp *.swo +.claude/ # Node (doc tooling) node_modules/ + +.info diff --git a/CHANGELOG.md b/CHANGELOG.md index fb30387..e7b1898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] - 2026-07-07 + +First public **v1** release: short-name registry only. + +### Changed + +- **Phone linking is now gated behind the `phone` Cargo feature (off by default).** The v1 wasm registers short names only; all phone messages, queries, state, and the `phone_link_fee_usaf` config field are compiled out unless built with `--features phone`. +- Phone numbers (phone build) are stored as bare digits, no leading `+`. + +### Added + +- Hardened name validation: dedicated `EmailNotAllowed` error for inputs containing `@`, plus rejection of non-ASCII / homoglyph input and stray dots outside the `.saf` suffix. +- Documentation aligned with the implemented v1 contract (phone content marked Phase 2 throughout). + +## [0.2.0] + ### Added -- Initial open-source documentation and repository scaffold. -- Contract API specification for short names and phone linking. -- Fee model: 50 SAF (name), 100 SAF (phone), governance-updatable, dev module wallet sink. -- Network configuration examples for Safrochain testnet and mainnet. +- CosmWasm contract implementation: name registry, exact-fee enforcement, dev-module-wallet fee routing, governance config, and `migrate`. +- `cw-multi-test` integration tests and JSON schema generation. +- Fee model: 50 SAF (name), 100 SAF (phone, phone build), governance-updatable, dev module wallet sink. +- Network configuration examples for Safrochain testnet and mainnet; testnet deployment. - CI workflows for documentation, link checks, and license validation. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..aa838db --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[workspace] +members = ["contracts/*"] +resolver = "2" + +[workspace.package] +edition = "2021" +version = "1.0.0" +license = "MIT" +repository = "https://github.com/Safrochain-Org/safhandle-contract" + +[workspace.dependencies] +cosmwasm-std = "2.1" +cosmwasm-schema = "2.1" +cw-storage-plus = "2.0" +cw2 = "2.0" +thiserror = "1.0" +cw-multi-test = "2.1" + +# Release profile tuned for CosmWasm wasm artifacts. +[profile.release] +opt-level = "z" +debug = false +rpath = false +lto = true +overflow-checks = true +codegen-units = 1 +panic = "abort" +incremental = false diff --git a/README.md b/README.md index 9bc2829..f8cc47d 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ --- -> **Status: Specification / Documentation phase.** CosmWasm source code and deployment artifacts will be added in a follow-up milestone. This repository defines the on-chain registry design and open-source project structure. +> **Status: v1 implemented and deployed to testnet.** The CosmWasm contract is built, covered by `cw-multi-test` integration tests, and live on `safro-testnet-1`. **v1 registers short names only** — phone linking is a Phase 2 feature compiled out of the shipped wasm (Cargo feature `phone`, off by default). -**SafHandle** lets users send SAF to short names like `john.saf` or phone numbers like `+243899123456` instead of long `addr_safro1...` addresses. This repository contains the **CosmWasm smart contract** specification and deployment documentation. +**SafHandle** lets users send SAF to short names like `john.saf` instead of long `addr_safro1...` addresses. This repository contains the **CosmWasm smart contract**, its tests, and deployment documentation. Phone-number handles are planned for Phase 2. > **Read the full explanation:** [docs/HOW_IT_WORKS.md](./docs/HOW_IT_WORKS.md) @@ -30,7 +30,7 @@ | Long, error-prone wallet addresses | Human-readable short names (`john`, `john.saf`) | | Copy-paste friction in P2P payments | Resolve a name or phone to `addr_safro` in one query | | No standard name registry on Safrochain | Canonical on-chain registry any wallet or dApp can query | -| Phone-based payments off-chain only | On-chain phone link now; off-chain verification in Phase 2 | +| Phone-based payments off-chain only | On-chain phone links planned for Phase 2 (see [PHONE_LINKING.md](./docs/PHONE_LINKING.md)) | ## How it works @@ -58,10 +58,10 @@ sequenceDiagram ## Fees -| Action | Default fee | Routing | -| --- | --- | --- | -| Register short name | **50 SAF** | Dev module wallet | -| Link phone number | **100 SAF** | Dev module wallet | +| Action | Default fee | Routing | Availability | +| --- | --- | --- | --- | +| Register short name | **50 SAF** | Dev module wallet | v1 | +| Link phone number | **100 SAF** | Dev module wallet | Phase 2 | Fees are **governance-updatable**. See [docs/FEES_AND_GOVERNANCE.md](./docs/FEES_AND_GOVERNANCE.md). @@ -83,30 +83,37 @@ Fees are **governance-updatable**. See [docs/FEES_AND_GOVERNANCE.md](./docs/FEES | [SECURITY_MODEL.md](./docs/SECURITY_MODEL.md) | Threat model | | [ROADMAP.md](./docs/ROADMAP.md) | Implementation phases | -## Planned repository layout +## Repository layout ```text contracts/safhandle/ ├── Cargo.toml ├── src/ -│ ├── contract.rs -│ ├── msg.rs -│ ├── state.rs -│ ├── error.rs -│ ├── fees.rs -│ └── names.rs +│ ├── contract.rs # entry points: instantiate / execute / query / migrate +│ ├── msg.rs # InstantiateMsg / ExecuteMsg / QueryMsg / MigrateMsg +│ ├── state.rs # Config, NameRecord, storage Maps/Items +│ ├── error.rs # ContractError variants +│ ├── validation.rs # name normalization + hardened validation +│ ├── helpers.rs # fee assertion helper +│ ├── lib.rs +│ └── bin/schema.rs # JSON schema generator ├── tests/ -└── artifacts/ - └── checksums.txt -deployment/ -├── deploy.sh -├── migrate.sh -└── lib/ +│ └── integration.rs # cw-multi-test coverage +└── schema/ # generated JSON schema (gitignored) +scripts/ +└── deploy-testnet.sh # store + instantiate on testnet +artifacts/ +├── safhandle.wasm # optimized build (gitignored) +└── checksums.txt config/ ├── testnet.json └── mainnet.json ``` +Phone linking code (state, messages, queries, validation) also lives in `src/` +but is compiled out unless built with `--features phone`. See +[PHONE_LINKING.md](./docs/PHONE_LINKING.md). + ## Network configuration | Network | Chain ID | Bech32 prefix | Denom | diff --git a/artifacts/checksums.txt b/artifacts/checksums.txt new file mode 100644 index 0000000..b562f9d --- /dev/null +++ b/artifacts/checksums.txt @@ -0,0 +1 @@ +8940f19df97f60482b48a8b707a4e7e6ed2f045bf9f5e342924304914f9fa97a safhandle.wasm diff --git a/config/mainnet.json b/config/mainnet.json index 2afafba..3388bd8 100644 --- a/config/mainnet.json +++ b/config/mainnet.json @@ -22,7 +22,6 @@ "role": "production", "safhandle": { "nameRegistrationFeeUsaf": "50000000", - "phoneLinkFeeUsaf": "100000000", "devModuleWallet": "", "governanceModule": "gov" } diff --git a/config/testnet.json b/config/testnet.json index 543a863..c287524 100644 --- a/config/testnet.json +++ b/config/testnet.json @@ -11,8 +11,8 @@ "rpc": "https://rpc.testnet.safrochain.com", "rest": "https://rest.testnet.safrochain.com", "ws": "wss://rpc.testnet.safrochain.com/websocket", - "explorer": "https://explorer.testnet.safrochain.com", - "contractAddress": "", + "explorer": "https://explorer.safrochain.com", + "contractAddress": "addr_safro1j6n2q333gy80pmpd6avss32y4nhd8deayv8m9x4uazt6zkdczk9sxjfun6", "gasPrice": "0.025usaf", "gasPriceSteps": { "low": 0.01, @@ -22,8 +22,7 @@ "role": "testing", "safhandle": { "nameRegistrationFeeUsaf": "50000000", - "phoneLinkFeeUsaf": "100000000", - "devModuleWallet": "", + "devModuleWallet": "addr_safro1qyzdpg5v9xepwn0jx65uxhjrq07t8v8w93qq7d", "governanceModule": "gov" } } diff --git a/contracts/safhandle/Cargo.toml b/contracts/safhandle/Cargo.toml new file mode 100644 index 0000000..5f83013 --- /dev/null +++ b/contracts/safhandle/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "safhandle" +description = "SafHandle CosmWasm registry — short names and phone links on Safrochain" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# Disable the exported entry points so the crate can be used as a library +# dependency (e.g. by a future verifier contract) without symbol clashes. +library = [] +# Phone linking is deferred to Phase 2. The v1 wasm is built WITHOUT this +# feature, so all phone messages, queries, state, and validation are compiled +# out entirely. Build with `--features phone` to re-enable the Phase 2 path. +phone = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +cw-multi-test = { workspace = true } +anyhow = "1" diff --git a/contracts/safhandle/schema/raw/execute.json b/contracts/safhandle/schema/raw/execute.json new file mode 100644 index 0000000..8e6f455 --- /dev/null +++ b/contracts/safhandle/schema/raw/execute.json @@ -0,0 +1,114 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Register a short name for the sender. Funds: exactly the name fee.", + "type": "object", + "required": [ + "register_name" + ], + "properties": { + "register_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Transfer name ownership. Owner-only.", + "type": "object", + "required": [ + "transfer_name" + ], + "properties": { + "transfer_name": { + "type": "object", + "required": [ + "name", + "new_owner" + ], + "properties": { + "name": { + "type": "string" + }, + "new_owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Release a name back to the pool. Owner-only.", + "type": "object", + "required": [ + "release_name" + ], + "properties": { + "release_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Governance-only partial config update.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "dev_module_wallet": { + "type": [ + "string", + "null" + ] + }, + "name_registration_fee_usaf": { + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "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/safhandle/schema/raw/instantiate.json b/contracts/safhandle/schema/raw/instantiate.json new file mode 100644 index 0000000..2f969ed --- /dev/null +++ b/contracts/safhandle/schema/raw/instantiate.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "dev_module_wallet", + "governance_admin", + "name_registration_fee_usaf", + "native_denom" + ], + "properties": { + "dev_module_wallet": { + "type": "string" + }, + "governance_admin": { + "type": "string" + }, + "name_registration_fee_usaf": { + "$ref": "#/definitions/Uint128" + }, + "native_denom": { + "type": "string" + } + }, + "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/safhandle/schema/raw/migrate.json b/contracts/safhandle/schema/raw/migrate.json new file mode 100644 index 0000000..f0a99f0 --- /dev/null +++ b/contracts/safhandle/schema/raw/migrate.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "properties": { + "add_reserved_names": { + "description": "Additive-only: extend the reserved name list on upgrade.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false +} diff --git a/contracts/safhandle/schema/raw/query.json b/contracts/safhandle/schema/raw/query.json new file mode 100644 index 0000000..ce1864c --- /dev/null +++ b/contracts/safhandle/schema/raw/query.json @@ -0,0 +1,105 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Resolve an input to an address. With the `phone` feature enabled, an all-digit input is auto-detected and resolved against the phone registry.", + "type": "object", + "required": [ + "get_address" + ], + "properties": { + "get_address": { + "type": "object", + "required": [ + "input" + ], + "properties": { + "input": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "resolve_name" + ], + "properties": { + "resolve_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name_record" + ], + "properties": { + "name_record": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reverse lookup: the name (and phone, when enabled) owned by an address.", + "type": "object", + "required": [ + "handles" + ], + "properties": { + "handles": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] +} diff --git a/contracts/safhandle/schema/raw/response_to_config.json b/contracts/safhandle/schema/raw/response_to_config.json new file mode 100644 index 0000000..5c60572 --- /dev/null +++ b/contracts/safhandle/schema/raw/response_to_config.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "description": "Governance-updatable contract configuration. See `docs/STATE_SCHEMA.md`.", + "type": "object", + "required": [ + "dev_module_wallet", + "governance_admin", + "name_registration_fee_usaf", + "native_denom", + "reserved_names" + ], + "properties": { + "dev_module_wallet": { + "$ref": "#/definitions/Addr" + }, + "governance_admin": { + "$ref": "#/definitions/Addr" + }, + "name_registration_fee_usaf": { + "$ref": "#/definitions/Uint128" + }, + "native_denom": { + "type": "string" + }, + "reserved_names": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "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/safhandle/schema/raw/response_to_get_address.json b/contracts/safhandle/schema/raw/response_to_get_address.json new file mode 100644 index 0000000..26b8e16 --- /dev/null +++ b/contracts/safhandle/schema/raw/response_to_get_address.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetAddressResponse", + "type": "object", + "required": [ + "address", + "normalized_key", + "record_type" + ], + "properties": { + "address": { + "type": "string" + }, + "normalized_key": { + "type": "string" + }, + "record_type": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/contracts/safhandle/schema/raw/response_to_handles.json b/contracts/safhandle/schema/raw/response_to_handles.json new file mode 100644 index 0000000..fe6930e --- /dev/null +++ b/contracts/safhandle/schema/raw/response_to_handles.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HandlesResponse", + "description": "Reverse-lookup result. Each field is `None` when the address owns no record of that kind. See `docs/CONTRACT_API.md`.", + "type": "object", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false +} diff --git a/contracts/safhandle/schema/raw/response_to_name_record.json b/contracts/safhandle/schema/raw/response_to_name_record.json new file mode 100644 index 0000000..d57b185 --- /dev/null +++ b/contracts/safhandle/schema/raw/response_to_name_record.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NameRecord", + "description": "A registered short name, keyed by its normalized form (e.g. `john.saf`).", + "type": "object", + "required": [ + "owner", + "registered_at_height", + "registered_at_time" + ], + "properties": { + "owner": { + "$ref": "#/definitions/Addr" + }, + "registered_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "registered_at_time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } +} diff --git a/contracts/safhandle/schema/raw/response_to_resolve_name.json b/contracts/safhandle/schema/raw/response_to_resolve_name.json new file mode 100644 index 0000000..d77362e --- /dev/null +++ b/contracts/safhandle/schema/raw/response_to_resolve_name.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AddressResponse", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/contracts/safhandle/schema/safhandle.json b/contracts/safhandle/schema/safhandle.json new file mode 100644 index 0000000..89fb843 --- /dev/null +++ b/contracts/safhandle/schema/safhandle.json @@ -0,0 +1,405 @@ +{ + "contract_name": "safhandle", + "contract_version": "0.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "dev_module_wallet", + "governance_admin", + "name_registration_fee_usaf", + "native_denom" + ], + "properties": { + "dev_module_wallet": { + "type": "string" + }, + "governance_admin": { + "type": "string" + }, + "name_registration_fee_usaf": { + "$ref": "#/definitions/Uint128" + }, + "native_denom": { + "type": "string" + } + }, + "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" + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Register a short name for the sender. Funds: exactly the name fee.", + "type": "object", + "required": [ + "register_name" + ], + "properties": { + "register_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Transfer name ownership. Owner-only.", + "type": "object", + "required": [ + "transfer_name" + ], + "properties": { + "transfer_name": { + "type": "object", + "required": [ + "name", + "new_owner" + ], + "properties": { + "name": { + "type": "string" + }, + "new_owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Release a name back to the pool. Owner-only.", + "type": "object", + "required": [ + "release_name" + ], + "properties": { + "release_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Governance-only partial config update.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "dev_module_wallet": { + "type": [ + "string", + "null" + ] + }, + "name_registration_fee_usaf": { + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "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" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Resolve an input to an address. With the `phone` feature enabled, an all-digit input is auto-detected and resolved against the phone registry.", + "type": "object", + "required": [ + "get_address" + ], + "properties": { + "get_address": { + "type": "object", + "required": [ + "input" + ], + "properties": { + "input": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "resolve_name" + ], + "properties": { + "resolve_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name_record" + ], + "properties": { + "name_record": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reverse lookup: the name (and phone, when enabled) owned by an address.", + "type": "object", + "required": [ + "handles" + ], + "properties": { + "handles": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "properties": { + "add_reserved_names": { + "description": "Additive-only: extend the reserved name list on upgrade.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "description": "Governance-updatable contract configuration. See `docs/STATE_SCHEMA.md`.", + "type": "object", + "required": [ + "dev_module_wallet", + "governance_admin", + "name_registration_fee_usaf", + "native_denom", + "reserved_names" + ], + "properties": { + "dev_module_wallet": { + "$ref": "#/definitions/Addr" + }, + "governance_admin": { + "$ref": "#/definitions/Addr" + }, + "name_registration_fee_usaf": { + "$ref": "#/definitions/Uint128" + }, + "native_denom": { + "type": "string" + }, + "reserved_names": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "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" + } + } + }, + "get_address": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetAddressResponse", + "type": "object", + "required": [ + "address", + "normalized_key", + "record_type" + ], + "properties": { + "address": { + "type": "string" + }, + "normalized_key": { + "type": "string" + }, + "record_type": { + "type": "string" + } + }, + "additionalProperties": false + }, + "handles": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HandlesResponse", + "description": "Reverse-lookup result. Each field is `None` when the address owns no record of that kind. See `docs/CONTRACT_API.md`.", + "type": "object", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "name_record": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NameRecord", + "description": "A registered short name, keyed by its normalized form (e.g. `john.saf`).", + "type": "object", + "required": [ + "owner", + "registered_at_height", + "registered_at_time" + ], + "properties": { + "owner": { + "$ref": "#/definitions/Addr" + }, + "registered_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "registered_at_time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } + }, + "resolve_name": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AddressResponse", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + } +} diff --git a/contracts/safhandle/src/bin/schema.rs b/contracts/safhandle/src/bin/schema.rs new file mode 100644 index 0000000..47aac35 --- /dev/null +++ b/contracts/safhandle/src/bin/schema.rs @@ -0,0 +1,11 @@ +use cosmwasm_schema::write_api; +use safhandle::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}; + +fn main() { + write_api! { + instantiate: InstantiateMsg, + execute: ExecuteMsg, + query: QueryMsg, + migrate: MigrateMsg, + } +} diff --git a/contracts/safhandle/src/contract.rs b/contracts/safhandle/src/contract.rs new file mode 100644 index 0000000..ec91daf --- /dev/null +++ b/contracts/safhandle/src/contract.rs @@ -0,0 +1,493 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, BankMsg, Binary, Coin, Deps, DepsMut, Env, MessageInfo, Response, StdError, + StdResult, Uint128, +}; +#[cfg(feature = "phone")] +use cosmwasm_std::Order; +use cw2::set_contract_version; + +use crate::error::ContractError; +use crate::helpers::assert_exact_fee; +use crate::msg::{ + AddressResponse, ExecuteMsg, GetAddressResponse, HandlesResponse, InstantiateMsg, MigrateMsg, + QueryMsg, +}; +use crate::state::{Config, NameRecord, CONFIG, NAMES, OWNER_NAMES}; +#[cfg(feature = "phone")] +use crate::state::{PhoneRecord, OWNER_PHONES, PHONES, VERIFIERS}; +use crate::validation::{label_of, normalize_name, DEFAULT_RESERVED}; +#[cfg(feature = "phone")] +use crate::validation::{looks_like_phone, validate_phone}; + +const CONTRACT_NAME: &str = "safrochain:safhandle"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let config = Config { + native_denom: msg.native_denom, + name_registration_fee_usaf: msg.name_registration_fee_usaf, + #[cfg(feature = "phone")] + phone_link_fee_usaf: msg.phone_link_fee_usaf, + dev_module_wallet: deps.api.addr_validate(&msg.dev_module_wallet)?, + governance_admin: deps.api.addr_validate(&msg.governance_admin)?, + reserved_names: DEFAULT_RESERVED.iter().map(|s| s.to_string()).collect(), + }; + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("action", "instantiate") + .add_attribute("governance_admin", config.governance_admin) + .add_attribute("dev_module_wallet", config.dev_module_wallet)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::RegisterName { name } => execute_register_name(deps, env, info, name), + #[cfg(feature = "phone")] + ExecuteMsg::LinkPhone { phone } => execute_link_phone(deps, env, info, phone), + ExecuteMsg::TransferName { name, new_owner } => { + execute_transfer_name(deps, info, name, new_owner) + } + ExecuteMsg::ReleaseName { name } => execute_release_name(deps, info, name), + #[cfg(feature = "phone")] + ExecuteMsg::ReleasePhone { phone } => execute_release_phone(deps, info, phone), + #[cfg(feature = "phone")] + ExecuteMsg::UpdateConfig { + name_registration_fee_usaf, + phone_link_fee_usaf, + dev_module_wallet, + } => execute_update_config( + deps, + info, + name_registration_fee_usaf, + phone_link_fee_usaf, + dev_module_wallet, + ), + #[cfg(not(feature = "phone"))] + ExecuteMsg::UpdateConfig { + name_registration_fee_usaf, + dev_module_wallet, + } => execute_update_config(deps, info, name_registration_fee_usaf, dev_module_wallet), + #[cfg(feature = "phone")] + ExecuteMsg::MarkPhoneVerified { phone } => { + execute_mark_phone_verified(deps, env, info, phone) + } + } +} + +fn execute_register_name( + deps: DepsMut, + env: Env, + info: MessageInfo, + name: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let normalized = normalize_name(&name)?; + + if config + .reserved_names + .iter() + .any(|r| r == label_of(&normalized)) + { + return Err(ContractError::ReservedName); + } + if NAMES.has(deps.storage, normalized.as_str()) { + return Err(ContractError::NameTaken); + } + if OWNER_NAMES.has(deps.storage, info.sender.as_str()) { + return Err(ContractError::AlreadyOwnsName); + } + assert_exact_fee(&info, &config.native_denom, config.name_registration_fee_usaf)?; + + NAMES.save( + deps.storage, + normalized.as_str(), + &NameRecord { + owner: info.sender.clone(), + registered_at_height: env.block.height, + registered_at_time: env.block.time.seconds(), + }, + )?; + OWNER_NAMES.save(deps.storage, info.sender.as_str(), &normalized)?; + + Ok(Response::new() + .add_message(fee_payout(&config, config.name_registration_fee_usaf)) + .add_attribute("action", "register_name") + .add_attribute("name", normalized) + .add_attribute("owner", info.sender) + .add_attribute("fee_usaf", config.name_registration_fee_usaf.to_string())) +} + +#[cfg(feature = "phone")] +fn execute_link_phone( + deps: DepsMut, + env: Env, + info: MessageInfo, + phone: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let normalized = validate_phone(&phone)?; + + if PHONES.has(deps.storage, normalized.as_str()) { + return Err(ContractError::PhoneTaken); + } + if OWNER_PHONES.has(deps.storage, info.sender.as_str()) { + return Err(ContractError::AlreadyOwnsPhone); + } + assert_exact_fee(&info, &config.native_denom, config.phone_link_fee_usaf)?; + + PHONES.save( + deps.storage, + normalized.as_str(), + &PhoneRecord { + owner: info.sender.clone(), + verified: false, + registered_at_height: env.block.height, + registered_at_time: env.block.time.seconds(), + verified_at_time: None, + }, + )?; + OWNER_PHONES.save(deps.storage, info.sender.as_str(), &normalized)?; + + Ok(Response::new() + .add_message(fee_payout(&config, config.phone_link_fee_usaf)) + .add_attribute("action", "link_phone") + .add_attribute("phone", normalized) + .add_attribute("owner", info.sender) + .add_attribute("fee_usaf", config.phone_link_fee_usaf.to_string()) + .add_attribute("verified", "false")) +} + +fn execute_transfer_name( + deps: DepsMut, + info: MessageInfo, + name: String, + new_owner: String, +) -> Result { + let normalized = normalize_name(&name)?; + let mut record = NAMES + .may_load(deps.storage, normalized.as_str())? + .ok_or(ContractError::NotFound)?; + if record.owner != info.sender { + return Err(ContractError::Unauthorized); + } + + let new_owner = deps.api.addr_validate(&new_owner)?; + // "One name per address": the recipient must not already hold a name. + if OWNER_NAMES.has(deps.storage, new_owner.as_str()) { + return Err(ContractError::AlreadyOwnsName); + } + + record.owner = new_owner.clone(); + NAMES.save(deps.storage, normalized.as_str(), &record)?; + OWNER_NAMES.remove(deps.storage, info.sender.as_str()); + OWNER_NAMES.save(deps.storage, new_owner.as_str(), &normalized)?; + + Ok(Response::new() + .add_attribute("action", "transfer_name") + .add_attribute("name", normalized) + .add_attribute("from", info.sender) + .add_attribute("to", new_owner)) +} + +fn execute_release_name( + deps: DepsMut, + info: MessageInfo, + name: String, +) -> Result { + let normalized = normalize_name(&name)?; + let record = NAMES + .may_load(deps.storage, normalized.as_str())? + .ok_or(ContractError::NotFound)?; + if record.owner != info.sender { + return Err(ContractError::Unauthorized); + } + + NAMES.remove(deps.storage, normalized.as_str()); + OWNER_NAMES.remove(deps.storage, info.sender.as_str()); + + Ok(Response::new() + .add_attribute("action", "release_name") + .add_attribute("name", normalized) + .add_attribute("owner", info.sender)) +} + +#[cfg(feature = "phone")] +fn execute_release_phone( + deps: DepsMut, + info: MessageInfo, + phone: String, +) -> Result { + let normalized = validate_phone(&phone)?; + let record = PHONES + .may_load(deps.storage, normalized.as_str())? + .ok_or(ContractError::NotFound)?; + if record.owner != info.sender { + return Err(ContractError::Unauthorized); + } + + PHONES.remove(deps.storage, normalized.as_str()); + OWNER_PHONES.remove(deps.storage, info.sender.as_str()); + + Ok(Response::new() + .add_attribute("action", "release_phone") + .add_attribute("phone", normalized) + .add_attribute("owner", info.sender)) +} + +#[cfg(feature = "phone")] +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + name_fee: Option, + phone_fee: Option, + dev_wallet: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + if info.sender != config.governance_admin { + return Err(ContractError::Unauthorized); + } + + if let Some(fee) = name_fee { + config.name_registration_fee_usaf = fee; + } + if let Some(fee) = phone_fee { + config.phone_link_fee_usaf = fee; + } + if let Some(wallet) = dev_wallet { + config.dev_module_wallet = deps.api.addr_validate(&wallet)?; + } + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("action", "update_config") + .add_attribute("updated_by", info.sender)) +} + +#[cfg(not(feature = "phone"))] +fn execute_update_config( + deps: DepsMut, + info: MessageInfo, + name_fee: Option, + dev_wallet: Option, +) -> Result { + let mut config = CONFIG.load(deps.storage)?; + if info.sender != config.governance_admin { + return Err(ContractError::Unauthorized); + } + + if let Some(fee) = name_fee { + config.name_registration_fee_usaf = fee; + } + if let Some(wallet) = dev_wallet { + config.dev_module_wallet = deps.api.addr_validate(&wallet)?; + } + CONFIG.save(deps.storage, &config)?; + + Ok(Response::new() + .add_attribute("action", "update_config") + .add_attribute("updated_by", info.sender)) +} + +#[cfg(feature = "phone")] +fn execute_mark_phone_verified( + deps: DepsMut, + env: Env, + info: MessageInfo, + phone: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + let authorized = info.sender == config.governance_admin + || VERIFIERS + .may_load(deps.storage, info.sender.as_str())? + .unwrap_or(false); + if !authorized { + return Err(ContractError::Unauthorized); + } + + let normalized = validate_phone(&phone)?; + let mut record = PHONES + .may_load(deps.storage, normalized.as_str())? + .ok_or(ContractError::NotFound)?; + record.verified = true; + record.verified_at_time = Some(env.block.time.seconds()); + PHONES.save(deps.storage, normalized.as_str(), &record)?; + + Ok(Response::new() + .add_attribute("action", "mark_phone_verified") + .add_attribute("phone", normalized) + .add_attribute("verified", "true")) +} + +fn fee_payout(config: &Config, amount: Uint128) -> BankMsg { + BankMsg::Send { + to_address: config.dev_module_wallet.to_string(), + amount: vec![Coin { + denom: config.native_denom.clone(), + amount, + }], + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetAddress { input } => to_json_binary(&query_get_address(deps, input)?), + QueryMsg::ResolveName { name } => to_json_binary(&query_resolve_name(deps, name)?), + #[cfg(feature = "phone")] + QueryMsg::ResolvePhone { phone } => to_json_binary(&query_resolve_phone(deps, phone)?), + QueryMsg::Config {} => to_json_binary(&CONFIG.load(deps.storage)?), + QueryMsg::NameRecord { name } => to_json_binary(&query_name_record(deps, name)?), + #[cfg(feature = "phone")] + QueryMsg::PhoneRecord { phone } => to_json_binary(&query_phone_record(deps, phone)?), + QueryMsg::Handles { address } => to_json_binary(&query_handles(deps, address)?), + } +} + +fn not_found() -> StdError { + StdError::generic_err("Record not found") +} + +fn to_std(err: ContractError) -> StdError { + StdError::generic_err(err.to_string()) +} + +fn query_get_address(deps: Deps, input: String) -> StdResult { + // An all-digit input resolves against the phone registry (Phase 2 only). + #[cfg(feature = "phone")] + if looks_like_phone(&input) { + let phone = validate_phone(&input).map_err(to_std)?; + let record = PHONES + .may_load(deps.storage, phone.as_str())? + .ok_or_else(not_found)?; + return Ok(GetAddressResponse { + address: record.owner.into_string(), + record_type: "phone".to_string(), + normalized_key: phone, + verified: Some(record.verified), + }); + } + + let normalized = normalize_name(&input).map_err(to_std)?; + let record = NAMES + .may_load(deps.storage, normalized.as_str())? + .ok_or_else(not_found)?; + Ok(GetAddressResponse { + address: record.owner.into_string(), + record_type: "name".to_string(), + normalized_key: normalized, + #[cfg(feature = "phone")] + verified: None, + }) +} + +fn query_resolve_name(deps: Deps, name: String) -> StdResult { + let normalized = normalize_name(&name).map_err(to_std)?; + let record = NAMES + .may_load(deps.storage, normalized.as_str())? + .ok_or_else(not_found)?; + Ok(AddressResponse { + address: record.owner.into_string(), + }) +} + +#[cfg(feature = "phone")] +fn query_resolve_phone(deps: Deps, phone: String) -> StdResult { + let normalized = validate_phone(&phone).map_err(to_std)?; + let record = PHONES + .may_load(deps.storage, normalized.as_str())? + .ok_or_else(not_found)?; + Ok(AddressResponse { + address: record.owner.into_string(), + }) +} + +fn query_name_record(deps: Deps, name: String) -> StdResult { + let normalized = normalize_name(&name).map_err(to_std)?; + NAMES + .may_load(deps.storage, normalized.as_str())? + .ok_or_else(not_found) +} + +#[cfg(feature = "phone")] +fn query_phone_record(deps: Deps, phone: String) -> StdResult { + let normalized = validate_phone(&phone).map_err(to_std)?; + PHONES + .may_load(deps.storage, normalized.as_str())? + .ok_or_else(not_found) +} + +/// Reverse lookup via the owner indexes — direct key reads, never a scan. +/// Returns `None` for whichever record the address does not hold. +fn query_handles(deps: Deps, address: String) -> StdResult { + let addr = deps.api.addr_validate(&address)?; + Ok(HandlesResponse { + name: OWNER_NAMES.may_load(deps.storage, addr.as_str())?, + #[cfg(feature = "phone")] + phone: OWNER_PHONES.may_load(deps.storage, addr.as_str())?, + }) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, msg: MigrateMsg) -> Result { + // Re-key phone records from the legacy `+243…` form to bare digits `243…` + // (see validation.rs). Idempotent: entries already bare are skipped, so a + // second migrate run is a no-op. Compiled out unless the `phone` feature is on. + #[cfg(feature = "phone")] + let phones_rekeyed: u32 = { + let phones: Vec<(String, PhoneRecord)> = PHONES + .range(deps.storage, None, None, Order::Ascending) + .collect::>()?; + let mut rekeyed: u32 = 0; + for (old_key, record) in phones { + if let Some(stripped) = old_key.strip_prefix('+') { + PHONES.remove(deps.storage, &old_key); + PHONES.save(deps.storage, stripped, &record)?; + rekeyed += 1; + } + } + // The reverse index keeps the phone string as its *value*; strip that too. + let owners: Vec<(String, String)> = OWNER_PHONES + .range(deps.storage, None, None, Order::Ascending) + .collect::>()?; + for (owner, phone) in owners { + if let Some(stripped) = phone.strip_prefix('+') { + OWNER_PHONES.save(deps.storage, &owner, &stripped.to_string())?; + } + } + rekeyed + }; + + if let Some(add) = msg.add_reserved_names { + CONFIG.update(deps.storage, |mut config| -> Result<_, ContractError> { + for name in add { + if !config.reserved_names.contains(&name) { + config.reserved_names.push(name); + } + } + Ok(config) + })?; + } + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let resp = Response::new().add_attribute("action", "migrate"); + #[cfg(feature = "phone")] + let resp = resp.add_attribute("phones_rekeyed", phones_rekeyed.to_string()); + Ok(resp) +} diff --git a/contracts/safhandle/src/error.rs b/contracts/safhandle/src/error.rs new file mode 100644 index 0000000..e41563d --- /dev/null +++ b/contracts/safhandle/src/error.rs @@ -0,0 +1,45 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +/// Error variants match the names documented in `docs/CONTRACT_API.md`. +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized, + + #[error("Invalid name")] + InvalidName, + + #[error("Email addresses are not valid names")] + EmailNotAllowed, + + #[error("Reserved name")] + ReservedName, + + #[error("Name already taken")] + NameTaken, + + #[cfg(feature = "phone")] + #[error("Invalid phone number")] + InvalidPhone, + + #[cfg(feature = "phone")] + #[error("Phone already taken")] + PhoneTaken, + + #[error("Incorrect fee amount")] + InsufficientFee, + + #[error("Record not found")] + NotFound, + + #[error("Address already owns a name")] + AlreadyOwnsName, + + #[cfg(feature = "phone")] + #[error("Address already owns a phone")] + AlreadyOwnsPhone, +} diff --git a/contracts/safhandle/src/helpers.rs b/contracts/safhandle/src/helpers.rs new file mode 100644 index 0000000..0b9fee6 --- /dev/null +++ b/contracts/safhandle/src/helpers.rs @@ -0,0 +1,20 @@ +use cosmwasm_std::{MessageInfo, Uint128}; + +use crate::error::ContractError; + +/// Enforce that exactly `expected` of `denom` was attached — no more, no less, +/// and no other coins. Overpayment and underpayment are both rejected. +pub fn assert_exact_fee( + info: &MessageInfo, + denom: &str, + expected: Uint128, +) -> Result<(), ContractError> { + if info.funds.len() != 1 { + return Err(ContractError::InsufficientFee); + } + let coin = &info.funds[0]; + if coin.denom != denom || coin.amount != expected { + return Err(ContractError::InsufficientFee); + } + Ok(()) +} diff --git a/contracts/safhandle/src/lib.rs b/contracts/safhandle/src/lib.rs new file mode 100644 index 0000000..7361386 --- /dev/null +++ b/contracts/safhandle/src/lib.rs @@ -0,0 +1,8 @@ +pub mod contract; +pub mod error; +pub mod helpers; +pub mod msg; +pub mod state; +pub mod validation; + +pub use error::ContractError; diff --git a/contracts/safhandle/src/msg.rs b/contracts/safhandle/src/msg.rs new file mode 100644 index 0000000..02adfb6 --- /dev/null +++ b/contracts/safhandle/src/msg.rs @@ -0,0 +1,100 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Uint128; + +// Referenced by the `#[returns(...)]` schema impl, which is not emitted for +// the wasm target — hence unused there. +#[allow(unused_imports)] +use crate::state::{Config, NameRecord}; +#[cfg(feature = "phone")] +#[allow(unused_imports)] +use crate::state::PhoneRecord; + +#[cw_serde] +pub struct InstantiateMsg { + pub native_denom: String, + pub name_registration_fee_usaf: Uint128, + #[cfg(feature = "phone")] + pub phone_link_fee_usaf: Uint128, + pub dev_module_wallet: String, + pub governance_admin: String, +} + +#[cw_serde] +pub enum ExecuteMsg { + /// Register a short name for the sender. Funds: exactly the name fee. + RegisterName { name: String }, + /// Link a phone (bare digits, no `+`) to the sender. Funds: exactly the phone fee. + #[cfg(feature = "phone")] + LinkPhone { phone: String }, + /// Transfer name ownership. Owner-only. + TransferName { name: String, new_owner: String }, + /// Release a name back to the pool. Owner-only. + ReleaseName { name: String }, + /// Remove a phone link. Owner-only. + #[cfg(feature = "phone")] + ReleasePhone { phone: String }, + /// Governance-only partial config update. + UpdateConfig { + name_registration_fee_usaf: Option, + #[cfg(feature = "phone")] + phone_link_fee_usaf: Option, + dev_module_wallet: Option, + }, + /// Phase 2: mark a phone verified. Verifier/governance only. + #[cfg(feature = "phone")] + MarkPhoneVerified { phone: String }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + /// Resolve an input to an address. With the `phone` feature enabled, an + /// all-digit input is auto-detected and resolved against the phone registry. + #[returns(GetAddressResponse)] + GetAddress { input: String }, + #[returns(AddressResponse)] + ResolveName { name: String }, + #[cfg(feature = "phone")] + #[returns(AddressResponse)] + ResolvePhone { phone: String }, + #[returns(Config)] + Config {}, + #[returns(NameRecord)] + NameRecord { name: String }, + #[cfg(feature = "phone")] + #[returns(PhoneRecord)] + PhoneRecord { phone: String }, + /// Reverse lookup: the name (and phone, when enabled) owned by an address. + #[returns(HandlesResponse)] + Handles { address: String }, +} + +#[cw_serde] +pub struct GetAddressResponse { + pub address: String, + pub record_type: String, + pub normalized_key: String, + /// Phone verification status; only present when phone linking is enabled. + #[cfg(feature = "phone")] + pub verified: Option, +} + +#[cw_serde] +pub struct AddressResponse { + pub address: String, +} + +/// Reverse-lookup result. Each field is `None` when the address owns no record +/// of that kind. See `docs/CONTRACT_API.md`. +#[cw_serde] +pub struct HandlesResponse { + pub name: Option, + #[cfg(feature = "phone")] + pub phone: Option, +} + +#[cw_serde] +pub struct MigrateMsg { + /// Additive-only: extend the reserved name list on upgrade. + pub add_reserved_names: Option>, +} diff --git a/contracts/safhandle/src/state.rs b/contracts/safhandle/src/state.rs new file mode 100644 index 0000000..0da5ec9 --- /dev/null +++ b/contracts/safhandle/src/state.rs @@ -0,0 +1,52 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint128}; +use cw_storage_plus::{Item, Map}; + +/// Governance-updatable contract configuration. See `docs/STATE_SCHEMA.md`. +#[cw_serde] +pub struct Config { + pub native_denom: String, + pub name_registration_fee_usaf: Uint128, + #[cfg(feature = "phone")] + pub phone_link_fee_usaf: Uint128, + pub dev_module_wallet: Addr, + pub governance_admin: Addr, + pub reserved_names: Vec, +} + +/// A registered short name, keyed by its normalized form (e.g. `john.saf`). +#[cw_serde] +pub struct NameRecord { + pub owner: Addr, + pub registered_at_height: u64, + pub registered_at_time: u64, +} + +/// A linked phone number, keyed by its bare digits (e.g. `243899123456`, no `+`). +#[cfg(feature = "phone")] +#[cw_serde] +pub struct PhoneRecord { + pub owner: Addr, + pub verified: bool, + pub registered_at_height: u64, + pub registered_at_time: u64, + pub verified_at_time: Option, +} + +pub const CONFIG: Item = Item::new("config"); + +/// Normalized name -> record. +pub const NAMES: Map<&str, NameRecord> = Map::new("names"); +/// Phone digits -> record. +#[cfg(feature = "phone")] +pub const PHONES: Map<&str, PhoneRecord> = Map::new("phones"); + +/// Reverse index enforcing "one name per address". +pub const OWNER_NAMES: Map<&str, String> = Map::new("owner_names"); +/// Reverse index enforcing "one phone per address". +#[cfg(feature = "phone")] +pub const OWNER_PHONES: Map<&str, String> = Map::new("owner_phones"); + +/// Addresses authorized to call `mark_phone_verified` (Phase 2). +#[cfg(feature = "phone")] +pub const VERIFIERS: Map<&str, bool> = Map::new("verifiers"); diff --git a/contracts/safhandle/src/validation.rs b/contracts/safhandle/src/validation.rs new file mode 100644 index 0000000..9393e4e --- /dev/null +++ b/contracts/safhandle/src/validation.rs @@ -0,0 +1,200 @@ +use crate::error::ContractError; + +pub const NAME_SUFFIX: &str = ".saf"; +pub const LABEL_MIN: usize = 3; +pub const LABEL_MAX: usize = 32; +pub const NAME_MAX: usize = 64; + +/// Country-code + subscriber digit bounds. Phones are stored as bare digits, +/// without a leading `+`. +#[cfg(feature = "phone")] +pub const PHONE_MIN_DIGITS: usize = 8; +#[cfg(feature = "phone")] +pub const PHONE_MAX_DIGITS: usize = 15; + +/// Reserved labels seeded at instantiate. See `docs/NAME_RULES.md`. +pub const DEFAULT_RESERVED: &[&str] = &[ + // Protocol + "safrochain", "safro", "safhandle", "gov", "admin", + // Infrastructure + "validator", "faucet", "treasury", "dev", + // TLD-like + "www", "api", "rpc", "explorer", +]; + +/// Return the label portion of a normalized name (without the `.saf` suffix). +pub fn label_of(normalized: &str) -> &str { + normalized.strip_suffix(NAME_SUFFIX).unwrap_or(normalized) +} + +/// Normalize and structurally validate a short name. +/// +/// Steps: trim -> reject email/non-ASCII -> lowercase ASCII -> append `.saf` if +/// missing -> reject stray dots -> validate the label character set and length. +/// Reserved-list membership is checked by the caller against the +/// (governance-updatable) config. +/// +/// Hardening (see `docs/NAME_RULES.md`): an `@` anywhere yields a dedicated +/// `EmailNotAllowed` error, non-ASCII input is rejected outright (blocks +/// homoglyph/unicode spoofing), and any dot outside the `.saf` suffix is +/// rejected (blocks domain- and email-shaped input like `john.com`). +pub fn normalize_name(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(ContractError::InvalidName); + } + + // An `@` means the caller passed an email address (or something shaped like + // one). Reject with a dedicated, self-explanatory error before anything else. + if trimmed.contains('@') { + return Err(ContractError::EmailNotAllowed); + } + + // Reject any non-ASCII byte. `to_ascii_lowercase` would leave unicode + // untouched and the charset check below would catch it, but an explicit + // guard blocks homoglyph/confusable spoofing with a clear failure point. + if !trimmed.is_ascii() { + return Err(ContractError::InvalidName); + } + + let lowered = trimmed.to_ascii_lowercase(); + let label = lowered.strip_suffix(NAME_SUFFIX).unwrap_or(&lowered); + + // After stripping the single allowed `.saf` suffix, no dot may remain. + // Blocks `john.com`, `a.b.saf`, and other domain/email-shaped input. + if label.contains('.') { + return Err(ContractError::InvalidName); + } + + validate_label(label)?; + + let normalized = format!("{label}{NAME_SUFFIX}"); + if normalized.len() > NAME_MAX { + return Err(ContractError::InvalidName); + } + Ok(normalized) +} + +fn validate_label(label: &str) -> Result<(), ContractError> { + let len = label.len(); + if !(LABEL_MIN..=LABEL_MAX).contains(&len) { + return Err(ContractError::InvalidName); + } + + // Numeric-only labels are reserved (blocks `123.saf`, `000.saf`). + if label.bytes().all(|b| b.is_ascii_digit()) { + return Err(ContractError::ReservedName); + } + + let bytes = label.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + let allowed = b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'; + if !allowed { + return Err(ContractError::InvalidName); + } + if b == b'-' { + // No leading/trailing hyphen, no consecutive hyphens. The + // no-consecutive-hyphen rule also rejects the IDN/punycode ACE + // prefix `xn--`, so a label can never render as a unicode lookalike. + if i == 0 || i == bytes.len() - 1 || bytes[i - 1] == b'-' { + return Err(ContractError::InvalidName); + } + } + } + Ok(()) +} + +/// Validate and normalize a phone number, stored as bare digits (no `+`), +/// e.g. `243899123456` (country code + subscriber). +#[cfg(feature = "phone")] +pub fn validate_phone(input: &str) -> Result { + let digits = input.trim(); + let len = digits.len(); + if !(PHONE_MIN_DIGITS..=PHONE_MAX_DIGITS).contains(&len) { + return Err(ContractError::InvalidPhone); + } + // All digits (so a leading `+` or any separator is rejected); country codes + // never start with 0. + if !digits.bytes().all(|b| b.is_ascii_digit()) || digits.starts_with('0') { + return Err(ContractError::InvalidPhone); + } + Ok(digits.to_string()) +} + +/// A trimmed, all-digit input is routed to the phone registry. Names always +/// contain a non-digit (numeric-only names are reserved), so phone and name +/// inputs never collide. +#[cfg(feature = "phone")] +pub fn looks_like_phone(input: &str) -> bool { + let trimmed = input.trim(); + !trimmed.is_empty() && trimmed.bytes().all(|b| b.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_names() { + assert_eq!(normalize_name(" John ").unwrap(), "john.saf"); + assert_eq!(normalize_name("alice").unwrap(), "alice.saf"); + assert_eq!(normalize_name("John.SAF").unwrap(), "john.saf"); + assert_eq!(normalize_name("my-name").unwrap(), "my-name.saf"); + } + + #[test] + fn rejects_bad_names() { + assert_eq!(normalize_name("a"), Err(ContractError::InvalidName)); // too short + assert_eq!(normalize_name(""), Err(ContractError::InvalidName)); + assert_eq!(normalize_name("-bad"), Err(ContractError::InvalidName)); + assert_eq!(normalize_name("bad-"), Err(ContractError::InvalidName)); + assert_eq!(normalize_name("a--b"), Err(ContractError::InvalidName)); + assert_eq!(normalize_name("under_score"), Err(ContractError::InvalidName)); + assert_eq!(normalize_name("123"), Err(ContractError::ReservedName)); + } + + /// Costaud validation: emails, domains, unicode, and punycode are all + /// rejected — with a dedicated error for anything email-shaped. + #[test] + fn rejects_email_shaped_input() { + assert_eq!(normalize_name("john@gmail.com"), Err(ContractError::EmailNotAllowed)); + assert_eq!(normalize_name("john@safrochain.com"), Err(ContractError::EmailNotAllowed)); + assert_eq!(normalize_name("a@b"), Err(ContractError::EmailNotAllowed)); + assert_eq!(normalize_name(" bob@x.saf "), Err(ContractError::EmailNotAllowed)); + } + + #[test] + fn rejects_domain_and_unicode_input() { + // Domain-shaped input (stray dots outside the `.saf` suffix). + assert_eq!(normalize_name("john.com"), Err(ContractError::InvalidName)); + assert_eq!(normalize_name("john.safrochain.com"), Err(ContractError::InvalidName)); + assert_eq!(normalize_name("a.b.saf"), Err(ContractError::InvalidName)); + // Non-ASCII / homoglyph spoofing. + assert_eq!(normalize_name("café"), Err(ContractError::InvalidName)); + assert_eq!(normalize_name("аdmin"), Err(ContractError::InvalidName)); // cyrillic 'а' + assert_eq!(normalize_name("emoji😀name"), Err(ContractError::InvalidName)); + // Punycode ACE prefix (blocked by the no-consecutive-hyphen rule). + assert_eq!(normalize_name("xn--n3h"), Err(ContractError::InvalidName)); + } + + #[cfg(feature = "phone")] + #[test] + fn validates_phones() { + assert_eq!(validate_phone("243899123456").unwrap(), "243899123456"); + assert_eq!(validate_phone(" 243899123456 ").unwrap(), "243899123456"); + assert_eq!(validate_phone("+243899123456"), Err(ContractError::InvalidPhone)); // '+' rejected + assert_eq!(validate_phone("243 899"), Err(ContractError::InvalidPhone)); + assert_eq!(validate_phone("123"), Err(ContractError::InvalidPhone)); // too short + assert_eq!(validate_phone("0123456789"), Err(ContractError::InvalidPhone)); // leading 0 + } + + #[cfg(feature = "phone")] + #[test] + fn routes_all_digit_input_to_phone() { + assert!(looks_like_phone("243899123456")); + assert!(looks_like_phone(" 243899123456 ")); + assert!(!looks_like_phone("+243899123456")); // '+' is not a digit + assert!(!looks_like_phone("john")); + assert!(!looks_like_phone("")); + } +} diff --git a/contracts/safhandle/tests/integration.rs b/contracts/safhandle/tests/integration.rs new file mode 100644 index 0000000..9aa6440 --- /dev/null +++ b/contracts/safhandle/tests/integration.rs @@ -0,0 +1,406 @@ +use cosmwasm_std::{coin, testing::MockApi, Addr, Empty, Uint128}; +use cw_multi_test::{App, AppBuilder, Contract, ContractWrapper, Executor}; + +use safhandle::contract::{execute, instantiate, query}; +use safhandle::msg::{ + AddressResponse, ExecuteMsg, GetAddressResponse, HandlesResponse, InstantiateMsg, QueryMsg, +}; +use safhandle::state::Config; +use safhandle::ContractError; + +const DENOM: &str = "usaf"; +const NAME_FEE: u128 = 50_000_000; +#[cfg(feature = "phone")] +const PHONE_FEE: u128 = 100_000_000; +const PREFIX: &str = "addr_safro"; + +fn contract_code() -> Box> { + Box::new(ContractWrapper::new(execute, instantiate, query)) +} + +struct Ctx { + app: App, + gov: Addr, + dev: Addr, + contract: Addr, +} + +impl Ctx { + fn addr(&self, name: &str) -> Addr { + self.app.api().addr_make(name) + } +} + +fn setup() -> Ctx { + let mut app = AppBuilder::new() + .with_api(MockApi::default().with_prefix(PREFIX)) + .build(|router, api, storage| { + for name in ["alice", "bob", "carol"] { + let addr = api.addr_make(name); + router + .bank + .init_balance(storage, &addr, vec![coin(1_000_000_000, DENOM)]) + .unwrap(); + } + }); + + let gov = app.api().addr_make("gov"); + let dev = app.api().addr_make("dev"); + let code_id = app.store_code(contract_code()); + + let contract = app + .instantiate_contract( + code_id, + gov.clone(), + &InstantiateMsg { + native_denom: DENOM.to_string(), + name_registration_fee_usaf: Uint128::new(NAME_FEE), + #[cfg(feature = "phone")] + phone_link_fee_usaf: Uint128::new(PHONE_FEE), + dev_module_wallet: dev.to_string(), + governance_admin: gov.to_string(), + }, + &[], + "safhandle", + Some(gov.to_string()), + ) + .unwrap(); + + Ctx { app, gov, dev, contract } +} + +fn register(ctx: &mut Ctx, who: &Addr, name: &str, amount: u128) -> anyhow::Result<()> { + ctx.app + .execute_contract( + who.clone(), + ctx.contract.clone(), + &ExecuteMsg::RegisterName { name: name.to_string() }, + &[coin(amount, DENOM)], + ) + .map(|_| ()) +} + +fn expect_err(res: anyhow::Result<()>, expected: ContractError) { + let err = res.unwrap_err(); + assert_eq!(err.downcast::().unwrap(), expected); +} + +#[test] +fn register_resolve_and_route_fee() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + + register(&mut ctx, &alice, "John", NAME_FEE).unwrap(); + + let res: GetAddressResponse = ctx + .app + .wrap() + .query_wasm_smart(&ctx.contract, &QueryMsg::GetAddress { input: "john".to_string() }) + .unwrap(); + assert_eq!(res.address, alice.to_string()); + assert_eq!(res.normalized_key, "john.saf"); + assert_eq!(res.record_type, "name"); + #[cfg(feature = "phone")] + assert_eq!(res.verified, None); + + // The exact fee lands in the dev module wallet. + let bal = ctx.app.wrap().query_balance(&ctx.dev, DENOM).unwrap(); + assert_eq!(bal.amount, Uint128::new(NAME_FEE)); +} + +#[test] +fn wrong_fee_is_rejected() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + + expect_err( + register(&mut ctx, &alice, "john", NAME_FEE - 1), + ContractError::InsufficientFee, + ); + expect_err( + register(&mut ctx, &alice, "john", NAME_FEE + 1), + ContractError::InsufficientFee, + ); +} + +#[test] +fn duplicate_name_is_rejected() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + let bob = ctx.addr("bob"); + + register(&mut ctx, &alice, "john", NAME_FEE).unwrap(); + expect_err(register(&mut ctx, &bob, "john", NAME_FEE), ContractError::NameTaken); +} + +#[test] +fn one_name_per_address() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + + register(&mut ctx, &alice, "john", NAME_FEE).unwrap(); + expect_err( + register(&mut ctx, &alice, "mary", NAME_FEE), + ContractError::AlreadyOwnsName, + ); +} + +#[test] +fn reserved_name_is_rejected() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + expect_err(register(&mut ctx, &alice, "safro", NAME_FEE), ContractError::ReservedName); +} + +#[test] +fn transfer_moves_ownership() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + let bob = ctx.addr("bob"); + + register(&mut ctx, &alice, "john", NAME_FEE).unwrap(); + ctx.app + .execute_contract( + alice.clone(), + ctx.contract.clone(), + &ExecuteMsg::TransferName { + name: "john".to_string(), + new_owner: bob.to_string(), + }, + &[], + ) + .unwrap(); + + let res: AddressResponse = ctx + .app + .wrap() + .query_wasm_smart(&ctx.contract, &QueryMsg::ResolveName { name: "john.saf".to_string() }) + .unwrap(); + assert_eq!(res.address, bob.to_string()); + + // Alice no longer owns a name, so she can register a fresh one. + register(&mut ctx, &alice, "alice", NAME_FEE).unwrap(); +} + +#[test] +fn release_then_reregister() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + let bob = ctx.addr("bob"); + + register(&mut ctx, &alice, "john", NAME_FEE).unwrap(); + ctx.app + .execute_contract( + alice.clone(), + ctx.contract.clone(), + &ExecuteMsg::ReleaseName { name: "john".to_string() }, + &[], + ) + .unwrap(); + + // No grace period in Phase 1: immediately re-registrable by anyone. + register(&mut ctx, &bob, "john", NAME_FEE).unwrap(); +} + +/// Emails and other special-character input are rejected at registration. +/// An `@` yields the dedicated `EmailNotAllowed` error; everything else that +/// breaks the charset yields `InvalidName`. +#[test] +fn rejects_email_and_special_input() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + + expect_err( + register(&mut ctx, &alice, "john@gmail.com", NAME_FEE), + ContractError::EmailNotAllowed, + ); + expect_err( + register(&mut ctx, &alice, "john.com", NAME_FEE), + ContractError::InvalidName, + ); + expect_err( + register(&mut ctx, &alice, "café", NAME_FEE), + ContractError::InvalidName, + ); + expect_err( + register(&mut ctx, &alice, "under_score", NAME_FEE), + ContractError::InvalidName, + ); + // Uppercase is accepted but normalized to lowercase. + register(&mut ctx, &alice, "MixedCase", NAME_FEE).unwrap(); + let res: AddressResponse = ctx + .app + .wrap() + .query_wasm_smart( + &ctx.contract, + &QueryMsg::ResolveName { name: "mixedcase".to_string() }, + ) + .unwrap(); + assert_eq!(res.address, alice.to_string()); +} + +#[test] +fn handles_reverse_lookup_name_only() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + let bob = ctx.addr("bob"); + + register(&mut ctx, &alice, "john", NAME_FEE).unwrap(); + + let res: HandlesResponse = ctx + .app + .wrap() + .query_wasm_smart(&ctx.contract, &QueryMsg::Handles { address: alice.to_string() }) + .unwrap(); + assert_eq!(res.name, Some("john.saf".to_string())); + + // An address with no records gets None. + let res: HandlesResponse = ctx + .app + .wrap() + .query_wasm_smart(&ctx.contract, &QueryMsg::Handles { address: bob.to_string() }) + .unwrap(); + assert_eq!(res.name, None); + + // After releasing the name, the reverse index clears. + ctx.app + .execute_contract( + alice.clone(), + ctx.contract.clone(), + &ExecuteMsg::ReleaseName { name: "john".to_string() }, + &[], + ) + .unwrap(); + let res: HandlesResponse = ctx + .app + .wrap() + .query_wasm_smart(&ctx.contract, &QueryMsg::Handles { address: alice.to_string() }) + .unwrap(); + assert_eq!(res.name, None); +} + +#[cfg(feature = "phone")] +#[test] +fn link_and_resolve_phone() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + + ctx.app + .execute_contract( + alice.clone(), + ctx.contract.clone(), + &ExecuteMsg::LinkPhone { phone: "243899123456".to_string() }, + &[coin(PHONE_FEE, DENOM)], + ) + .unwrap(); + + let res: GetAddressResponse = ctx + .app + .wrap() + .query_wasm_smart( + &ctx.contract, + &QueryMsg::GetAddress { input: "243899123456".to_string() }, + ) + .unwrap(); + assert_eq!(res.address, alice.to_string()); + assert_eq!(res.record_type, "phone"); + assert_eq!(res.verified, Some(false)); +} + +#[cfg(feature = "phone")] +#[test] +fn handles_reverse_lookup() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + let bob = ctx.addr("bob"); + + // Alice registers a name and links a phone. + register(&mut ctx, &alice, "john", NAME_FEE).unwrap(); + ctx.app + .execute_contract( + alice.clone(), + ctx.contract.clone(), + &ExecuteMsg::LinkPhone { phone: "243899123456".to_string() }, + &[coin(PHONE_FEE, DENOM)], + ) + .unwrap(); + + // Reverse lookup returns both handles for Alice. + let res: HandlesResponse = ctx + .app + .wrap() + .query_wasm_smart(&ctx.contract, &QueryMsg::Handles { address: alice.to_string() }) + .unwrap(); + assert_eq!(res.name, Some("john.saf".to_string())); + assert_eq!(res.phone, Some("243899123456".to_string())); + + // An address with no records gets None on both. + let res: HandlesResponse = ctx + .app + .wrap() + .query_wasm_smart(&ctx.contract, &QueryMsg::Handles { address: bob.to_string() }) + .unwrap(); + assert_eq!(res.name, None); + assert_eq!(res.phone, None); + + // After releasing the name, the reverse index clears. + ctx.app + .execute_contract( + alice.clone(), + ctx.contract.clone(), + &ExecuteMsg::ReleaseName { name: "john".to_string() }, + &[], + ) + .unwrap(); + let res: HandlesResponse = ctx + .app + .wrap() + .query_wasm_smart(&ctx.contract, &QueryMsg::Handles { address: alice.to_string() }) + .unwrap(); + assert_eq!(res.name, None); + assert_eq!(res.phone, Some("243899123456".to_string())); +} + +#[test] +fn update_config_is_governance_only() { + let mut ctx = setup(); + let alice = ctx.addr("alice"); + let gov = ctx.gov.clone(); + + let unauthorized = ctx + .app + .execute_contract( + alice, + ctx.contract.clone(), + &ExecuteMsg::UpdateConfig { + name_registration_fee_usaf: Some(Uint128::new(75_000_000)), + #[cfg(feature = "phone")] + phone_link_fee_usaf: None, + dev_module_wallet: None, + }, + &[], + ) + .map(|_| ()); + expect_err(unauthorized, ContractError::Unauthorized); + + ctx.app + .execute_contract( + gov, + ctx.contract.clone(), + &ExecuteMsg::UpdateConfig { + name_registration_fee_usaf: Some(Uint128::new(75_000_000)), + #[cfg(feature = "phone")] + phone_link_fee_usaf: None, + dev_module_wallet: None, + }, + &[], + ) + .unwrap(); + + let config: Config = ctx + .app + .wrap() + .query_wasm_smart(&ctx.contract, &QueryMsg::Config {}) + .unwrap(); + assert_eq!(config.name_registration_fee_usaf, Uint128::new(75_000_000)); +} diff --git a/docs/ANTI_SQUATTING.md b/docs/ANTI_SQUATTING.md index 666115a..2f08f7b 100644 --- a/docs/ANTI_SQUATTING.md +++ b/docs/ANTI_SQUATTING.md @@ -7,7 +7,7 @@ SafHandle uses economic and policy mechanisms to reduce name and phone squatting | Mechanism | Effect | | --- | --- | | **50 SAF name fee** | Raises cost of registering many names | -| **100 SAF phone fee** | Higher cost for phone namespace | +| **100 SAF phone fee** _(Phase 2)_ | Higher cost for phone namespace | | **No refund on release** | Squatters cannot recover fees when abandoning names | | **Governance-adjustable fees** | Community can raise fees if squatting increases | @@ -18,8 +18,9 @@ Fees are not trivially bypassed — exact `usaf` amounts are enforced on every r | Rule | Anti-squatting effect | | --- | --- | | Minimum 3-character labels | Blocks single-letter premium grabs (`a.saf`) | +| Numeric-only labels reserved | Blocks `123.saf`-style grabs | | Reserved name list | Protects protocol and brand names | -| Character set restrictions | Prevents homograph / unicode confusion attacks | +| Character set restrictions | Lowercase ASCII + digits + hyphen only; emails (`@`), stray dots, and non-ASCII / punycode are rejected — prevents homograph / unicode confusion | | Global uniqueness | One owner per normalized name | See [NAME_RULES.md](./NAME_RULES.md) for full validation rules. @@ -54,7 +55,7 @@ The contract spec recommends **optional** per-block registration limits per addr | Phones per address | 1 active | | Registrations per address per day | 1 (future enhancement) | -Phase 1 enforces one name and one phone per address. Daily rate limits may be added in a contract upgrade. +v1 enforces one name per address (and one phone per address in the phone build). Daily rate limits may be added in a contract upgrade. ## No grace period (Phase 1) @@ -64,9 +65,9 @@ Released names are immediately available for re-registration. Future phases may - Renewal fees for long-held names - Expiration for inactive names -## Phone squatting +## Phone squatting _(Phase 2)_ -Phone numbers have a higher fee (100 SAF) and require Phase 2 verification for trusted status. Unverified phones resolve but display warnings — reducing value of squatting unowned numbers. +When phone linking ships, phone numbers carry a higher fee (100 SAF) and require verification for trusted status. Unverified phones resolve but display warnings — reducing the value of squatting unowned numbers. ## Reporting abuse diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f0faf5..8855ac5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # SafHandle Architecture -SafHandle is a single **CosmWasm smart contract** deployed on Safrochain. It maintains two registries (short names and phone numbers), enforces fees, and exposes governance-updatable configuration. +SafHandle is a single **CosmWasm smart contract** deployed on Safrochain. In v1 it maintains a **short-name registry**, enforces fees, and exposes governance-updatable configuration. A second **phone registry** ships behind the `phone` Cargo feature (Phase 2, compiled out of the v1 wasm). ## High-level diagram @@ -32,18 +32,18 @@ flowchart TB | Responsibility | Details | | --- | --- | | Name registry | Map normalized short names to owner addresses | -| Phone registry | Map E.164 numbers to owner addresses | +| Phone registry _(phone feature)_ | Map bare-digit numbers to owner addresses | | Fee collection | Accept `usaf` fees on registration actions | | Fee routing | Send collected fees to dev module wallet | | Governance config | Store updatable fee amounts and admin params | -| Queries | Resolve any name or phone to `addr_safro` | +| Queries | Resolve a name (or phone, in the phone build) to `addr_safro` | ### Governance module Safrochain on-chain governance can update: - `name_registration_fee_usaf` (default: 50,000,000) -- `phone_link_fee_usaf` (default: 100,000,000) +- `phone_link_fee_usaf` (default: 100,000,000) — _phone feature only_ - `dev_module_wallet` address - Reserved name list extensions @@ -53,18 +53,18 @@ Governance executes `UpdateConfig` via authorized proposal — not callable by a All registration fees flow to a single **dev module wallet** account configured at instantiate. This account is controlled by the Safrochain development fund / module treasury. -| Fee type | Default | Recipient | -| --- | --- | --- | -| Name registration | 50 SAF | Dev module wallet | -| Phone link | 100 SAF | Dev module wallet | +| Fee type | Default | Recipient | Availability | +| --- | --- | --- | --- | +| Name registration | 50 SAF | Dev module wallet | v1 | +| Phone link | 100 SAF | Dev module wallet | Phase 2 | ### SDK layer (separate repo) The [safhandle-sdk](https://github.com/Safrochain-Org/safhandle-sdk) wraps contract queries and executes for wallets and dApps. It is not part of this contract repository but consumes the same message schema. -### Phone verification (Phase 2) +### Phone linking & verification (Phase 2) -Phase 1 stores phone links on-chain with `verified: false`. Phase 2 adds an off-chain verification service that submits `MarkPhoneVerified` (or equivalent) after OTP/SMS proof. See [PHONE_LINKING.md](./PHONE_LINKING.md). +Phone linking is not in v1. When the `phone` feature is enabled, links are stored on-chain with `verified: false`, and an off-chain verification service submits `MarkPhoneVerified` after OTP/SMS proof. See [PHONE_LINKING.md](./PHONE_LINKING.md). ## Data flow: name registration diff --git a/docs/CLI_COMMANDS.md b/docs/CLI_COMMANDS.md new file mode 100644 index 0000000..12c60b0 --- /dev/null +++ b/docs/CLI_COMMANDS.md @@ -0,0 +1,372 @@ +# CLI Commands Guide — SafHandle + +This guide covers the **full lifecycle** of a SafHandle handle from the terminal with +`safrochaind`: from **attaching** a name or phone number, to **querying** it, through to +**releasing** the attachment. + +> There are two families of commands: +> +> - `safrochaind tx wasm execute …` → **transactions** that change state (attach, transfer, release). Signed, cost gas, sometimes fees. +> - `safrochaind query wasm contract-state smart …` → **read-only queries**. Free, no signature. + +Full message reference: [CONTRACT_API.md](./CONTRACT_API.md). + +> **⚠️ Phone = Phase 2.** The **v1 wasm handles names only**. Every phone command in this guide (`link_phone`, `release_phone`, `mark_phone_verified`, `resolve_phone`, `phone_record`) exists only in a `--features phone` build — a v1 contract **rejects** them. They are kept here as a Phase 2 reference. Numbers are stored as **bare digits, without `+`** (e.g. `243899123456`). + +--- + +## Table of contents + +1. [Prerequisites](#1-prerequisites) +2. [Environment variables](#2-environment-variables) +3. [Lifecycle at a glance](#3-lifecycle-at-a-glance) +4. [Step 1 — Attach a name (`register_name`)](#4-step-1--attach-a-name-register_name) +5. [Step 2 — Attach a phone (`link_phone`)](#5-step-2--attach-a-phone-link_phone) +6. [Step 3 — Query (the queries)](#6-step-3--query-the-queries) +7. [Step 4 — Transfer a name (`transfer_name`)](#7-step-4--transfer-a-name-transfer_name) +8. [Step 5 — Release an attachment (`release_*`)](#8-step-5--release-an-attachment-release_) +9. [Governance commands](#9-governance-commands) +10. [Summary table](#10-summary-table) +11. [Error codes](#11-error-codes) + +--- + +## 1. Prerequisites + +| Item | Value (testnet) | +| --------------------- | --------------------------------------------- | +| Binary | `safrochaind` (latest version) | +| Chain ID | `safro-testnet-1` | +| RPC | `https://rpc.testnet.safrochain.com` | +| Native denom | `usaf` (6 decimals → 1 SAF = 1 000 000 usaf) | +| Address prefix | `addr_safro` | +| Gas price | `0.025usaf` | +| Name registration fee | **50 SAF** = `50000000usaf` | +| Phone linking fee | **100 SAF** = `100000000usaf` _(Phase 2)_ | + +You need: + +- a **key** in the keyring (`safrochaind keys add my-key` or `keys list`); +- enough `usaf` for gas **and** fees; +- the **contract address** of the deployed SafHandle contract (see [config/testnet.json](../config/testnet.json), `contractAddress` field). + +--- + +## 2. Environment variables + +To keep the commands short, export these variables once per session: + +```bash +export CHAIN_ID="safro-testnet-1" +export NODE="https://rpc.testnet.safrochain.com" +export CONTRACT="addr_safro1..." # deployed SafHandle contract address +export KEY="my-key" # key name in the keyring + +# Common flags for all transactions +export TXFLAGS="--chain-id $CHAIN_ID --node $NODE \ + --gas auto --gas-adjustment 1.4 --gas-prices 0.025usaf \ + -y -o json" + +# Common flags for all queries +export QFLAGS="--node $NODE -o json" +``` + +> Tip: append `| jq` to `query` commands for readable JSON output. + +--- + +## 3. Lifecycle at a glance + +```text + ┌──────────────────────────────────────────────────────────┐ + │ ATTACH (tx) │ + │ register_name (50 SAF) │ link_phone (100 SAF) │ + └───────────────┬───────────────────────┬──────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────────────────────────────┐ + │ LOOKUP (query) │ + │ get_address · resolve_name · resolve_phone │ + │ name_record · phone_record · handles · config │ + └───────────────┬───────────────────────┬──────────────────┘ + │ │ + (optional) transfer_name │ + │ │ + ▼ ▼ + ┌──────────────────────────────────────────────────────────┐ + │ RELEASE (tx) │ + │ release_name │ release_phone │ + └──────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Step 1 — Attach a name (`register_name`) + +Registers a short, human-readable name for the sender's address. + +```bash +safrochaind tx wasm execute $CONTRACT \ + '{"register_name":{"name":"john.saf"}}' \ + --amount 50000000usaf \ + --from $KEY \ + $TXFLAGS +``` + +| Parameter | Details | +| ---------- | --------------------------------------------------------------------------- | +| `name` | Short name, normalized by the contract (see [NAME_RULES.md](./NAME_RULES.md)) | +| `--amount` | Exactly `50000000usaf` (the contract requires the exact amount) | + +**Normalization**: `John` → `john.saf`, the `.saf` extension is appended if absent, +lowercased, label of 3 to 32 characters (`a-z`, `0-9`, `-`). Emails (`@`), stray dots, +and non-ASCII/unicode characters are rejected. + +**Possible errors**: `NameTaken`, `InvalidName`, `EmailNotAllowed`, `ReservedName`, `InsufficientFee`. + +--- + +## 5. Step 2 — Attach a phone (`link_phone`) — _Phase 2_ + +> Available only in a `--features phone` build. A v1 contract rejects this message. + +Links a number in **bare digits (without `+`)** to the sender's address. + +```bash +safrochaind tx wasm execute $CONTRACT \ + '{"link_phone":{"phone":"243899123456"}}' \ + --amount 100000000usaf \ + --from $KEY \ + $TXFLAGS +``` + +| Parameter | Details | +| ---------- | ------------------------------------------------------------- | +| `phone` | Bare digits: country code + number, 8–15 digits, no `+` or leading `0` | +| `--amount` | Exactly `100000000usaf` | + +> The number is stored with `verified: false`. OTP verification is a later step +> (see [`mark_phone_verified`](#9-governance-commands)). Resolution works immediately; +> wallets may show a "not verified" warning. + +**Possible errors**: `PhoneTaken`, `InvalidPhone`, `InsufficientFee`. + +--- + +## 6. Step 3 — Query (the queries) + +All of these commands are **free** and **read-only**. + +### 6.1 `get_address` — universal resolution + +In v1, resolves a **name**. In the `--features phone` build, a **fully numeric** input +(bare digits, without `+`) is detected and resolved against the phone registry. + +```bash +# By name (v1) +safrochaind query wasm contract-state smart $CONTRACT \ + '{"get_address":{"input":"john"}}' $QFLAGS + +# By phone (Phase 2 only — bare digits) +safrochaind query wasm contract-state smart $CONTRACT \ + '{"get_address":{"input":"243899123456"}}' $QFLAGS +``` + +Response (v1): + +```json +{ + "address": "addr_safro1...", + "record_type": "name", + "normalized_key": "john.saf" +} +``` + +The `verified` field exists only in the phone build: for a phone, `record_type` is +`"phone"` and `verified` is `true`/`false`. In v1, a numeric input is a numeric-only +label, therefore reserved → error. + +### 6.2 `resolve_name` — resolve by name only + +```bash +safrochaind query wasm contract-state smart $CONTRACT \ + '{"resolve_name":{"name":"john.saf"}}' $QFLAGS +``` + +### 6.3 `resolve_phone` — resolve by phone only _(Phase 2)_ + +```bash +safrochaind query wasm contract-state smart $CONTRACT \ + '{"resolve_phone":{"phone":"243899123456"}}' $QFLAGS +``` + +### 6.4 `name_record` — full record of a name + +Returns the owner and the registration block height. + +```bash +safrochaind query wasm contract-state smart $CONTRACT \ + '{"name_record":{"name":"john.saf"}}' $QFLAGS +``` + +### 6.5 `phone_record` — full record of a phone _(Phase 2)_ + +Returns the owner and the verification status. + +```bash +safrochaind query wasm contract-state smart $CONTRACT \ + '{"phone_record":{"phone":"243899123456"}}' $QFLAGS +``` + +### 6.6 `handles` — reverse lookup (by address) + +Returns the name held by an address (and the phone in the phone build). **Never fails**: +missing handles come back as `null`. + +```bash +safrochaind query wasm contract-state smart $CONTRACT \ + '{"handles":{"address":"addr_safro1..."}}' $QFLAGS +``` + +Response (v1): + +```json +{ "name": "john.saf" } +``` + +In the phone build, the response also includes `"phone": "243899123456"`. + +### 6.7 `config` — contract configuration + +Current fees, denom, governance admin. + +```bash +safrochaind query wasm contract-state smart $CONTRACT \ + '{"config":{}}' $QFLAGS +``` + +--- + +## 7. Step 4 — Transfer a name (`transfer_name`) + +Transfers ownership of a name to a new address. **Only the current owner** can call it. +No fee (gas only). + +```bash +safrochaind tx wasm execute $CONTRACT \ + '{"transfer_name":{"name":"john.saf","new_owner":"addr_safro1carol..."}}' \ + --from $KEY \ + $TXFLAGS +``` + +After the transfer, only the new owner can re-link, transfer, or release the name. + +--- + +## 8. Step 5 — Release an attachment (`release_*`) + +End of the lifecycle: the name/phone is released. **Only the owner** can call it. +No fee (gas only), and **registration fees are not refunded**. + +### 8.1 `release_name` — release a name + +Burns ownership and returns the name to circulation (re-registrable by anyone). + +```bash +safrochaind tx wasm execute $CONTRACT \ + '{"release_name":{"name":"john.saf"}}' \ + --from $KEY \ + $TXFLAGS +``` + +### 8.2 `release_phone` — unlink a phone _(Phase 2)_ + +Removes the number's link. + +```bash +safrochaind tx wasm execute $CONTRACT \ + '{"release_phone":{"phone":"243899123456"}}' \ + --from $KEY \ + $TXFLAGS +``` + +> Post-release check: re-run `get_address` — you should get a `NotFound` error, and +> `handles` should return `null` for that handle type. + +--- + +## 9. Governance commands + +Restricted to the `governance_admin` address (or the authorized verifier for OTP). + +### 9.1 `update_config` — update fees / wallet + +All fields are optional; omitted ones keep their value. + +```bash +# v1 (name only) +safrochaind tx wasm execute $CONTRACT \ + '{"update_config":{"name_registration_fee_usaf":"50000000","dev_module_wallet":"addr_safro1..."}}' \ + --from $KEY \ + $TXFLAGS +``` + +> `phone_link_fee_usaf` is accepted only in the `--features phone` build. + +### 9.2 `mark_phone_verified` — mark a phone verified _(Phase 2)_ + +After off-chain OTP proof. Callable by the authorized verifier contract or governance. + +```bash +safrochaind tx wasm execute $CONTRACT \ + '{"mark_phone_verified":{"phone":"243899123456"}}' \ + --from $KEY \ + $TXFLAGS +``` + +--- + +## 10. Summary table + +| Phase | Message | Type | Fee | Who can call | Availability | +| ---------- | --------------------- | ----- | -------- | ----------------------- | ------------ | +| Attach | `register_name` | tx | 50 SAF | Anyone | v1 | +| Attach | `link_phone` | tx | 100 SAF | Anyone | Phase 2 | +| Query | `get_address` | query | — | Anyone | v1 | +| Query | `resolve_name` | query | — | Anyone | v1 | +| Query | `resolve_phone` | query | — | Anyone | Phase 2 | +| Query | `name_record` | query | — | Anyone | v1 | +| Query | `phone_record` | query | — | Anyone | Phase 2 | +| Query | `handles` | query | — | Anyone | v1 | +| Query | `config` | query | — | Anyone | v1 | +| Manage | `transfer_name` | tx | gas only | Owner | v1 | +| Release | `release_name` | tx | gas only | Owner | v1 | +| Release | `release_phone` | tx | gas only | Owner | Phase 2 | +| Governance | `update_config` | tx | gas only | `governance_admin` | v1 | +| Governance | `mark_phone_verified` | tx | gas only | Verifier / governance | Phase 2 | + +--- + +## 11. Error codes + +| Error | Cause | Applies to | +| ----------------- | --------------------------------------------------------------- | ----------------------------------------- | +| `NameTaken` | Name already registered | `register_name` | +| `InvalidName` | Invalid character set / length | `register_name` | +| `EmailNotAllowed` | Input containing `@` (email) | `register_name` | +| `ReservedName` | Reserved name (`safrochain`, `admin`, …) or numeric-only | `register_name` | +| `PhoneTaken` | Phone already linked _(Phase 2)_ | `link_phone` | +| `InvalidPhone` | Invalid bare digits (`+`, leading `0`, length) _(Phase 2)_ | `link_phone` | +| `InsufficientFee` | `--amount` differs from the exact fee | `register_name`, `link_phone` | +| `NotFound` | Handle does not exist | resolvers, records | +| `Unauthorized` | Caller is not owner / not admin | `transfer_name`, `release_*`, governance | + +--- + +## See also + +- [CONTRACT_API.md](./CONTRACT_API.md) — full message reference +- [NAME_RULES.md](./NAME_RULES.md) — normalization and reserved names +- [PHONE_LINKING.md](./PHONE_LINKING.md) — phone linking details +- [DEPLOYMENT.md](./DEPLOYMENT.md) — deployment and retrieving the contract address +- [HOW_IT_WORKS.md](./HOW_IT_WORKS.md) — overview of how it works diff --git a/docs/CONTRACT_API.md b/docs/CONTRACT_API.md index 63271bb..8cdb843 100644 --- a/docs/CONTRACT_API.md +++ b/docs/CONTRACT_API.md @@ -2,13 +2,14 @@ Message definitions for the SafHandle CosmWasm contract. Types use `cosmwasm-schema` (`#[cw_serde]`). Amounts are in `usaf` (6 decimal places). +> **v1 vs. Phase 2.** The shipped v1 wasm registers **short names only**. Every phone message/query/field below (`LinkPhone`, `ReleasePhone`, `MarkPhoneVerified`, `ResolvePhone`, `PhoneRecord`, `phone_link_fee_usaf`, `verified`, `Handles.phone`) is **compiled out** unless the crate is built with `--features phone`. Because `#[cw_serde]` denies unknown fields, sending a phone field to a v1 contract is rejected. Fields marked _(phone feature)_ appear only in the phone build. + ## InstantiateMsg ```json { "native_denom": "usaf", "name_registration_fee_usaf": "50000000", - "phone_link_fee_usaf": "100000000", "dev_module_wallet": "addr_safro1...", "governance_admin": "addr_safro1..." } @@ -18,7 +19,7 @@ Message definitions for the SafHandle CosmWasm contract. Types use `cosmwasm-sch | --- | --- | --- | | `native_denom` | `String` | Fee denom, typically `usaf` | | `name_registration_fee_usaf` | `Uint128` | Default 50 SAF | -| `phone_link_fee_usaf` | `Uint128` | Default 100 SAF | +| `phone_link_fee_usaf` | `Uint128` | _(phone feature)_ Default 100 SAF — omit in v1 | | `dev_module_wallet` | `String` | Recipient of all registration fees | | `governance_admin` | `String` | Address authorized for config updates (governance module account) | @@ -42,25 +43,27 @@ Register a short name for the sender. **Funds:** Exactly `name_registration_fee_usaf` of `native_denom`. -**Errors:** `NameTaken`, `InvalidName`, `ReservedName`, `InsufficientFee`. +**Errors:** `NameTaken`, `InvalidName`, `EmailNotAllowed`, `ReservedName`, `InsufficientFee`. --- ### LinkPhone -Link an E.164 phone number to the sender's address. +> **⚠️ Phase 2 only — not in the v1 build.** `LinkPhone`, `ReleasePhone`, `MarkPhoneVerified`, `ResolvePhone`, and `PhoneRecord` are compiled out unless the crate is built with `--features phone`. The v1 wasm does not expose them. See [PHONE_LINKING.md](./PHONE_LINKING.md). + +Link a phone number to the sender's address. Phones are stored as **bare digits — no leading `+`** (country code + subscriber, e.g. `243899123456`). A `+` or any separator is rejected. ```json { "link_phone": { - "phone": "+243899123456" + "phone": "243899123456" } } ``` | Field | Type | Required | Description | | --- | --- | --- | --- | -| `phone` | `String` | Yes | E.164 format (`+` prefix) | +| `phone` | `String` | Yes | Bare digits, 8–15 long, no leading `+` or `0` | **Funds:** Exactly `phone_link_fee_usaf` of `native_denom`. @@ -99,14 +102,14 @@ Burn ownership and free the name for re-registration. Only the owner may call. --- -### ReleasePhone +### ReleasePhone _(phone feature)_ Remove a phone link. Only the owner may call. ```json { "release_phone": { - "phone": "+243899123456" + "phone": "243899123456" } } ``` @@ -121,24 +124,23 @@ Update fee parameters and wallet routing. Callable only by `governance_admin`. { "update_config": { "name_registration_fee_usaf": "50000000", - "phone_link_fee_usaf": "100000000", "dev_module_wallet": "addr_safro1..." } } ``` -All fields are optional; omitted fields retain current values. +All fields are optional; omitted fields retain current values. `phone_link_fee_usaf` is accepted only in the phone build _(phone feature)_. --- -### MarkPhoneVerified (Phase 2) +### MarkPhoneVerified _(phone feature)_ Mark a phone as verified after off-chain OTP proof. Callable only by authorized verifier contract or governance. ```json { "mark_phone_verified": { - "phone": "+243899123456" + "phone": "243899123456" } } ``` @@ -157,20 +159,19 @@ Resolve a short name or phone number. } ``` -**Response:** +**Response (v1):** ```json { "address": "addr_safro1...", "record_type": "name", - "normalized_key": "john.saf", - "verified": null + "normalized_key": "john.saf" } ``` -For phone records, `record_type` is `"phone"` and `verified` is `true` or `false`. +The `verified` field exists only in the phone build _(phone feature)_. There, an all-digit input auto-resolves against the phone registry: `record_type` is `"phone"` and `verified` is `true` or `false`. In v1 an all-digit input is a numeric-only label, which is reserved → `InvalidName` / `ReservedName`. -**Errors:** `NotFound`. +**Errors:** `NotFound`, `InvalidName`, `EmailNotAllowed`. --- @@ -188,14 +189,14 @@ Lookup by normalized name only. --- -### ResolvePhone +### ResolvePhone _(phone feature)_ -Lookup by E.164 phone. +Lookup by bare-digit phone (no `+`). ```json { "resolve_phone": { - "phone": "+243899123456" + "phone": "243899123456" } } ``` @@ -228,26 +229,56 @@ Return full record for a name including owner and registration height. --- -### PhoneRecord +### PhoneRecord _(phone feature)_ Return full record for a phone including owner and verification status. ```json { "phone_record": { - "phone": "+243899123456" + "phone": "243899123456" } } ``` +--- + +### Handles + +Reverse lookup for an address. Backed by the owner indexes (`OWNER_NAMES`, plus +`OWNER_PHONES` in the phone build), so it is a direct key read — never a scan. +Unlike the forward resolvers, this **never errors** on a missing record; absent +handles come back as `null`. In v1 the response has only a `name` field; the +`phone` field is present only _(phone feature)_. + +```json +{ + "handles": { + "address": "addr_safro1..." + } +} +``` + +**Response (phone build shown; v1 omits `phone`):** + +```json +{ + "name": "john.saf", + "phone": "243899123456" +} +``` + +Either field is `null` when the address owns no record of that kind. + ## Events (attributes) | Action | Key attributes | | --- | --- | | `register_name` | `name`, `owner`, `fee_usaf` | -| `link_phone` | `phone`, `owner`, `fee_usaf`, `verified=false` | | `transfer_name` | `name`, `from`, `to` | +| `release_name` | `name`, `owner` | | `update_config` | `updated_by` | +| `link_phone` _(phone feature)_ | `phone`, `owner`, `fee_usaf`, `verified=false` | ## SDK mapping diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 3ef9089..f2d3e06 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,6 @@ # Deployment Guide -Target deployment workflow for the SafHandle CosmWasm contract on Safrochain testnet and mainnet. **Shell scripts are documented here but not yet implemented** — this phase is specification only. +Deployment workflow for the SafHandle CosmWasm contract on Safrochain testnet and mainnet. A working testnet script lives at [scripts/deploy-testnet.sh](../scripts/deploy-testnet.sh) (store + instantiate). The `deployment/` layout below is the target structure for the fuller mainnet tooling. ## Prerequisites @@ -51,9 +51,10 @@ cp deployment/.env.testnet.example deployment/.env.testnet ## Build WASM (future) ```bash -# Optimizer (recommended) +# Optimizer (recommended). Use 0.17.0+ (Rust 1.86): older tags ship Rust +# 1.78/1.81 which cannot build cosmwasm-std 2.3.x's edition2024 dependencies. docker run --rm -v "$(pwd)":/code \ - cosmwasm/optimizer:0.16.0 + cosmwasm/optimizer:0.17.0 # Output: artifacts/safhandle.wasm + artifacts/checksums.txt ``` @@ -85,12 +86,13 @@ Record `code_id` in `deployment/state/testnet.json`. { "native_denom": "usaf", "name_registration_fee_usaf": "50000000", - "phone_link_fee_usaf": "100000000", "dev_module_wallet": "addr_safro1...", "governance_admin": "addr_safro1..." } ``` +> v1 instantiate takes **no** `phone_link_fee_usaf` — `#[cw_serde]` denies unknown fields, so passing it fails. Add it back only when deploying a wasm built with `--features phone`. + ```bash safrochaind tx wasm instantiate '' \ --label "safhandle-registry-v1" \ diff --git a/docs/FEES_AND_GOVERNANCE.md b/docs/FEES_AND_GOVERNANCE.md index 154e7ab..cba0871 100644 --- a/docs/FEES_AND_GOVERNANCE.md +++ b/docs/FEES_AND_GOVERNANCE.md @@ -4,12 +4,12 @@ SafHandle charges fixed fees for registration actions. Fees are paid in `usaf` ( ## Default fees -| Action | Display | Minimal units (`usaf`) | Config key | -| --- | --- | --- | --- | -| Register short name | **50 SAF** | `50000000` (`50_000_000`) | `name_registration_fee_usaf` | -| Link phone number | **100 SAF** | `100000000` (`100_000_000`) | `phone_link_fee_usaf` | +| Action | Display | Minimal units (`usaf`) | Config key | Availability | +| --- | --- | --- | --- | --- | +| Register short name | **50 SAF** | `50000000` (`50_000_000`) | `name_registration_fee_usaf` | v1 | +| Link phone number | **100 SAF** | `100000000` (`100_000_000`) | `phone_link_fee_usaf` | Phase 2 (`--features phone`) | -These defaults are set at `instantiate` and mirrored in [config/testnet.json](../config/testnet.json) and [config/mainnet.json](../config/mainnet.json). +The name fee is set at `instantiate`; `phone_link_fee_usaf` applies only to the phone build. Defaults are mirrored in [config/testnet.json](../config/testnet.json) and [config/mainnet.json](../config/mainnet.json). ## Fee collection @@ -38,9 +38,9 @@ Governance (via `governance_admin` address) may update: | Parameter | Type | Description | | --- | --- | --- | | `name_registration_fee_usaf` | `Uint128` | Short name fee | -| `phone_link_fee_usaf` | `Uint128` | Phone link fee | +| `phone_link_fee_usaf` | `Uint128` | Phone link fee — _phone build only_ | | `dev_module_wallet` | `String` | Fee recipient | -| `reserved_names` | `Vec` | Additional blocked names | +| `reserved_names` | `Vec` | Additional blocked names (extended via `MigrateMsg.add_reserved_names`) | ### Governance flow diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md index d2e1057..047ed86 100644 --- a/docs/HOW_IT_WORKS.md +++ b/docs/HOW_IT_WORKS.md @@ -1,6 +1,6 @@ # How SafHandle Works -SafHandle is a **name and phone registry** on Safrochain. Users register human-readable identifiers that resolve to `addr_safro` wallet addresses. Any wallet, dApp, or block explorer can query the registry without trusting a centralized server. +SafHandle is a **short-name registry** on Safrochain (phone linking is a Phase 2 add-on). Users register human-readable identifiers that resolve to `addr_safro` wallet addresses. Any wallet, dApp, or block explorer can query the registry without trusting a centralized server. ## The problem @@ -12,7 +12,7 @@ Cosmos-style addresses like `addr_safro1abc...xyz` are: ## The solution -Register a short name or phone number on-chain. Send SAF to `john.saf` instead of a 50+ character address. +Register a short name on-chain. Send SAF to `john.saf` instead of a 50+ character address. (Phone-number handles arrive in Phase 2.) ## Step-by-step flow @@ -33,13 +33,13 @@ Bob wants to pay Alice. He types `john` in his wallet send form. 2. The contract normalizes `john` to `john.saf`, looks up the record, and returns `addr_safro1alice...`. 3. Bob confirms and sends SAF to the resolved address. -### 3. Link a phone number +### 3. Link a phone number _(Phase 2 — not in v1)_ -Alice wants recipients to send to `+243899123456`. +Phone linking ships behind the `phone` Cargo feature and is compiled out of the v1 wasm. When enabled, Alice wants recipients to send to `243899123456`. -1. Alice submits `link_phone` with E.164 number `+243899123456`. +1. Alice submits `link_phone` with the bare-digit number `243899123456` (no `+`). 2. She attaches **100 SAF** (100,000,000 `usaf`) as the phone link fee. -3. The contract stores the link with `verified: false` (verification is Phase 2). +3. The contract stores the link with `verified: false` (verification is a later phase). 4. Resolution works immediately; unverified phones may show a warning in wallets. ### 4. Transfer ownership @@ -55,9 +55,9 @@ Alice sells or gifts `john.saf` to Carol. | --- | --- | --- | | `john` | `john.saf` | Owner's `addr_safro` address | | `john.saf` | `john.saf` | Owner's `addr_safro` address | -| `+243899123456` | `+243899123456` | Linked `addr_safro` address | +| `243899123456` _(Phase 2)_ | `243899123456` | Linked `addr_safro` address | -Phone inputs must be valid **E.164** format (leading `+`, country code, subscriber number). +In the phone build, phone inputs are **bare digits** (country code + subscriber, no leading `+` or `0`). See [NAME_RULES.md](./NAME_RULES.md) for the full name validation rules (lowercase, alphanumeric + hyphen, no emails/unicode). ## Who uses SafHandle diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 7941c53..a33c8ca 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -21,27 +21,32 @@ At instantiate, set `--admin` to the **governance module account** or a dedicate **Prefer migration** to preserve the canonical contract address used by wallets and the SDK. -## MigrateMsg (specification) +## MigrateMsg + +The actual migrate payload has a single optional field: ```json { - "migrate": { - "add_reserved_names": ["newbrand"], - "set_verifier": { - "address": "addr_safro1...", - "authorized": true - } - } + "add_reserved_names": ["newbrand"] +} +``` + +```rust +pub struct MigrateMsg { + /// Additive-only: extend the reserved name list on upgrade. + pub add_reserved_names: Option>, } ``` -Migration messages should be **additive only** — never delete user registrations. +`migrate` also bumps the stored `cw2` contract version. In the phone build it +additionally re-keys any legacy `+243…` phone entries to bare digits `243…` +(idempotent). Migration is **additive only** — it never deletes user registrations. ## Migration procedure ```bash # 1. Build new WASM -docker run --rm -v "$(pwd)":/code cosmwasm/optimizer:0.16.0 +docker run --rm -v "$(pwd)":/code cosmwasm/optimizer:0.17.0 # 2. Store new code safrochaind tx wasm store artifacts/safhandle.wasm ... @@ -55,8 +60,8 @@ safrochaind tx wasm migrate '' \ ## State compatibility rules -1. **Never change Map key formats** for `NAMES` or `PHONES`. -2. New struct fields use `Option` with `None` defaults in migrate handler. +1. **Do not change Map key formats** for `NAMES` (or `PHONES` in the phone build) without a re-keying pass in the `migrate` handler — as done for the legacy `+`→bare-digit phone migration. +2. New struct fields use `Option` with `None` defaults in the migrate handler. 3. Run integration tests against migrated state before mainnet. 4. Publish migration notes in `CHANGELOG.md` and GitHub Release. @@ -81,8 +86,16 @@ Mainnet migrations should go through governance: ## Versioning +Contract version is tracked via `cw2` (`set_contract_version`) as `safrochain:safhandle`. + | Contract version | Code ID (testnet) | Code ID (mainnet) | | --- | --- | --- | -| v0.0.0-spec | — | — (not deployed) | +| v1.0.0 (names only, v1) | see [config/testnet.json](../config/testnet.json) | — (not deployed) | Update this table after each deployment. + +> **v1.0.0 is a fresh deployment, not a migrate.** The pre-v1 build stored a +> `Config` with a `phone_link_fee_usaf` field. Because `#[cw_serde]` denies +> unknown fields, migrating an old instance to the v1 (name-only) code would +> fail to deserialize the stored `Config`. Store a new code ID and instantiate +> a new contract instead of migrating in place. diff --git a/docs/NAME_RULES.md b/docs/NAME_RULES.md index 7f0cdcd..6e4ad22 100644 --- a/docs/NAME_RULES.md +++ b/docs/NAME_RULES.md @@ -10,16 +10,22 @@ Rules governing short name registration, normalization, and reserved namespaces. | `john.saf` | `john.saf` | Yes | | `JOHN` | `john.saf` | Yes (lowercased) | | `John.SAF` | `john.saf` | Yes (lowercased) | -| `john.safrochain.com` | — | No | -| `+243899123456` | — | No (use phone flow) | +| `john.safrochain.com` | — | No (stray dot) | +| `john@gmail.com` | — | No (email) | +| `café` | — | No (non-ASCII) | +| `+243899123456` | — | No (phone linking is Phase 2, disabled in v1) | ## Normalization algorithm 1. Trim leading and trailing whitespace. -2. Convert to lowercase ASCII. -3. If the name does not end with `.saf`, append `.saf`. -4. Reject if total length exceeds **64 characters** (including `.saf`). -5. Reject if label before `.saf` is empty. +2. Reject if empty. +3. Reject if the input contains `@` (email address) → dedicated `EmailNotAllowed` error. +4. Reject if the input contains any non-ASCII byte (blocks unicode/homoglyph spoofing). +5. Convert to lowercase ASCII. +6. If the name does not end with `.saf`, append `.saf`. +7. Reject if any dot remains in the label after stripping the `.saf` suffix (blocks domain- and email-shaped input like `john.com`). +8. Validate the label character set and length (see below). +9. Reject if total length exceeds **64 characters** (including `.saf`). ## Character set @@ -29,7 +35,9 @@ Labels (the part before `.saf`) may contain: - Digits `0-9` - Hyphens `-` (not at start or end of label) -**Not allowed:** underscores, spaces, unicode, consecutive hyphens, leading/trailing hyphens. +**Not allowed:** underscores, spaces, dots (outside the `.saf` suffix), the `@` sign, unicode / non-ASCII, consecutive hyphens, leading/trailing hyphens. + +The no-consecutive-hyphen rule also blocks the IDN/punycode ACE prefix `xn--`, so a stored label can never render as a unicode lookalike. ## Length limits @@ -63,14 +71,15 @@ Names are unique globally. First successful `register_name` wins. No grace perio ## Phone numbers are not names -Inputs starting with `+` followed by digits are routed to the phone registry, not the name registry. See [PHONE_LINKING.md](./PHONE_LINKING.md). +Phone linking is a **Phase 2** feature and is **compiled out of the v1 build** (Cargo feature `phone`, off by default). In v1 there is no phone registry: an all-digit input is simply a numeric-only label, which is reserved and therefore rejected. When the `phone` feature is enabled, an all-digit input is auto-routed to the phone registry instead. See [PHONE_LINKING.md](./PHONE_LINKING.md). ## Validation errors | Error | Cause | | --- | --- | -| `InvalidName` | Fails character set or length rules | -| `ReservedName` | Matches reserved list | +| `InvalidName` | Fails character set, dot, non-ASCII, or length rules | +| `EmailNotAllowed` | Input contains `@` (email address) | +| `ReservedName` | Matches reserved list, or is numeric-only | | `NameTaken` | Normalized key already registered | ## Examples @@ -81,4 +90,7 @@ Input: "alice" → "alice.saf" ✓ Input: "a" → rejected ✗ (too short) Input: "safrochain" → rejected ✗ (reserved) Input: "my-name" → "my-name.saf" ✓ +Input: "john@x.com" → rejected ✗ (email) +Input: "john.com" → rejected ✗ (stray dot) +Input: "café" → rejected ✗ (non-ASCII) ``` diff --git a/docs/PHONE_LINKING.md b/docs/PHONE_LINKING.md index a835b18..a0d96df 100644 --- a/docs/PHONE_LINKING.md +++ b/docs/PHONE_LINKING.md @@ -1,27 +1,29 @@ # Phone Linking -SafHandle supports linking **E.164 phone numbers** to `addr_safro` wallet addresses. Phone links are stored **on-chain from Phase 1**. Mobile number **verification is deferred to Phase 2** via an off-chain service. +> **⚠️ Not in v1.** Phone linking is deferred to **Phase 2** and is **compiled out of the v1 build**. It lives behind the Cargo feature `phone` (off by default); the shipped v1 wasm exposes no `LinkPhone` / `ReleasePhone` / `MarkPhoneVerified` messages, no `ResolvePhone` / `PhoneRecord` queries, and no phone state. Build with `--features phone` to enable the flows described below. This document is the Phase 2 design reference. + +When enabled, SafHandle links phone numbers to `addr_safro` wallet addresses. Numbers are stored as **bare digits (no leading `+`)**. Mobile-number **verification** is layered on later via an off-chain service. ## Overview -| Phase | Capability | +| Stage | Capability | | --- | --- | -| **Phase 1** (current spec) | Register, resolve, transfer, release phone links on-chain | -| **Phase 2** | Off-chain OTP/SMS verification; on-chain `verified` flag | +| **Phone feature** (`--features phone`) | Register, resolve, transfer, release phone links on-chain (`verified: false`) | +| **Verification** (later) | Off-chain OTP/SMS verification; on-chain `verified` flag via `mark_phone_verified` | -## E.164 format +## Phone number format -All phone numbers must be normalized to **E.164**: +Numbers are stored as **bare digits** — the contract rejects `+`, spaces, and separators: | Rule | Example | | --- | --- | -| Leading `+` required | `+243899123456` | -| Country code + subscriber number | `+243` (DRC) + `899123456` | -| No spaces, dashes, or parentheses | `+243899123456` not `+243 899 123 456` | +| Digits only, no leading `+` | `243899123456` (not `+243899123456`) | +| Country code + subscriber number | `243` (DRC) + `899123456` | +| 8–15 digits, no leading `0` | `243899123456` | -Invalid formats return `InvalidPhone` at registration. +Invalid formats return `InvalidPhone` at registration. (A legacy `+`-prefixed key is re-keyed to bare digits by `migrate`.) -## Registration flow (Phase 1) +## Registration flow ```mermaid sequenceDiagram @@ -30,16 +32,16 @@ sequenceDiagram participant Contract participant DevWallet - User->>Wallet: Link +243899123456 + User->>Wallet: Link 243899123456 Wallet->>Contract: link_phone + 100 SAF Contract->>DevWallet: Transfer fee Contract->>Contract: Store phone, verified=false Contract-->>Wallet: Success ``` -1. User submits `link_phone` with E.164 number. +1. User submits `link_phone` with a bare-digit number. 2. User pays **100 SAF** fee. -3. Contract stores `+243899123456 → addr_safro` with `verified: false`. +3. Contract stores `243899123456 → addr_safro` with `verified: false`. 4. Resolution returns the address immediately. ## Resolution behavior @@ -77,9 +79,9 @@ sequenceDiagram - Send OTP via SMS or carrier API - Validate user-submitted code - Submit authorized `mark_phone_verified` message to contract -- Never store phone numbers on-chain beyond the E.164 key already registered +- Never store phone numbers on-chain beyond the bare-digit key already registered -### Authorization model (Phase 2) +### Authorization model Only addresses on an **allowlist** (set via governance) may call `mark_phone_verified`: @@ -90,7 +92,7 @@ Only addresses on an **allowlist** (set via governance) may call `mark_phone_ver | Data | On-chain | Off-chain | | --- | --- | --- | -| E.164 phone number | Yes (as registry key) | Verification logs (minimized) | +| Phone number (bare digits) | Yes (as registry key) | Verification logs (minimized) | | OTP codes | Never | Verifier service (ephemeral) | | Wallet address | Yes | — | @@ -98,11 +100,11 @@ Phone numbers on a public chain are visible to anyone. Users should be informed ## One phone per address -Each address may link **one** phone number in Phase 1. Re-linking requires `release_phone` first. +Each address may link **one** phone number. Re-linking requires `release_phone` first. ## Collision policy -Each E.164 number is globally unique in the registry. First successful `link_phone` wins. +Each phone number is globally unique in the registry. First successful `link_phone` wins. ## SDK integration diff --git a/docs/README.md b/docs/README.md index 2cfef59..a768056 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ Welcome to the SafHandle CosmWasm registry documentation. Read in the order belo | 3 | [CONTRACT_API.md](./CONTRACT_API.md) | Execute and query message reference | | 4 | [NAME_RULES.md](./NAME_RULES.md) | Short name formats and validation | | 5 | [FEES_AND_GOVERNANCE.md](./FEES_AND_GOVERNANCE.md) | Fees, dev wallet routing, governance params | -| 6 | [PHONE_LINKING.md](./PHONE_LINKING.md) | Phone registration and verification phases | +| 6 | [PHONE_LINKING.md](./PHONE_LINKING.md) | Phone registration and verification — Phase 2, `--features phone` | | 7 | [ANTI_SQUATTING.md](./ANTI_SQUATTING.md) | Protections against name squatting | | 8 | [STATE_SCHEMA.md](./STATE_SCHEMA.md) | Contract storage layout | | 9 | [DEPLOYMENT.md](./DEPLOYMENT.md) | Testnet and mainnet deployment | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0250bc8..ff51dcb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,51 +2,51 @@ Implementation phases for SafHandle contract and ecosystem. -## Phase 1 — Specification (current) +## Phase 1 — Specification -**Status: In progress** +**Status: Done** - [x] Open-source repository scaffold - [x] Contract API specification - [x] Fee and governance documentation -- [x] Phone linking spec (on-chain, verification deferred) - [x] Network configuration examples - [x] CI for documentation and repo hygiene ## Phase 2 — Contract implementation -**Target: Q3 2026** +**Status: Done** -- [ ] CosmWasm contract (`contracts/safhandle/`) -- [ ] Unit tests and `cw-multi-test` integration tests -- [ ] WASM optimizer build pipeline -- [ ] Deployment scripts (`deployment/`) -- [ ] Testnet deployment and address publication -- [ ] Contract audit (recommended before mainnet) +- [x] CosmWasm contract (`contracts/safhandle/`) — name registry +- [x] Unit tests and `cw-multi-test` integration tests +- [x] Hardened name validation (lowercase, alphanumeric + hyphen, anti-email, anti-unicode) +- [x] WASM optimizer build pipeline +- [x] Deployment script (`scripts/deploy-testnet.sh`) +- [ ] Third-party contract audit (recommended before mainnet) ## Phase 3 — Testnet launch -**Target: Q3–Q4 2026** +**Status: In progress** -- [ ] Deploy to `safro-testnet-1` -- [ ] Update [config/testnet.json](../config/testnet.json) with contract address +- [x] Deploy to `safro-testnet-1` +- [x] Publish contract address in [config/testnet.json](../config/testnet.json) - [ ] SDK testnet integration ([safhandle-sdk](https://github.com/Safrochain-Org/safhandle-sdk)) - [ ] Wallet partner integrations (beta) - [ ] Community testing and feedback ## Phase 4 — Mainnet launch -**Target: Q4 2026** +**Status: Planned** - [ ] Governance proposal for mainnet deployment - [ ] Deploy to `safrochain-1` - [ ] Publish WASM checksums and code ID - [ ] Safroutine Hub and ecosystem dApp integration -## Phase 5 — Phone verification +## Phase 5 — Phone linking & verification -**Target: 2027** +**Status: Deferred (built behind the `phone` Cargo feature, off in v1)** +- [ ] Ship the `phone` feature in a deployed wasm (`LinkPhone` / `ReleasePhone`, phone registry, resolution) - [ ] Off-chain OTP verification service - [ ] `mark_phone_verified` authorization model - [ ] SDK verification flow diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index 06990cd..fb256bb 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -7,10 +7,10 @@ Threat model and mitigations for the SafHandle on-chain registry. | Asset | Sensitivity | | --- | --- | | Name → address mappings | High (wrong mapping = lost funds) | -| Phone → address mappings | High | +| Phone → address mappings _(Phase 2)_ | High | | Fee routing configuration | Medium (misrouting diverts revenue) | | Reserved name list | Low | -| User phone numbers (on-chain) | Medium (public visibility) | +| User phone numbers (on-chain) _(Phase 2)_ | Medium (public visibility) | ## Threat actors @@ -28,8 +28,9 @@ Threat model and mitigations for the SafHandle on-chain registry. | Threat | Mitigation | | --- | --- | -| Fee bypass | Strict `info.funds` validation | -| Name squatting | 50/100 SAF fees, min length, reserved list | +| Fee bypass | Strict `info.funds` validation (exact amount, single denom) | +| Name squatting | Registration fee, min length, reserved + numeric-only blocks | +| Homograph / email spoofing | Lowercase-ASCII-only charset; `@` (email), stray dots, non-ASCII/unicode, and punycode `xn--` all rejected | | Double registration | Map uniqueness checks | | Invalid addresses | Bech32 validation on `addr_safro` prefix | @@ -48,7 +49,7 @@ Threat model and mitigations for the SafHandle on-chain registry. | Dev wallet redirect | `UpdateConfig` only via `governance_admin` | | Forced name transfer | Not implemented — owner-only transfer | -### Phone privacy (Phase 1) +### Phone privacy (Phase 2) | Threat | Mitigation | | --- | --- | diff --git a/docs/STATE_SCHEMA.md b/docs/STATE_SCHEMA.md index be4a347..662d113 100644 --- a/docs/STATE_SCHEMA.md +++ b/docs/STATE_SCHEMA.md @@ -2,15 +2,20 @@ Storage layout for the SafHandle CosmWasm contract. Uses `cw-storage-plus` primitives. +> **v1 vs. phone build.** `PHONES`, `OWNER_PHONES`, `VERIFIERS`, `PhoneRecord`, and `Config.phone_link_fee_usaf` exist only when built with `--features phone`. The v1 wasm stores names only. Phone keys are **bare digits, no leading `+`**. + ## Overview ```text +# v1 (names only) CONFIG Item -NAMES Map key: normalized name (e.g. "john.saf") -PHONES Map key: E.164 phone (e.g. "+243899123456") -OWNER_NAMES Map key: owner address → normalized name -OWNER_PHONES Map key: owner address → E.164 phone -VERIFIERS Map key: verifier address (Phase 2) +NAMES Map<&str, NameRecord> key: normalized name (e.g. "john.saf") +OWNER_NAMES Map<&str, String> key: owner address → normalized name + +# phone feature (--features phone) adds: +PHONES Map<&str, PhoneRecord> key: bare-digit phone (e.g. "243899123456") +OWNER_PHONES Map<&str, String> key: owner address → bare-digit phone +VERIFIERS Map<&str, bool> key: verifier address ``` ## Config (Item) @@ -19,6 +24,7 @@ VERIFIERS Map key: verifier address (Phase 2) pub struct Config { pub native_denom: String, pub name_registration_fee_usaf: Uint128, + #[cfg(feature = "phone")] // phone build only pub phone_link_fee_usaf: Uint128, pub dev_module_wallet: Addr, pub governance_admin: Addr, @@ -45,7 +51,7 @@ pub struct NameRecord { | `NAMES` | `"names"` | | Map key | Normalized name, e.g. `"john.saf"` | -## PhoneRecord (Map) +## PhoneRecord (Map) — _phone feature only_ ```rust pub struct PhoneRecord { @@ -60,18 +66,18 @@ pub struct PhoneRecord { | Key | Storage key string | | --- | --- | | `PHONES` | `"phones"` | -| Map key | E.164 string, e.g. `"+243899123456"` | +| Map key | Bare digits, e.g. `"243899123456"` (no `+`) | ## Owner reverse indexes -Enable "one name per address" and "one phone per address" enforcement. +Enable "one name per address" (and "one phone per address" in the phone build) enforcement. -| Map | Key | Value | -| --- | --- | --- | -| `OWNER_NAMES` | `addr_safro1...` | `"john.saf"` | -| `OWNER_PHONES` | `addr_safro1...` | `"+243899123456"` | +| Map | Key | Value | Availability | +| --- | --- | --- | --- | +| `OWNER_NAMES` | `addr_safro1...` | `"john.saf"` | v1 | +| `OWNER_PHONES` | `addr_safro1...` | `"243899123456"` | phone feature | -## Verifiers (Phase 2) +## Verifiers — _phone feature only_ ```rust // VERIFIERS: Map<&str, bool> @@ -88,9 +94,9 @@ Enable "one name per address" and "one phone per address" enforcement. 3. Insert `NAMES[name] = NameRecord { owner: sender, ... }` 4. Insert `OWNER_NAMES[sender] = name` -### link_phone +### link_phone — _phone feature only_ -1. Validate phone, check `PHONES` does not contain key +1. Validate phone (bare digits, no `+`), check `PHONES` does not contain key 2. Check `OWNER_PHONES` does not have entry for sender 3. Insert `PHONES[phone] = PhoneRecord { owner: sender, verified: false, ... }` 4. Insert `OWNER_PHONES[sender] = phone` diff --git a/schema/raw/execute.json b/schema/raw/execute.json new file mode 100644 index 0000000..8e6f455 --- /dev/null +++ b/schema/raw/execute.json @@ -0,0 +1,114 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Register a short name for the sender. Funds: exactly the name fee.", + "type": "object", + "required": [ + "register_name" + ], + "properties": { + "register_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Transfer name ownership. Owner-only.", + "type": "object", + "required": [ + "transfer_name" + ], + "properties": { + "transfer_name": { + "type": "object", + "required": [ + "name", + "new_owner" + ], + "properties": { + "name": { + "type": "string" + }, + "new_owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Release a name back to the pool. Owner-only.", + "type": "object", + "required": [ + "release_name" + ], + "properties": { + "release_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Governance-only partial config update.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "dev_module_wallet": { + "type": [ + "string", + "null" + ] + }, + "name_registration_fee_usaf": { + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "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/schema/raw/instantiate.json b/schema/raw/instantiate.json new file mode 100644 index 0000000..2f969ed --- /dev/null +++ b/schema/raw/instantiate.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "dev_module_wallet", + "governance_admin", + "name_registration_fee_usaf", + "native_denom" + ], + "properties": { + "dev_module_wallet": { + "type": "string" + }, + "governance_admin": { + "type": "string" + }, + "name_registration_fee_usaf": { + "$ref": "#/definitions/Uint128" + }, + "native_denom": { + "type": "string" + } + }, + "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/schema/raw/migrate.json b/schema/raw/migrate.json new file mode 100644 index 0000000..f0a99f0 --- /dev/null +++ b/schema/raw/migrate.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "properties": { + "add_reserved_names": { + "description": "Additive-only: extend the reserved name list on upgrade.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false +} diff --git a/schema/raw/query.json b/schema/raw/query.json new file mode 100644 index 0000000..ce1864c --- /dev/null +++ b/schema/raw/query.json @@ -0,0 +1,105 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Resolve an input to an address. With the `phone` feature enabled, an all-digit input is auto-detected and resolved against the phone registry.", + "type": "object", + "required": [ + "get_address" + ], + "properties": { + "get_address": { + "type": "object", + "required": [ + "input" + ], + "properties": { + "input": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "resolve_name" + ], + "properties": { + "resolve_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name_record" + ], + "properties": { + "name_record": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reverse lookup: the name (and phone, when enabled) owned by an address.", + "type": "object", + "required": [ + "handles" + ], + "properties": { + "handles": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] +} diff --git a/schema/raw/response_to_config.json b/schema/raw/response_to_config.json new file mode 100644 index 0000000..5c60572 --- /dev/null +++ b/schema/raw/response_to_config.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "description": "Governance-updatable contract configuration. See `docs/STATE_SCHEMA.md`.", + "type": "object", + "required": [ + "dev_module_wallet", + "governance_admin", + "name_registration_fee_usaf", + "native_denom", + "reserved_names" + ], + "properties": { + "dev_module_wallet": { + "$ref": "#/definitions/Addr" + }, + "governance_admin": { + "$ref": "#/definitions/Addr" + }, + "name_registration_fee_usaf": { + "$ref": "#/definitions/Uint128" + }, + "native_denom": { + "type": "string" + }, + "reserved_names": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "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/schema/raw/response_to_get_address.json b/schema/raw/response_to_get_address.json new file mode 100644 index 0000000..26b8e16 --- /dev/null +++ b/schema/raw/response_to_get_address.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetAddressResponse", + "type": "object", + "required": [ + "address", + "normalized_key", + "record_type" + ], + "properties": { + "address": { + "type": "string" + }, + "normalized_key": { + "type": "string" + }, + "record_type": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/schema/raw/response_to_handles.json b/schema/raw/response_to_handles.json new file mode 100644 index 0000000..fe6930e --- /dev/null +++ b/schema/raw/response_to_handles.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HandlesResponse", + "description": "Reverse-lookup result. Each field is `None` when the address owns no record of that kind. See `docs/CONTRACT_API.md`.", + "type": "object", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false +} diff --git a/schema/raw/response_to_name_record.json b/schema/raw/response_to_name_record.json new file mode 100644 index 0000000..d57b185 --- /dev/null +++ b/schema/raw/response_to_name_record.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NameRecord", + "description": "A registered short name, keyed by its normalized form (e.g. `john.saf`).", + "type": "object", + "required": [ + "owner", + "registered_at_height", + "registered_at_time" + ], + "properties": { + "owner": { + "$ref": "#/definitions/Addr" + }, + "registered_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "registered_at_time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } +} diff --git a/schema/raw/response_to_resolve_name.json b/schema/raw/response_to_resolve_name.json new file mode 100644 index 0000000..d77362e --- /dev/null +++ b/schema/raw/response_to_resolve_name.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AddressResponse", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/schema/safhandle.json b/schema/safhandle.json new file mode 100644 index 0000000..89fb843 --- /dev/null +++ b/schema/safhandle.json @@ -0,0 +1,405 @@ +{ + "contract_name": "safhandle", + "contract_version": "0.2.0", + "idl_version": "1.0.0", + "instantiate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "InstantiateMsg", + "type": "object", + "required": [ + "dev_module_wallet", + "governance_admin", + "name_registration_fee_usaf", + "native_denom" + ], + "properties": { + "dev_module_wallet": { + "type": "string" + }, + "governance_admin": { + "type": "string" + }, + "name_registration_fee_usaf": { + "$ref": "#/definitions/Uint128" + }, + "native_denom": { + "type": "string" + } + }, + "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" + } + } + }, + "execute": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExecuteMsg", + "oneOf": [ + { + "description": "Register a short name for the sender. Funds: exactly the name fee.", + "type": "object", + "required": [ + "register_name" + ], + "properties": { + "register_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Transfer name ownership. Owner-only.", + "type": "object", + "required": [ + "transfer_name" + ], + "properties": { + "transfer_name": { + "type": "object", + "required": [ + "name", + "new_owner" + ], + "properties": { + "name": { + "type": "string" + }, + "new_owner": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Release a name back to the pool. Owner-only.", + "type": "object", + "required": [ + "release_name" + ], + "properties": { + "release_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Governance-only partial config update.", + "type": "object", + "required": [ + "update_config" + ], + "properties": { + "update_config": { + "type": "object", + "properties": { + "dev_module_wallet": { + "type": [ + "string", + "null" + ] + }, + "name_registration_fee_usaf": { + "anyOf": [ + { + "$ref": "#/definitions/Uint128" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "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" + } + } + }, + "query": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "QueryMsg", + "oneOf": [ + { + "description": "Resolve an input to an address. With the `phone` feature enabled, an all-digit input is auto-detected and resolved against the phone registry.", + "type": "object", + "required": [ + "get_address" + ], + "properties": { + "get_address": { + "type": "object", + "required": [ + "input" + ], + "properties": { + "input": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "resolve_name" + ], + "properties": { + "resolve_name": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "name_record" + ], + "properties": { + "name_record": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Reverse lookup: the name (and phone, when enabled) owned by an address.", + "type": "object", + "required": [ + "handles" + ], + "properties": { + "handles": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "migrate": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "MigrateMsg", + "type": "object", + "properties": { + "add_reserved_names": { + "description": "Additive-only: extend the reserved name list on upgrade.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "sudo": null, + "responses": { + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "description": "Governance-updatable contract configuration. See `docs/STATE_SCHEMA.md`.", + "type": "object", + "required": [ + "dev_module_wallet", + "governance_admin", + "name_registration_fee_usaf", + "native_denom", + "reserved_names" + ], + "properties": { + "dev_module_wallet": { + "$ref": "#/definitions/Addr" + }, + "governance_admin": { + "$ref": "#/definitions/Addr" + }, + "name_registration_fee_usaf": { + "$ref": "#/definitions/Uint128" + }, + "native_denom": { + "type": "string" + }, + "reserved_names": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + }, + "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" + } + } + }, + "get_address": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "GetAddressResponse", + "type": "object", + "required": [ + "address", + "normalized_key", + "record_type" + ], + "properties": { + "address": { + "type": "string" + }, + "normalized_key": { + "type": "string" + }, + "record_type": { + "type": "string" + } + }, + "additionalProperties": false + }, + "handles": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "HandlesResponse", + "description": "Reverse-lookup result. Each field is `None` when the address owns no record of that kind. See `docs/CONTRACT_API.md`.", + "type": "object", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "name_record": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "NameRecord", + "description": "A registered short name, keyed by its normalized form (e.g. `john.saf`).", + "type": "object", + "required": [ + "owner", + "registered_at_height", + "registered_at_time" + ], + "properties": { + "owner": { + "$ref": "#/definitions/Addr" + }, + "registered_at_height": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "registered_at_time": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false, + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } + }, + "resolve_name": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AddressResponse", + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string" + } + }, + "additionalProperties": false + } + } +} diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh new file mode 100755 index 0000000..30c1366 --- /dev/null +++ b/scripts/deploy-testnet.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# +# Deploys the safhandle contract to the Safrochain testnet: +# 1. store artifacts/safhandle.wasm -> new code_id +# 2. instantiate from that code_id -> new address (Config initialized) +# 3. prints the address to record in .env and config/testnet.json +# +# Usage (from safhandle-contract/): +# bash scripts/deploy-testnet.sh +# +# Prerequisites: safrochaind in PATH, key `issoni` imported and funded. +set -euo pipefail + +# ─────────────── Settings (edit if needed) ─────────────── +KEY="issoni" # signer (funded, = governance_admin) +ADMIN_ADDR="addr_safro1qyzdpg5v9xepwn0jx65uxhjrq07t8v8w93qq7d" # contract admin (allows future migrations) +GOV_ADMIN="$ADMIN_ADDR" # governance_admin in Config +DEV_WALLET="$ADMIN_ADDR" # dev_module_wallet (receives fees) — TODO: dedicated wallet? +LABEL="safhandle-registry-v1" +NODE="https://rpc.testnet.safrochain.com:443" +CHAIN_ID="safro-testnet-1" +GAS_PRICES="0.025usaf" +GAS_ADJ="1.4" +WASM="artifacts/safhandle.wasm" +CONFIG_JSON="config/testnet.json" +# Keyring backend: leave empty for the client default (the one that lists your keys). +# Set "test" or "file" if your keys live there, e.g. KEYRING="--keyring-backend test" +KEYRING="" +# ───────────────────────────────────────────────────────────── + +BIN="safrochaind" +TXFLAGS="--from $KEY --node $NODE --chain-id $CHAIN_ID --gas auto --gas-adjustment $GAS_ADJ --gas-prices $GAS_PRICES --broadcast-mode sync -y --output json $KEYRING" + +cd "$(dirname "$0")/.." # -> safhandle-contract/ root + +[ -f "$WASM" ] || { echo "❌ $WASM not found. Build the contract first." >&2; exit 1; } + +# Reads a value from config/testnet.json (dotted path, e.g. safhandle.nameRegistrationFeeUsaf) +cfg() { python3 -c "import json,functools; d=json.load(open('$CONFIG_JSON')); print(functools.reduce(lambda a,k:a[k],'$1'.split('.'),d))"; } + +DENOM="$(cfg coinMinimalDenom)" +NAME_FEE="$(cfg safhandle.nameRegistrationFeeUsaf)" +# Phone linking is a Phase 2 feature, compiled out of the v1 wasm. The v1 +# InstantiateMsg has no phone_link_fee_usaf field; passing it would be rejected +# (cw_serde denies unknown fields). Re-add PHONE_FEE below when deploying a +# wasm built with `--features phone`. +# PHONE_FEE="$(cfg safhandle.phoneLinkFeeUsaf)" + +# Extracts an event attribute from the `query tx` JSON output (handles legacy base64). +# NB: the program is passed via `-c` (not a heredoc), otherwise the heredoc would occupy +# stdin and json.load(sys.stdin) would read empty instead of the JSON sent by the pipe. +attr() { # $1 = event.type $2 = attribute.key (reads JSON on stdin) + python3 -c ' +import sys, json, base64 +etype, ekey = sys.argv[1], sys.argv[2] +d = json.load(sys.stdin) +def dec(s): + if s is None: return None + try: return base64.b64decode(s).decode() # SDK legacy: base64 attrs + except Exception: return s +for ev in d.get("events", []): + if ev.get("type") == etype: + for a in ev.get("attributes", []): + k = a.get("key"); v = a.get("value") + if k == ekey or dec(k) == ekey: + print(v if k == ekey else dec(v)); sys.exit(0) +sys.exit(f"{etype}.{ekey} not found in events") +' "$1" "$2" +} + +# Waits for a tx to be included, then returns its full JSON on stdout. +# Tolerates the delay before the tx is indexed (query tx then returns +# empty or an error). Fails cleanly if the tx is included with code != 0. +wait_tx() { # $1 = txhash + local hash="$1" i out rc + for i in $(seq 1 40); do + sleep 2 + out=$($BIN query tx "$hash" --node "$NODE" --output json 2>/dev/null) || true + [ -z "$out" ] && continue + # python exit code: 0 = included OK, 2 = included but failed, 3 = not valid JSON yet + if printf '%s' "$out" | python3 -c " +import sys,json +try: d=json.load(sys.stdin) +except Exception: sys.exit(3) +sys.exit(0 if d.get('code',0) in (0,None) else 2)"; then + printf '%s' "$out"; return 0 + else + rc=$? + if [ "$rc" = "2" ]; then + echo "❌ tx $hash included but failed:" >&2 + printf '%s' "$out" | python3 -c "import sys,json;print(json.load(sys.stdin).get('raw_log','')[:400])" >&2 + exit 1 + fi + # rc=3 -> not indexed yet, retry + fi + done + echo "❌ tx $hash not found after ~80s." >&2; exit 1 +} + +# Optional: pass an already-stored code_id as the 1st argument to skip the `store` +# (useful to re-instantiate without re-uploading the wasm and paying gas again). +if [ -n "${1:-}" ]; then + CODE_ID="$1" + echo "▶ store skipped — reusing code_id $CODE_ID" +else + echo "▶ store $WASM (signer: $KEY)…" + STORE_HASH=$($BIN tx wasm store "$WASM" $TXFLAGS | python3 -c "import sys,json;print(json.load(sys.stdin)['txhash'])") + echo " txhash: $STORE_HASH — waiting for inclusion…" + CODE_ID=$(wait_tx "$STORE_HASH" | attr store_code code_id) + echo "✅ code_id = $CODE_ID" +fi + +INIT_MSG=$(python3 -c " +import json +print(json.dumps({ + 'native_denom': '$DENOM', + 'name_registration_fee_usaf': '$NAME_FEE', + 'dev_module_wallet': '$DEV_WALLET', + 'governance_admin': '$GOV_ADMIN', +}))") +echo "▶ instantiate code_id $CODE_ID" +echo " msg: $INIT_MSG" +INIT_HASH=$($BIN tx wasm instantiate "$CODE_ID" "$INIT_MSG" \ + --label "$LABEL" --admin "$ADMIN_ADDR" $TXFLAGS \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['txhash'])") +echo " txhash: $INIT_HASH — waiting for inclusion…" +CONTRACT_ADDR=$(wait_tx "$INIT_HASH" | attr instantiate _contract_address) + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "✅ safhandle contract deployed!" +echo " code_id: $CODE_ID" +echo " address: $CONTRACT_ADDR" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Quick check:" +echo " $BIN query wasm contract-state smart $CONTRACT_ADDR '{\"config\":{}}' --node $NODE --output json" diff --git a/scripts/verify-repo.test.mjs b/scripts/verify-repo.test.mjs index d3ce52c..592bdd4 100644 --- a/scripts/verify-repo.test.mjs +++ b/scripts/verify-repo.test.mjs @@ -18,7 +18,7 @@ test("CI workflow exists", () => { test("network config includes safhandle fees", () => { const testnet = JSON.parse(readFileSync(join(root, "config/testnet.json"), "utf8")); assert.equal(testnet.safhandle.nameRegistrationFeeUsaf, "50000000"); - assert.equal(testnet.safhandle.phoneLinkFeeUsaf, "100000000"); + // v1 is name-only; phone linking (and its fee) is deferred to Phase 2. assert.equal(testnet.chainId, "safro-testnet-1"); });