diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..23d304e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,49 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Bug Description +A clear and concise description of what the bug is. + +## Steps to Reproduce +1. Go to '...' +2. Click on '...' +3. Execute '...' +4. See error + +## Expected Behavior +A clear and concise description of what you expected to happen. + +## Actual Behavior +What actually happened instead. + +## Environment +- OS: [e.g., macOS 14.0, Ubuntu 22.04] +- Browser: [e.g., Chrome 120, Firefox 119] +- Node.js version: [e.g., 18.19.0] +- Network: [e.g., testnet, mainnet] +- Clarinet version: [e.g., 2.3.0] + +## Component Affected +- [ ] Smart Contract (Clarity) +- [ ] Frontend (Next.js) +- [ ] Username Registration +- [ ] Domain Resolution + +## Username Domain Context (if applicable) +- Username being registered +- Registration transaction hash +- Domain resolution attempt +- User's STX address + +## Logs +``` +Paste relevant error logs here +``` + +## Screenshots +If applicable, add screenshots to help explain your problem. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..0c6d417 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,40 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Problem Statement +A clear and concise description of what problem this feature would solve. +Ex. I'm always frustrated when [...] + +## Proposed Solution +A clear and concise description of what you want to happen. + +## Alternative Solutions +A clear and concise description of any alternative solutions or features you've considered. + +## Use Case +Describe the use case for this feature. How would it enhance the Bitcoin username domain experience? + +## Technical Implementation +If you have ideas on how to implement this feature, please share them here: +- Which component would be affected? (Contract/Frontend/Both) +- Smart contract changes needed +- Username registration modifications +- Domain resolution impact + +## Impact on Username System +How would this feature affect: +- Username registration process +- Domain resolution speed +- User experience +- Decentralized identity + +## Examples +Provide examples of similar features in other decentralized identity systems. + +## Additional Context +Add any mockups, references, or other relevant information. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..028bf4f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,48 @@ +## Description +Brief description of the changes in this PR. + +## Related Issue +Fixes #(issue number) + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Code refactoring +- [ ] Performance improvement +- [ ] Security enhancement + +## Changes Made +- Change 1 +- Change 2 +- Change 3 + +## Testing +Describe the tests you ran to verify your changes: +- [ ] Contract tests pass (`npm run clarinet:test`) +- [ ] Unit tests pass (`npm test`) +- [ ] Frontend builds successfully (`npm run build`) +- [ ] Manual testing performed +- [ ] New tests added for new functionality + +## Component Checklist +- [ ] Smart Contract (Clarity) + - [ ] Gas optimization considered + - [ ] Username registration logic verified + - [ ] Domain resolution tested + - [ ] Backward compatibility maintained +- [ ] Frontend (Next.js) + - [ ] Responsive design maintained + - [ ] Wallet integration tested + - [ ] Registration flow verified + - [ ] Domain lookup accurate +- [ ] Tests (Vitest) + - [ ] Unit test coverage maintained + - [ ] Edge cases covered (username conflicts, etc.) + - [ ] Integration tests included + +## Username Domain Impact (if applicable) +- [ ] Username registration tested +- [ ] Domain resolution verified +- [ ] User identities correctly managed \ No newline at end of file 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/package.json b/package.json index 7e67636..92b5a79 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,14 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", + "test:report": "vitest run -- --coverage --costs", "check": "clarinet check", - "console": "clarinet console" + "console": "clarinet console", + "clarinet:test": "clarinet test" }, - "devDependencies": { - "@hirosystems/clarinet-sdk": "^2.3.0", - "@stacks/network": "^7.2.0", - "@stacks/transactions": "^6.17.0", - "@stacks/wallet-sdk": "^7.2.0", - "bip39": "^3.1.0", - "node-fetch": "^3.3.2", - "typescript": "^5.3.3", - "vitest": "^1.2.2", - "vitest-environment-clarinet": "^2.0.0" + "repository": { + "type": "git", + "url": "git+https://github.com/AdekunleBamz/biud.git" }, "keywords": [ "stacks", @@ -27,8 +22,27 @@ "domain", "registrar", "ens", - "web3" + "web3", + "decentralized-identity" ], - "author": "", - "license": "MIT" + "author": "Adekunle Bamz", + "license": "MIT", + "bugs": { + "url": "https://github.com/AdekunleBamz/biud/issues" + }, + "homepage": "https://github.com/AdekunleBamz/biud#readme", + "engines": { + "node": ">=18.0.0" + }, + "devDependencies": { + "@hirosystems/clarinet-sdk": "^2.3.0", + "@stacks/network": "^7.2.0", + "@stacks/transactions": "^6.17.0", + "@stacks/wallet-sdk": "^7.2.0", + "bip39": "^3.1.0", + "node-fetch": "^3.3.2", + "typescript": "^5.3.3", + "vitest": "^1.2.2", + "vitest-environment-clarinet": "^2.0.0" + } } 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 // ════════════════════════════════════════════════════════════════════════════