diff --git a/README.md b/README.md index c94afbc..94d31ae 100644 --- a/README.md +++ b/README.md @@ -1,325 +1,5 @@ -# BiUD — Bitcoin Username Domain (.sBTC) +# BiUD -
+A building identity platform on Stacks. -``` -██████╗ ██╗██╗ ██╗██████╗ -██╔══██╗██║██║ ██║██╔══██╗ -██████╔╝██║██║ ██║██║ ██║ -██╔══██╗██║██║ ██║██║ ██║ -██████╔╝██║╚██████╔╝██████╔╝ -╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ -``` - -**Decentralized Username Registrar on Stacks (Bitcoin L2)** - -[![Clarity](https://img.shields.io/badge/Clarity-4-blue)](https://clarity-lang.org/) -[![Stacks](https://img.shields.io/badge/Stacks-Bitcoin%20L2-orange)](https://stacks.co/) -[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) - -
- ---- - -## 🌟 Overview - -BiUD is a decentralized username system similar to ENS but built on **Stacks**, the leading Bitcoin L2. Users can register human-readable names with the `.sBTC` TLD: - -- `alice.sBTC` -- `mybrand.sBTC` -- `satoshi.sBTC` - -Names are registered, renewed, transferred, and resolved **entirely on-chain** using Clarity smart contracts. - ---- - -## ✨ Features - -| Feature | Description | -|---------|-------------| -| 📝 **Name Registration** | Register usernames with `.sBTC` TLD | -| 🔄 **Renewals** | Extend ownership before expiry | -| 🔀 **Transfers** | Transfer names to other principals | -| 🎯 **Resolvers** | Pluggable resolution via traits | -| 💎 **Premium Names** | Short names (≤4 chars) cost more | -| 💰 **Fee Distribution** | Protocol fees to treasury + deployer | -| ⏰ **Expiry System** | Auto-expiry with grace period | -| 🔐 **Admin Governance** | Configurable fees and settings | - ---- - -## 📁 Project Structure - -``` -biud/ -├── Clarinet.toml # Clarinet configuration -├── contracts/ -│ └── biud-username.clar # Main registry contract -├── tests/ -│ └── biud-username_test.ts # Test suite -├── settings/ -│ ├── Devnet.toml # Development network config -│ ├── Testnet.toml # Testnet configuration -│ └── Mainnet.toml # Mainnet configuration -└── frontend/ # (Optional) Frontend UI -``` - ---- - -## 🚀 Quick Start - -### Prerequisites - -- [Clarinet](https://github.com/hirosystems/clarinet) >= 2.0 -- Node.js >= 18 - -### Installation - -```bash -# Clone the repository -cd biud - -# Check the contract -clarinet check - -# Run tests -clarinet test - -# Start console for interactive development -clarinet console -``` - -### Running Tests - -```bash -# Run all tests -clarinet test - -# Run with verbose output -clarinet test --verbose - -# Run specific test file -clarinet test tests/biud-username_test.ts -``` - ---- - -## 📖 Contract API - -### Core Functions - -#### `register-name` -Register a new username with `.sBTC` TLD. - -```clarity -(register-name (label (string-utf8 32))) -``` - -**Parameters:** -- `label`: Username (lowercase a-z, 0-9, hyphen, max 32 chars) - -**Returns:** `{ name-id, full-name, expiry-height, fee-paid }` - -#### `renew-name` -Extend the registration period for an existing name. - -```clarity -(renew-name (label (string-utf8 32))) -``` - -#### `transfer-name` -Transfer ownership to another principal. - -```clarity -(transfer-name (label (string-utf8 32)) (new-owner principal)) -``` - -#### `set-resolver` -Set a resolver contract for custom name resolution. - -```clarity -(set-resolver (label (string-utf8 32)) (resolver principal)) -``` - -### Read-Only Functions - -| Function | Description | -|----------|-------------| -| `get-name` | Get full name record | -| `is-available` | Check if name is available | -| `get-owner` | Get owner of a name | -| `get-expiry` | Get expiry block height | -| `is-premium-name` | Check if name is premium | -| `get-fee-config` | Get current fee configuration | -| `get-registration-fee` | Calculate fee for a label | -| `get-names-by-owner` | Get all names owned by principal | - -### Admin Functions - -| Function | Description | -|----------|-------------| -| `set-base-fee` | Update base registration fee | -| `set-renew-fee` | Update renewal fee | -| `set-premium-multiplier` | Update premium price multiplier | -| `set-fee-recipient` | Update fee recipient address | -| `set-premium-label` | Mark a label as premium | -| `set-protocol-treasury` | Update protocol treasury | -| `set-protocol-fee-percent` | Update protocol fee percentage | - ---- - -## 💎 Pricing - -### Standard Names (5+ characters) -- **Registration:** 10 STX -- **Renewal:** 5 STX - -### Premium Names (1-4 characters) -- **Registration:** 50 STX (5x multiplier) -- **Renewal:** 5 STX - -> Admin can mark any name as premium regardless of length. - ---- - -## ⏰ Registration Periods - -| Period | Blocks | Duration | -|--------|--------|----------| -| Registration | 52,560 | ~1 year | -| Grace Period | 1,008 | ~7 days | - -During the grace period, only the original owner can renew the name. - ---- - -## 🔧 Fee Distribution - -Registration and renewal fees are split: - -- **90%** → Fee Recipient (deployer by default) -- **10%** → Protocol Treasury - -> Configurable by admin via `set-protocol-fee-percent`. - ---- - -## 🔐 Error Codes - -| Code | Name | Description | -|------|------|-------------| -| 1001 | `ERR_NAME_TAKEN` | Name already registered | -| 1002 | `ERR_NAME_EXPIRED` | Name has expired | -| 1003 | `ERR_NOT_OWNER` | Caller is not the owner | -| 1004 | `ERR_NOT_ADMIN` | Caller is not admin | -| 1005 | `ERR_INVALID_LABEL` | Invalid label format | -| 1006 | `ERR_PAYMENT_FAILED` | Fee transfer failed | -| 1007 | `ERR_IN_GRACE_PERIOD` | Name is in grace period | -| 1008 | `ERR_RESOLVER_INVALID` | Invalid resolver contract | -| 1009 | `ERR_NAME_NOT_FOUND` | Name does not exist | -| 1010 | `ERR_LABEL_TOO_LONG` | Label exceeds 32 characters | -| 1011 | `ERR_LABEL_EMPTY` | Empty label provided | -| 1012 | `ERR_INVALID_CHARACTER` | Label contains invalid character | -| 1013 | `ERR_TRANSFER_TO_SELF` | Cannot transfer to self | -| 1014 | `ERR_ZERO_FEE` | Fee cannot be zero | - ---- - -## 🎯 Resolver Trait - -External contracts can implement the resolver trait for custom resolution: - -```clarity -(define-trait resolver-trait - ( - (resolve ((string-utf8 32) principal) - (response (optional (buff 64)) uint)) - ) -) -``` - -### Example Resolver - -```clarity -(impl-trait .biud-username.resolver-trait) - -(define-public (resolve (label (string-utf8 32)) (owner principal)) - (ok (some 0x1234567890abcdef...)) -) -``` - ---- - -## 📡 Events - -The contract emits events for all major actions: - -- `NameRegistered` - New name registered -- `NameRenewed` - Name renewed -- `NameTransferred` - Ownership transferred -- `ResolverSet` - Resolver contract updated -- `FeeConfigUpdated` - Fee settings changed -- `TreasuryUpdated` - Treasury settings changed -- `PremiumLabelSet` - Premium label status changed - ---- - -## 🖥️ Frontend Integration - -See the [frontend/](frontend/) directory for a minimal UI outline. Key integrations: - -```typescript -// Check availability -const available = await callReadOnly('is-available', [stringUtf8(label)]); - -// Register name -const result = await callPublic('register-name', [stringUtf8(label)]); - -// Get name info -const nameInfo = await callReadOnly('get-name', [stringUtf8(label)]); -``` - ---- - -## 🔒 Security Considerations - -1. **Admin Key Security**: The deployer address has admin privileges. Secure this key. -2. **Fee Configuration**: Only admin can modify fees to prevent manipulation. -3. **Grace Period**: Protects users from losing names due to brief lapses. -4. **Resolver Validation**: Resolvers must implement the trait correctly. - ---- - -## 🛣️ Roadmap - -- [x] Core registration system -- [x] Premium name pricing -- [x] Fee distribution -- [x] Resolver trait -- [ ] Subdomain support -- [ ] NFT integration -- [ ] Bulk registration -- [ ] Auction system for premium names -- [ ] Cross-chain resolution - ---- - -## 📄 License - -MIT License - see [LICENSE](LICENSE) - ---- - -## 🤝 Contributing - -Contributions welcome! Please read our contributing guidelines first. - ---- - -
- -**Built on Stacks • Secured by Bitcoin** - -[Website](https://biud.example.com) • [Discord](https://discord.gg/biud) • [Twitter](https://twitter.com/biud) - -
+npm install diff --git a/contracts/biud-username.clar b/contracts/biud-username.clar index 27b99f4..cf4650c 100644 --- a/contracts/biud-username.clar +++ b/contracts/biud-username.clar @@ -233,6 +233,39 @@ ) ) +;; Validate subdomain label: supports sub.parent format +(define-private (validate-subdomain-label (label (string-utf8 32))) + (let + ( + (dot-index (index-of label ".")) + ) + (if (is-some dot-index) + ;; It's a subdomain + (let + ( + (parent-end (unwrap-panic dot-index)) + (sub-len (len label)) + (parent (unwrap! (slice? label u0 parent-end) ERR_INVALID_LABEL)) + (subdomain-start (+ parent-end u1)) + (subdomain (unwrap! (slice? label subdomain-start sub-len) ERR_INVALID_LABEL)) + ) + ;; Check parent and subdomain not empty + (asserts! (> (len parent) u0) ERR_INVALID_LABEL) + (asserts! (> (len subdomain) u0) ERR_INVALID_LABEL) + ;; Validate parent and subdomain as valid labels + (try! (validate-label parent)) + (try! (validate-label subdomain)) + (ok { is-subdomain: true, parent: parent, subdomain: subdomain }) + ) + ;; Not a subdomain + (begin + (try! (validate-label label)) + (ok { is-subdomain: false, parent: "", subdomain: "" }) + ) + ) + ) +) + ;; Generate the next name ID (define-private (get-next-name-id) (let @@ -361,73 +394,110 @@ (let ( ;; Validate the label format - (validation-result (try! (validate-label label))) - ;; Generate the full name with TLD - (full-name (unwrap! (as-max-len? (concat label u".sBTC") u64) ERR_INVALID_LABEL)) - ;; Check if name is premium - (is-premium (check-is-premium label)) - ;; Calculate the registration fee - (reg-fee (calculate-registration-fee is-premium)) - ;; Get existing registration if any - (existing (map-get? name-registry { label: label })) - ;; Generate new name ID - (new-name-id (get-next-name-id)) - ;; Calculate expiry height - (expiry (+ block-height REGISTRATION_PERIOD)) + (validation-result (try! (validate-subdomain-label label))) + ;; Check parent ownership if subdomain + (is-subdomain (get is-subdomain validation-result)) ) - ;; Check if name is available - (match existing - name-record - ;; Name exists - check if expired - (begin - (asserts! (is-name-expired (get expiry-height name-record)) ERR_NAME_TAKEN) - ;; Remove from previous owner's list - (try! (remove-name-from-owner (get owner name-record) (get name-id name-record))) + ;; If subdomain, verify parent ownership + (if is-subdomain + (let + ( + (parent (get parent validation-result)) + (parent-record (unwrap! (map-get? name-registry { label: parent }) ERR_NAME_NOT_FOUND)) + ) + (asserts! (is-eq (get owner parent-record) tx-sender) ERR_NOT_OWNER) + (asserts! (<= block-height (get expiry-height parent-record)) ERR_NAME_EXPIRED) + true ) - ;; Name doesn't exist - proceed true ) - - ;; Collect registration fee - (asserts! (> reg-fee u0) ERR_ZERO_FEE) - (try! (distribute-fees reg-fee)) - - ;; Create the name record - (map-set name-registry - { label: label } - { + ;; Generate the full name with TLD + (let + ( + (full-name (if is-subdomain + (let + ( + (parent (get parent validation-result)) + (subdomain (get subdomain validation-result)) + ) + (unwrap! (as-max-len? (concat (concat subdomain ".") (concat parent ".sBTC")) u64) ERR_INVALID_LABEL) + ) + (unwrap! (as-max-len? (concat label ".sBTC") u64) ERR_INVALID_LABEL) + )) + ;; Check if name is premium + (is-premium (check-is-premium label)) + ;; Calculate the registration fee + (reg-fee (calculate-registration-fee is-premium)) + ;; Get existing registration if any + (existing (map-get? name-registry { label: label })) + ;; Generate new name ID + (new-name-id (get-next-name-id)) + ;; Calculate expiry height + (expiry (+ block-height REGISTRATION_PERIOD)) + ) + ;; Check if name is available + (match existing + name-record + ;; Name exists - check if expired + (begin + (asserts! (is-name-expired (get expiry-height name-record)) ERR_NAME_TAKEN) + ;; Remove from previous owner's list + (try! (remove-name-from-owner (get owner name-record) (get name-id name-record))) + ) + ;; Name doesn't exist - proceed + true + ) + + ;; Collect registration fee + (asserts! (> reg-fee u0) ERR_ZERO_FEE) + (try! (distribute-fees reg-fee)) + + ;; Create the name record + (map-set name-registry + { label: label } + { + name-id: new-name-id, + full-name: full-name, + owner: tx-sender, + resolver: none, + expiry-height: expiry, + is-premium: is-premium, + created-at: block-height, + last-renewed: block-height + } + ) + + ;; Set reverse lookup + (map-set name-id-to-label + { name-id: new-name-id } + { label: label } + ) + + ;; Add to owner's name list + (try! (add-name-to-owner tx-sender new-name-id)) + + ;; Emit registration event + (emit-name-registered label full-name tx-sender new-name-id expiry reg-fee is-premium) + + (ok { name-id: new-name-id, full-name: full-name, - owner: tx-sender, - resolver: none, expiry-height: expiry, - is-premium: is-premium, - created-at: block-height, - last-renewed: block-height - } - ) - - ;; Set reverse lookup - (map-set name-id-to-label - { name-id: new-name-id } - { label: label } + fee-paid: reg-fee + }) ) - - ;; Add to owner's name list - (try! (add-name-to-owner tx-sender new-name-id)) - - ;; Emit registration event - (emit-name-registered label full-name tx-sender new-name-id expiry reg-fee is-premium) - - (ok { - name-id: new-name-id, - full-name: full-name, - expiry-height: expiry, - fee-paid: reg-fee - }) ) ) +;; Register multiple names in one transaction (up to 10) +(define-private (register-name-wrapper (label (string-utf8 32))) + (register-name label) +) + +(define-public (register-multiple-names (labels (list 10 (string-utf8 32)))) + (ok (map register-name-wrapper labels)) +) + ;; ════════════════════════════════════════════════════════════════════════════ ;; PUBLIC FUNCTIONS - RENEWAL ;; ════════════════════════════════════════════════════════════════════════════ diff --git a/tests/biud-username_test.ts b/tests/biud-username_test.ts index ad87fa8..974a514 100644 --- a/tests/biud-username_test.ts +++ b/tests/biud-username_test.ts @@ -785,6 +785,245 @@ describe("Event Emissions", () => { }); }); +// ════════════════════════════════════════════════════════════════════════════ +// SUBDOMAIN TESTS +// ════════════════════════════════════════════════════════════════════════════ + +describe("Subdomain Support", () => { + it("should register a subdomain successfully", () => { + // Register parent + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("alice")], wallet1); + + // Register subdomain + const result = simnet.callPublicFn( + contractName, + "register-name", + [Cl.stringUtf8("sub.alice")], + wallet1 + ); + + expect(result.result).toBeOk(Cl.tuple({ + "name-id": Cl.uint(2), + "full-name": Cl.stringUtf8("sub.alice.sBTC"), + "expiry-height": Cl.uint(simnet.blockHeight + 52560), + "fee-paid": Cl.uint(10000000) // Same fee, not premium + })); + }); + + it("should fail to register subdomain if parent not owned", () => { + // Register parent by wallet1 + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("alice")], wallet1); + + // Try to register subdomain by wallet2 + const result = simnet.callPublicFn( + contractName, + "register-name", + [Cl.stringUtf8("sub.alice")], + wallet2 + ); + + expect(result.result).toBeErr(Cl.uint(1003)); // ERR_NOT_OWNER + }); + + it("should treat subdomain as non-premium", () => { + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("alice")], wallet1); + + // Check premium status + expect( + simnet.callReadOnlyFn(contractName, "is-premium-name", [Cl.stringUtf8("sub.alice")], wallet1).result + ).toBeBool(false); // Longer than 4 chars + }); + + it("should allow renewal of subdomain", () => { + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("alice")], wallet1); + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("sub.alice")], wallet1); + + const result = simnet.callPublicFn( + contractName, + "renew-name", + [Cl.stringUtf8("sub.alice")], + wallet1 + ); + + expect(result.result).toBeOk(Cl.tuple({ + "new-expiry-height": Cl.uint(simnet.blockHeight + 52560 + 52559), + "fee-paid": Cl.uint(5000000) + })); + }); + + it("should allow transfer of subdomain", () => { + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("alice")], wallet1); + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("sub.alice")], wallet1); + + const result = simnet.callPublicFn( + contractName, + "transfer-name", + [Cl.stringUtf8("sub.alice"), Cl.principal(wallet2)], + wallet1 + ); + + expect(result.result).toBeOk(Cl.bool(true)); + + // Verify owner changed + expect( + simnet.callReadOnlyFn(contractName, "get-owner", [Cl.stringUtf8("sub.alice")], wallet1).result + ).toBeSome(Cl.principal(wallet2)); + }); + + it("should validate subdomain format", () => { + // Invalid: empty subdomain + let result = simnet.callPublicFn( + contractName, + "register-name", + [Cl.stringUtf8(".alice")], // starts with dot + wallet1 + ); + expect(result.result).toBeErr(Cl.uint(1005)); // ERR_INVALID_LABEL + + // Invalid: empty parent + result = simnet.callPublicFn( + contractName, + "register-name", + [Cl.stringUtf8("sub.")], // ends with dot + wallet1 + ); + expect(result.result).toBeErr(Cl.uint(1005)); + + // Valid format but parent doesn't exist + result = simnet.callPublicFn( + contractName, + "register-name", + [Cl.stringUtf8("sub.nonexistent")], + wallet1 + ); + expect(result.result).toBeErr(Cl.uint(1009)); // ERR_NAME_NOT_FOUND + }); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// BULK REGISTRATION TESTS +// ════════════════════════════════════════════════════════════════════════════ + +describe("Bulk Registration", () => { + it("should register multiple names successfully", () => { + const labels = ["bulk1", "bulk2", "bulk3"]; + const result = simnet.callPublicFn( + contractName, + "register-multiple-names", + [Cl.list(labels.map(l => Cl.stringUtf8(l)))], + wallet1 + ); + + expect(result.result).toBeOk(Cl.list([ + Cl.tuple({ + "name-id": Cl.uint(1), + "full-name": Cl.stringUtf8("bulk1.sBTC"), + "expiry-height": Cl.uint(simnet.blockHeight + 52560), + "fee-paid": Cl.uint(10000000) + }), + Cl.tuple({ + "name-id": Cl.uint(2), + "full-name": Cl.stringUtf8("bulk2.sBTC"), + "expiry-height": Cl.uint(simnet.blockHeight + 52560), + "fee-paid": Cl.uint(10000000) + }), + Cl.tuple({ + "name-id": Cl.uint(3), + "full-name": Cl.stringUtf8("bulk3.sBTC"), + "expiry-height": Cl.uint(simnet.blockHeight + 52560), + "fee-paid": Cl.uint(10000000) + }) + ])); + }); + + it("should handle mixed successful and failed registrations", () => { + // Register one name first + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("exists")], wallet1); + + const labels = ["newname", "exists", "another"]; + const result = simnet.callPublicFn( + contractName, + "register-multiple-names", + [Cl.list(labels.map(l => Cl.stringUtf8(l)))], + wallet1 + ); + + // The result should contain mixed ok/err + expect(result.result).toBeOk(Cl.list([ + Cl.tuple({ // newname succeeds + "name-id": Cl.uint(2), + "full-name": Cl.stringUtf8("newname.sBTC"), + "expiry-height": Cl.uint(simnet.blockHeight + 52560), + "fee-paid": Cl.uint(10000000) + }), + Cl.uint(1001), // exists fails with ERR_NAME_TAKEN + Cl.tuple({ // another succeeds + "name-id": Cl.uint(3), + "full-name": Cl.stringUtf8("another.sBTC"), + "expiry-height": Cl.uint(simnet.blockHeight + 52560), + "fee-paid": Cl.uint(10000000) + }) + ])); + }); + + it("should handle empty list", () => { + const result = simnet.callPublicFn( + contractName, + "register-multiple-names", + [Cl.list([])], + wallet1 + ); + + expect(result.result).toBeOk(Cl.list([])); + }); + + it("should register subdomains in bulk", () => { + // Register parent + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("parent")], wallet1); + + const labels = ["sub1.parent", "sub2.parent"]; + const result = simnet.callPublicFn( + contractName, + "register-multiple-names", + [Cl.list(labels.map(l => Cl.stringUtf8(l)))], + wallet1 + ); + + expect(result.result).toBeOk(Cl.list([ + Cl.tuple({ + "name-id": Cl.uint(2), + "full-name": Cl.stringUtf8("sub1.parent.sBTC"), + "expiry-height": Cl.uint(simnet.blockHeight + 52560), + "fee-paid": Cl.uint(10000000) + }), + Cl.tuple({ + "name-id": Cl.uint(3), + "full-name": Cl.stringUtf8("sub2.parent.sBTC"), + "expiry-height": Cl.uint(simnet.blockHeight + 52560), + "fee-paid": Cl.uint(10000000) + }) + ])); + }); + + it("should fail bulk subdomain registration without parent ownership", () => { + // Register parent by wallet1 + simnet.callPublicFn(contractName, "register-name", [Cl.stringUtf8("parent")], wallet1); + + // Try to register subdomains by wallet2 + const labels = ["sub.parent"]; + const result = simnet.callPublicFn( + contractName, + "register-multiple-names", + [Cl.list(labels.map(l => Cl.stringUtf8(l)))], + wallet2 + ); + + expect(result.result).toBeOk(Cl.list([ + Cl.uint(1003) // ERR_NOT_OWNER + ])); + }); +}); + // ════════════════════════════════════════════════════════════════════════════ // EDGE CASE TESTS // ════════════════════════════════════════════════════════════════════════════