diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false 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 // ════════════════════════════════════════════════════════════════════════════