From 1e32d5517a0648bf6de7febec7fb7ade896168c8 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Thu, 8 Feb 2024 00:10:02 -0700 Subject: [PATCH] feat: add v2 to use with abtc --- Clarinet.toml | 10 + README.md | 6 + src/abtc.clar | 93 ++++ src/stacks-m2m-trait-v1.clar | 6 +- src/stacks-m2m-v2.clar | 415 ++++++++++++++++ tests/stacks-m2m-v2.test.ts | 909 +++++++++++++++++++++++++++++++++++ 6 files changed, 1436 insertions(+), 3 deletions(-) create mode 100644 src/abtc.clar create mode 100644 src/stacks-m2m-v2.clar create mode 100644 tests/stacks-m2m-v2.test.ts diff --git a/Clarinet.toml b/Clarinet.toml index 355d3f5..60a454b 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -6,6 +6,11 @@ telemetry = true cache_dir = './.cache' requirements = [] +[contracts.abtc] +path = 'src/abtc.clar' +clarity_version = 2 +epoch = 2.4 + [contracts.stacks-m2m-trait-v1] path = 'src/stacks-m2m-trait-v1.clar' clarity_version = 2 @@ -16,6 +21,11 @@ path = 'src/stacks-m2m-v1.clar' clarity_version = 2 epoch = 2.4 +[contracts.stacks-m2m-v2] +path = 'src/stacks-m2m-v2.clar' +clarity_version = 2 +epoch = 2.4 + [repl.analysis] passes = ['check_checker'] diff --git a/README.md b/README.md index 01d465b..3d7c7a9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ # 🤖💳🤖 Stacks M2M Smart contracts and tests for machine-payable transactions on Stacks. + +## Local Development + +1. Clone repository +2. Install dependencies: `npm install` +3. Run tests: `npm test` diff --git a/src/abtc.clar b/src/abtc.clar new file mode 100644 index 0000000..683acea --- /dev/null +++ b/src/abtc.clar @@ -0,0 +1,93 @@ +(define-constant ERR-NOT-AUTHORIZED (err u1000)) +(define-fungible-token bridged-btc) +(define-data-var contract-owner principal tx-sender) +(define-map approved-contracts principal bool) +(define-data-var token-name (string-ascii 32) "aBTC") +(define-data-var token-symbol (string-ascii 10) "aBTC") +(define-data-var token-uri (optional (string-utf8 256)) (some u"https://cdn.alexlab.co/metadata/token-abtc.json")) +(define-data-var token-decimals uint u8) +(define-read-only (get-contract-owner) + (ok (var-get contract-owner))) +(define-public (set-contract-owner (owner principal)) + (begin + (try! (check-is-owner)) + (ok (var-set contract-owner owner)))) +(define-private (check-is-owner) + (ok (asserts! (is-eq tx-sender (var-get contract-owner)) ERR-NOT-AUTHORIZED))) +(define-private (check-is-approved) + (ok (asserts! (default-to false (map-get? approved-contracts tx-sender)) ERR-NOT-AUTHORIZED))) +(define-public (set-name (new-name (string-ascii 32))) + (begin + (try! (check-is-owner)) + (ok (var-set token-name new-name)))) +(define-public (set-symbol (new-symbol (string-ascii 10))) + (begin + (try! (check-is-owner)) + (ok (var-set token-symbol new-symbol)) + ) +) +(define-public (set-decimals (new-decimals uint)) + (begin + (try! (check-is-owner)) + (ok (var-set token-decimals new-decimals)))) +(define-public (set-token-uri (new-uri (optional (string-utf8 256)))) + (begin + (try! (check-is-owner)) + (ok (var-set token-uri new-uri)))) +(define-public (add-approved-contract (new-approved-contract principal)) + (begin + (try! (check-is-owner)) + (ok (map-set approved-contracts new-approved-contract true)))) +(define-public (set-approved-contract (owner principal) (approved bool)) + (begin + (try! (check-is-owner)) + (ok (map-set approved-contracts owner approved)))) +(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34)))) + (begin + (asserts! (is-eq sender tx-sender) ERR-NOT-AUTHORIZED) + (try! (ft-transfer? bridged-btc amount sender recipient)) + (match memo to-print (print to-print) 0x) + (ok true))) +(define-read-only (get-name) + (ok (var-get token-name))) +(define-read-only (get-symbol) + (ok (var-get token-symbol))) +(define-read-only (get-decimals) + (ok (var-get token-decimals))) +(define-read-only (get-balance (who principal)) + (ok (ft-get-balance bridged-btc who))) +(define-read-only (get-total-supply) + (ok (ft-get-supply bridged-btc))) +(define-read-only (get-token-uri) + (ok (var-get token-uri))) +(define-constant ONE_8 u100000000) +(define-public (mint (amount uint) (recipient principal)) + (begin + (asserts! (or (is-ok (check-is-approved)) (is-ok (check-is-owner))) ERR-NOT-AUTHORIZED) + (ft-mint? bridged-btc amount recipient))) +(define-public (burn (amount uint) (sender principal)) + (begin + (asserts! (or (is-ok (check-is-approved)) (is-ok (check-is-owner))) ERR-NOT-AUTHORIZED) + (ft-burn? bridged-btc amount sender))) +(define-private (pow-decimals) + (pow u10 (unwrap-panic (get-decimals)))) +(define-read-only (fixed-to-decimals (amount uint)) + (/ (* amount (pow-decimals)) ONE_8)) +(define-private (decimals-to-fixed (amount uint)) + (/ (* amount ONE_8) (pow-decimals))) +(define-read-only (get-total-supply-fixed) + (ok (decimals-to-fixed (unwrap-panic (get-total-supply))))) +(define-read-only (get-balance-fixed (account principal)) + (ok (decimals-to-fixed (unwrap-panic (get-balance account))))) +(define-public (transfer-fixed (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34)))) + (transfer (fixed-to-decimals amount) sender recipient memo)) +(define-public (mint-fixed (amount uint) (recipient principal)) + (mint (fixed-to-decimals amount) recipient)) +(define-public (burn-fixed (amount uint) (sender principal)) + (burn (fixed-to-decimals amount) sender)) +(define-private (burn-fixed-many-iter (item {amount: uint, sender: principal})) + (burn-fixed (get amount item) (get sender item))) +(define-public (burn-fixed-many (senders (list 200 {amount: uint, sender: principal}))) + (begin + (asserts! (or (is-ok (check-is-approved)) (is-ok (check-is-owner))) ERR-NOT-AUTHORIZED) + (ok (map burn-fixed-many-iter senders)))) \ No newline at end of file diff --git a/src/stacks-m2m-trait-v1.clar b/src/stacks-m2m-trait-v1.clar index 4d308bc..944a2ab 100644 --- a/src/stacks-m2m-trait-v1.clar +++ b/src/stacks-m2m-trait-v1.clar @@ -1,10 +1,10 @@ (define-trait stacks-m2m-trait-v1 ( (set-payment-address (principal principal) (response bool uint)) - (add-resource ((string-utf8 50) (string-utf8 255) uint) (response bool uint)) + (add-resource (uint (string-utf8 50) (string-utf8 255)) (response bool uint)) (delete-resource (uint) (response bool uint)) (delete-resource-by-name ((string-utf8 50)) (response bool uint)) - (pay-invoice (uint (optional (buff 34))) (response bool uint)) - (pay-invoice-by-resource-name ((string-utf8 50) (optional (buff 34))) (response bool uint)) + ;; (pay-invoice (uint (optional (buff 34))) (response bool uint)) + ;; (pay-invoice-by-resource-name ((string-utf8 50) (optional (buff 34))) (response bool uint)) ) ) diff --git a/src/stacks-m2m-v2.clar b/src/stacks-m2m-v2.clar new file mode 100644 index 0000000..b6c3039 --- /dev/null +++ b/src/stacks-m2m-v2.clar @@ -0,0 +1,415 @@ + +;; title: stacks-m2m-v1 +;; version: 0.0.1 +;; summary: HTTP 402 payments powered by Stacks + +;; traits +;; + +;; getting errors for add-resource, pay-invoice, and pay-invoice-by-name +;; maybe next time! +(impl-trait .stacks-m2m-trait-v1.stacks-m2m-trait-v1) + +;; xBTC SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.token-wbtc +;; aBTC SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.token-abtc + +;; constants +;; + +;; initially scoped to service provider deploying a contract +(define-constant DEPLOYER contract-caller) +(define-constant SELF (as-contract tx-sender)) + +;; math helpers (credit: ALEX) +(define-constant ONE_8 (pow u10 u8)) + +;; errors +(define-constant ERR_UNAUTHORIZED (err u1000)) +(define-constant ERR_INVALID_PARAMS (err u1001)) +(define-constant ERR_NAME_ALREADY_USED (err u1002)) +(define-constant ERR_SAVING_RESOURCE_DATA (err u1003)) +(define-constant ERR_DELETING_RESOURCE_DATA (err u1004)) +(define-constant ERR_RESOURCE_NOT_FOUND (err u1005)) +(define-constant ERR_USER_ALREADY_EXISTS (err u1006)) +(define-constant ERR_SAVING_USER_DATA (err u1007)) +(define-constant ERR_USER_NOT_FOUND (err u1008)) +(define-constant ERR_INVOICE_ALREADY_PAID (err u1009)) +(define-constant ERR_SAVING_INVOICE_DATA (err u1010)) +(define-constant ERR_INVOICE_HASH_NOT_FOUND (err u1011)) +(define-constant ERR_SETTING_MEMO_ON_TRANSFER (err u1012)) + +;; data vars +;; + +;; tracking counts for each map +(define-data-var userCount uint u0) +(define-data-var resourceCount uint u0) +(define-data-var invoiceCount uint u0) + +;; payout address, deployer can set +(define-data-var paymentAddress principal DEPLOYER) + +;; data maps +;; + +;; tracks user indexes by address +(define-map UserIndexes + principal ;; user address + uint ;; user index +) + +;; tracks full user data keyed by user index +;; can iterate over full map with userCount data-var +(define-map UserData + uint ;; user index + { + address: principal, + totalSpent: uint, + totalUsed: uint, + } +) + +;; tracks resource indexes by resource name +(define-map ResourceIndexes + (string-utf8 50) ;; resource name + uint ;; resource index +) + +;; tracks resources added by deployer keyed by resource index +;; can iterate over full map with resourceCount data-var +(define-map ResourceData + uint ;; resource index + { + createdAt: uint, + name: (string-utf8 50), + description: (string-utf8 255), + price: uint, + totalSpent: uint, + totalUsed: uint, + } +) + +;; tracks invoice indexes by invoice ID +(define-map InvoiceIndexes + (buff 32) ;; invoice SHA256 hash + uint ;; invoice index +) + +;; tracks last payment from user for a resource +(define-map RecentPayments + { + userIndex: uint, + resourceIndex: uint, + } + uint ;; invoice index +) + +;; tracks invoices paid by users requesting access to a resource +(define-map InvoiceData + uint ;; invoice index + { + amount: uint, + createdAt: uint, + hash: (buff 32), + userIndex: uint, + resourceName: (string-utf8 50), + resourceIndex: uint, + } +) + +;; read only functions +;; + +;; returns total registered users +(define-read-only (get-total-users) + (var-get userCount) +) + +;; returns user index for address if known +(define-read-only (get-user-index (user principal)) + (map-get? UserIndexes user) +) + +;; returns user data by user index if known +(define-read-only (get-user-data (index uint)) + (map-get? UserData index) +) + +;; returns user data by address if known +(define-read-only (get-user-data-by-address (user principal)) + (get-user-data (unwrap! (get-user-index user) none)) +) + +;; returns total registered resources +(define-read-only (get-total-resources) + (var-get resourceCount) +) + +;; returns resource index for name if known +(define-read-only (get-resource-index (name (string-utf8 50))) + (map-get? ResourceIndexes name) +) + +;; returns resource data by resource index if known +(define-read-only (get-resource (index uint)) + (map-get? ResourceData index) +) + +;; returns resource data by resource name if known +(define-read-only (get-resource-by-name (name (string-utf8 50))) + (get-resource (unwrap! (get-resource-index name) none)) +) + +;; returns total registered invoices +(define-read-only (get-total-invoices) + (var-get invoiceCount) +) + +;; returns invoice index for hash if known +(define-read-only (get-invoice-index (hash (buff 32))) + (map-get? InvoiceIndexes hash) +) + +;; returns invoice data by invoice index if known +(define-read-only (get-invoice (index uint)) + (map-get? InvoiceData index) +) + +;; returns invoice data by invoice hash if known +(define-read-only (get-invoice-by-hash (hash (buff 32))) + (get-invoice (unwrap! (get-invoice-index hash) none)) +) + +;; returns invoice index by user index and resource index if known +(define-read-only (get-recent-payment (resourceIndex uint) (userIndex uint)) + (map-get? RecentPayments { + userIndex: userIndex, + resourceIndex: resourceIndex, + }) +) + +;; returns invoice data by user index and resource index if known +(define-read-only (get-recent-payment-data (resourceIndex uint) (userIndex uint)) + (get-invoice (unwrap! (get-recent-payment resourceIndex userIndex) none)) +) + +;; returns invoice data by user address and resource name if known +(define-read-only (get-recent-payment-data-by-address (name (string-utf8 50)) (user principal)) + (get-recent-payment-data (unwrap! (get-resource-index name) none) (unwrap! (get-user-index user) none)) +) + +;; returns payment address +(define-read-only (get-payment-address) + (some (var-get paymentAddress)) +) + +;; returns a unique but deterministic invoice hash based on: +;; - the bitcoin block and stacks block values (time) +;; - the user address requesting the invoice (who) +;; - the resource the user is requesting (what) +;; - the contract name (where) +(define-read-only (get-invoice-hash (user principal) (resourceIndex uint) (blockHeight uint)) + (let + ( + ;; 32 byte bitcoin hash / stacks hash from block height + (btcBlockHash (unwrap! (get-block-info? burnchain-header-hash blockHeight) none)) + (stxBlockHash (unwrap! (get-block-info? id-header-hash blockHeight) none)) + ;; concatenate bitcoin + stacks hash into single buff + (combinedBlockHash (concat btcBlockHash stxBlockHash)) + ;; 20 byte pubkey from address + (userDestruct (unwrap! (principal-destruct? user) none)) + (userPubkey (get hash-bytes userDestruct)) + ;; 32 byte resource hash, combo of resource name + contract name + (resourceData (unwrap! (get-resource resourceIndex) none)) + (resourceName (unwrap! (to-consensus-buff? (get name resourceData)) none)) + (contractName (unwrap! (to-consensus-buff? SELF) none)) + (resourceInfo (concat resourceName contractName)) + (resourceHash (sha256 resourceInfo)) + ;; concatenate user pubkey + resource hash + (combinedUserHash (concat userPubkey resourceHash)) + ;; concatenate both combined hashes for a single buff + (allCombinedHashes (concat combinedBlockHash combinedUserHash)) + ) + ;; return combined hash + (some (sha256 allCombinedHashes)) + ) +) + +;; public functions +;; + +;; sets payment address used for invoices +;; only accessible by deployer or current payment address +(define-public (set-payment-address (oldAddress principal) (newAddress principal)) + (begin + ;; check that old address matches current address + (asserts! (is-eq oldAddress (var-get paymentAddress)) ERR_UNAUTHORIZED) + ;; address cannot be the same + (asserts! (not (is-eq oldAddress newAddress)) ERR_UNAUTHORIZED) + ;; check if caller matches deployer or oldAddress + (asserts! (or + (is-eq contract-caller oldAddress) + (try! (is-deployer)) + ) ERR_UNAUTHORIZED) + ;; set new payment address + (ok (var-set paymentAddress newAddress)) + ) +) + +;; adds active resource that invoices can be generated for +;; only accessible by deployer +(define-public (add-resource (price uint) (name (string-utf8 50)) (description (string-utf8 255))) + (let + ( + (newCount (+ (get-total-resources) u1)) + ) + ;; check if caller matches deployer + (try! (is-deployer)) + ;; check all values are provided + (asserts! (> (len name) u0) ERR_INVALID_PARAMS) + (asserts! (> (len description) u0) ERR_INVALID_PARAMS) + (asserts! (> price u0) ERR_INVALID_PARAMS) + ;; update ResourceIndexes map, check name is unique + (asserts! (map-insert ResourceIndexes name newCount) ERR_NAME_ALREADY_USED) + ;; update ResourceData map + (asserts! (map-insert ResourceData + newCount + { + createdAt: block-height, + name: name, + description: description, + price: price, + totalSpent: u0, + totalUsed: u0, + } + ) ERR_SAVING_RESOURCE_DATA) + ;; increment resourceCount + (var-set resourceCount newCount) + ;; return new count + (ok newCount) + ) +) + +;; deletes active resource that invoices can be generated against +;; does not delete unique name, rule stays enforced to prevent +;; any bait/switch and other weirdness while we're exploring +(define-public (delete-resource (index uint)) + (begin + ;; check if caller matches deployer + (try! (is-deployer)) + ;; check provided index is within range + (asserts! (and (> index u0) (<= index (var-get resourceCount))) ERR_INVALID_PARAMS) + ;; return and delete resource data from map + (ok (asserts! (map-delete ResourceData index) ERR_DELETING_RESOURCE_DATA)) + ) +) + +;; adapter to allow deleting by name instead of index +(define-public (delete-resource-by-name (name (string-utf8 50))) + (delete-resource (unwrap! (get-resource-index name) ERR_INVALID_PARAMS)) +) + +;; allows a user to pay an invoice for a resource +(define-public (pay-invoice (resourceIndex uint) (memo (optional (buff 34)))) + (let + ( + (newCount (+ (get-total-invoices) u1)) + (lastAnchoredBlock (- block-height u1)) + (resourceData (unwrap! (get-resource resourceIndex) ERR_RESOURCE_NOT_FOUND)) + (userIndex (unwrap! (get-or-create-user contract-caller) ERR_USER_NOT_FOUND)) + (userData (unwrap! (get-user-data userIndex) ERR_USER_NOT_FOUND)) + (invoiceHash (unwrap! (get-invoice-hash contract-caller resourceIndex lastAnchoredBlock) ERR_INVOICE_HASH_NOT_FOUND)) + ) + ;; update InvoiceIndexes map, check invoice hash is unique + (asserts! (map-insert InvoiceIndexes invoiceHash newCount) ERR_INVOICE_ALREADY_PAID) + ;; update InvoiceData map + (asserts! (map-insert InvoiceData + newCount + { + amount: (get price resourceData), + createdAt: block-height, + hash: invoiceHash, + userIndex: userIndex, + resourceName: (get name resourceData), + resourceIndex: resourceIndex, + } + ) ERR_SAVING_INVOICE_DATA) + ;; update RecentPayments map + (map-set RecentPayments + { + userIndex: userIndex, + resourceIndex: resourceIndex, + } + newCount + ) + ;; update UserData map + (map-set UserData + userIndex + (merge userData { + totalSpent: (+ (get totalSpent userData) (get price resourceData)), + totalUsed: (+ (get totalUsed userData) u1) + }) + ) + ;; update ResourceData map + (map-set ResourceData + resourceIndex + (merge resourceData { + totalSpent: (+ (get totalSpent resourceData) (get price resourceData)), + totalUsed: (+ (get totalUsed resourceData) u1) + }) + ) + ;; increment counter + (var-set invoiceCount newCount) + ;; print updated details + (print { + invoiceHash: invoiceHash, + resourceData: (get-resource resourceIndex), + userData: (get-user-data userIndex) + }) + ;; make transfer + (if (is-some memo) + (try! (stx-transfer-memo? (get price resourceData) contract-caller (var-get paymentAddress) (unwrap! memo ERR_SETTING_MEMO_ON_TRANSFER))) + (try! (stx-transfer? (get price resourceData) contract-caller (var-get paymentAddress))) + ) + ;; return new count + (ok newCount) + ) +) + +(define-public (pay-invoice-by-resource-name (name (string-utf8 50)) (memo (optional (buff 34)))) + (pay-invoice (unwrap! (get-resource-index name) ERR_RESOURCE_NOT_FOUND) memo) +) + +;; private functions +;; + +(define-private (is-deployer) + (ok (asserts! (is-eq contract-caller DEPLOYER) ERR_UNAUTHORIZED)) +) + +(define-private (get-or-create-user (address principal)) + (match (map-get? UserIndexes address) + value (ok value) ;; return index if found + (let + ( + ;; increment current index + (newCount (+ (get-total-users) u1)) + ) + ;; update UserIndexes map, check address is unique + (asserts! (map-insert UserIndexes address newCount) ERR_SAVING_USER_DATA) + ;; update UserData map + (asserts! (map-insert UserData + newCount + { + address: address, + totalSpent: u0, + totalUsed: u0, + } + ) ERR_SAVING_USER_DATA) + ;; save new index + (var-set userCount newCount) + ;; return new index + (ok newCount) + ) + ) +) diff --git a/tests/stacks-m2m-v2.test.ts b/tests/stacks-m2m-v2.test.ts new file mode 100644 index 0000000..3849315 --- /dev/null +++ b/tests/stacks-m2m-v2.test.ts @@ -0,0 +1,909 @@ +import { initSimnet } from "@hirosystems/clarinet-sdk"; +import { Cl, cvToValue } from "@stacks/transactions"; +import { describe, expect, it } from "vitest"; + +enum ErrCode { + ERR_UNAUTHORIZED = 1000, + ERR_INVALID_PARAMS, + ERR_NAME_ALREADY_USED, + ERR_SAVING_RESOURCE_DATA, + ERR_DELETING_RESOURCE_DATA, + ERR_RESOURCE_NOT_FOUND, + ERR_USER_ALREADY_EXISTS, + ERR_SAVING_USER_DATA, + ERR_USER_NOT_FOUND, + ERR_INVOICE_ALREADY_PAID, + ERR_SAVING_INVOICE_DATA, + ERR_INVOICE_HASH_NOT_FOUND, + ERR_SETTING_MEMO_ON_TRANSFER, +} + +const createResource = (name: string, desc: string, price: number) => { + return [Cl.stringUtf8(name), Cl.stringUtf8(desc), Cl.uint(price)]; +}; + +const defaultPrice = 1_000_000; // 1 STX + +const testResource = [ + Cl.stringUtf8("Bitcoin Face"), + Cl.stringUtf8("Generate a unique Bitcoin face."), + Cl.uint(defaultPrice), +]; + +// based on hex value printed to console in first run +// curious to see if this is the same every time +// before we have a local function to compute the same data +// (some 0x053637c68fd20a0bdeef9712f0eb6c2b5c041a7f0ee6a6aed601267c25a39cb6) +// const expectedBlock0Resource0 = Buffer.from("053637c68fd20a0bdeef9712f0eb6c2b5c041a7f0ee6a6aed601267c25a39cb6", "hex") +// incremented resource count (and others) to start at 1 +// (some 0x38bc32acb79a15b7a0b04f4268eba0c5be61d17e0629aac508da25d8884b017e) +// changed hashing algorithm, now composed of: resource name, contract name, stacks block hash, bitcoin block hash, user pubkey +// (some 0x520b34a624e73b0533db4475f260febb048e7eb150f8773779b9fd9dab85d652) +// (some 0xffc41d187c6b7bdd89fec3b4cdf967f7ab81d1efb3358cee6df8f08c9d1c76e9) +const expectedBlock0Resource1 = Buffer.from( + "ffc41d187c6b7bdd89fec3b4cdf967f7ab81d1efb3358cee6df8f08c9d1c76e9", + "hex" +); + +describe("Adding a resource", () => { + it("add-resource() fails if not called by deployer", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const address1 = accounts.get("wallet_1")!; + // ACT + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + address1 + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_UNAUTHORIZED)); + }); + + it("add-resource() fails if name is blank", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + // ACT + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + createResource("", "description", defaultPrice), + deployer + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_INVALID_PARAMS)); + }); + + it("add-resource() fails if description is blank", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + // ACT + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + createResource("name", "", defaultPrice), + deployer + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_INVALID_PARAMS)); + }); + + it("add-resource() fails if price is 0", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + // ACT + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + createResource("name", "description", 0), + deployer + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_INVALID_PARAMS)); + }); + it("add-resource() fails if name already used", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const expectedCount = 1; + // ACT + const firstResponse = simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + const secondResponse = simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // ASSERT + expect(firstResponse.result).toBeOk(Cl.uint(expectedCount)); + expect(secondResponse.result).toBeErr( + Cl.uint(ErrCode.ERR_NAME_ALREADY_USED) + ); + }); + + it("add-resource() succeeds and increments resource count", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const expectedCount = 1; + // ACT + const oldCount = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-total-resources", + [], + deployer + ); + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + const newCount = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-total-resources", + [], + deployer + ); + // ASSERT + expect(oldCount.result).toBeUint(expectedCount - 1); + expect(response.result).toBeOk(Cl.uint(expectedCount)); + expect(newCount.result).toBeUint(expectedCount); + }); +}); + +describe("Deleting a Resource", () => { + it("delete-resource() fails if not called by deployer", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const address1 = accounts.get("wallet_1")!; + // ACT + // create resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // delete resource + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "delete-resource", + [Cl.uint(1)], + address1 + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_UNAUTHORIZED)); + }); + + it("delete-resource() fails if provided index is greater than current resource count", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + // ACT + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "delete-resource", + [Cl.uint(1)], + deployer + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_INVALID_PARAMS)); + }); + + it("delete-resource() fails if executed twice on the same resource", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + // ACT + // create resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // delete resource + simnet.callPublicFn( + "stacks-m2m-v1", + "delete-resource", + [Cl.uint(1)], + deployer + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // delete resource again + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "delete-resource", + [Cl.uint(1)], + deployer + ); + // ASSERT + expect(response.result).toBeErr( + Cl.uint(ErrCode.ERR_DELETING_RESOURCE_DATA) + ); + }); + + it("delete-resource-by-name() fails if not called by deployer", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const address1 = accounts.get("wallet_1")!; + // ACT + // create resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // delete resource + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "delete-resource-by-name", + [Cl.stringUtf8("Bitcoin Face")], + address1 + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_UNAUTHORIZED)); + }); + + it("delete-resource-by-name() fails if provided name is not found", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + // ACT + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "delete-resource-by-name", + [Cl.stringUtf8("Nothingburger")], + deployer + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_INVALID_PARAMS)); + }); + + it("pay-invoice() fails for a deleted resource", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const address1 = accounts.get("wallet_1")!; + const deployer = accounts.get("deployer")!; + // ACT + // create resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // delete resource + simnet.callPublicFn( + "stacks-m2m-v1", + "delete-resource", + [Cl.uint(1)], + deployer + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // pay invoice + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + Cl.none(), // memo + ], + address1 + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_RESOURCE_NOT_FOUND)); + }); +}); + +describe("Setting a Payment Address", () => { + it("set-payment-address() fails if not called by deployer", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const address1 = accounts.get("wallet_1")!; + // ACT + // get current payment address + const currentPaymentAddressResponse = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-payment-address", + [], + address1 + ); + // parse into an object we can read + const currentPaymentAddress = cvToValue( + currentPaymentAddressResponse.result + ); + // set payment address + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "set-payment-address", + [ + Cl.standardPrincipal(currentPaymentAddress.value), + Cl.standardPrincipal(address1), + ], + address1 + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_UNAUTHORIZED)); + }); + + it("set-payment-address() fails if old address param is incorrect", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const address1 = accounts.get("wallet_1")!; + const deployer = accounts.get("deployer")!; + // ACT + // set payment address + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "set-payment-address", + [Cl.standardPrincipal(address1), Cl.standardPrincipal(address1)], + deployer + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_UNAUTHORIZED)); + }); + + it("set-payment-address() succeeds if called by the deployer", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const address1 = accounts.get("wallet_1")!; + + // ACT + // get current payment address + const currentPaymentAddressResponse = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-payment-address", + [], + address1 + ); + // parse into an object we can read + const currentPaymentAddress = cvToValue( + currentPaymentAddressResponse.result + ); + // set payment address + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "set-payment-address", + [ + Cl.standardPrincipal(currentPaymentAddress.value), + Cl.standardPrincipal(address1), + ], + deployer + ); + // ASSERT + expect(response.result).toBeOk(Cl.bool(true)); + }); + + it("set-payment-address() succeeds if called by current payment address", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const address1 = accounts.get("wallet_1")!; + const address2 = accounts.get("wallet_2")!; + + // ACT + // get current payment address + const currentPaymentAddressResponse = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-payment-address", + [], + address1 + ); + // parse into an object we can read + const currentPaymentAddress = cvToValue( + currentPaymentAddressResponse.result + ); + // set payment address + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "set-payment-address", + [ + Cl.standardPrincipal(currentPaymentAddress.value), + Cl.standardPrincipal(address1), + ], + deployer + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // get current payment address again + const updatedPaymentAddressResponse = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-payment-address", + [], + address1 + ); + // parse into an object we can read + const updatedPaymentAddress = cvToValue( + updatedPaymentAddressResponse.result + ); + // set payment address again + const secondResponse = simnet.callPublicFn( + "stacks-m2m-v1", + "set-payment-address", + [Cl.standardPrincipal(address1), Cl.standardPrincipal(address2)], + address1 + ); + // ASSERT + expect(updatedPaymentAddress.value).toEqual(address1); + expect(response.result).toBeOk(Cl.bool(true)); + expect(secondResponse.result).toBeOk(Cl.bool(true)); + }); +}); + +describe("Generating an invoice hash", () => { + it("get-invoice-hash() returns none if resource is not found", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + // ACT + const response = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-invoice-hash", + [ + Cl.standardPrincipal(deployer), // user + Cl.uint(0), // resource index + Cl.uint(0), // block height + ], + deployer + ); + // ASSERT + expect(response.result).toBeNone(); + }); + + it("get-invoice-hash() succeeds and returns the correct value", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + // ACT + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + const response = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-invoice-hash", + [ + Cl.standardPrincipal(deployer), // user + Cl.uint(1), // resource index + Cl.uint(0), // block height + ], + deployer + ); + // ASSERT + expect(response.result).toBeSome(Cl.buffer(expectedBlock0Resource1)); + }); + + it("get-invoice-hash() succeeds and returns the correct value after the chain progresses", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + // ACT + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + simnet.mineEmptyBlocks(5000); + const response = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-invoice-hash", + [ + Cl.standardPrincipal(deployer), // user + Cl.uint(1), // resource index + Cl.uint(0), // block height + ], + deployer + ); + // ASSERT + expect(response.result).toBeSome(Cl.buffer(expectedBlock0Resource1)); + }); + + it("get-invoice-hash() succeeds and generates unique values for different users at different block heights", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const address1 = accounts.get("wallet_1")!; + const address2 = accounts.get("wallet_2")!; + const wallets = [deployer, address1, address2]; + + // ACT + // add a resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // create an array of invoice hashes for 500 blocks + const invoiceHashes = []; + for (const wallet of wallets) { + for (let i = 0; i < 500; i++) { + const response = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-invoice-hash", + [ + Cl.standardPrincipal(wallet), // user + Cl.uint(1), // resource index + Cl.uint(i), // block height + ], + wallet + ); + invoiceHashes.push(response.result); + } + } + + // ASSERT + // check that each invoice hash is unique + const uniqueInvoiceHashes = new Set(invoiceHashes); + expect(uniqueInvoiceHashes.size).toEqual(invoiceHashes.length); + }); + + it("get-invoice-hash() succeeds and generates consistent values for different users at different block heights", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const address1 = accounts.get("wallet_1")!; + const address2 = accounts.get("wallet_2")!; + const wallets = [deployer, address1, address2]; + + // ACT + // add a resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // create an array of invoice hashes for 500 blocks + const firstInvoiceHashes = []; + for (const wallet of wallets) { + for (let i = 0; i < 500; i++) { + const response = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-invoice-hash", + [ + Cl.standardPrincipal(wallet), // user + Cl.uint(1), // resource index + Cl.uint(i), // block height + ], + wallet + ); + firstInvoiceHashes.push(response.result); + } + } + // progress the chain + simnet.mineEmptyBlocks(5000); + // create an array of invoice hashes for 500 blocks + const secondInvoiceHashes = []; + for (const wallet of wallets) { + for (let i = 0; i < 500; i++) { + const response = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-invoice-hash", + [ + Cl.standardPrincipal(wallet), // user + Cl.uint(1), // resource index + Cl.uint(i), // block height + ], + wallet + ); + secondInvoiceHashes.push(response.result); + } + } + + // ASSERT + // check that the arrays are equal + expect(firstInvoiceHashes).toEqual(secondInvoiceHashes); + }); +}); + +describe("Paying an invoice", () => { + it("pay-invoice() fails if resource is not found", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const address1 = accounts.get("wallet_1")!; + // ACT + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(0), // resource index + Cl.none(), // memo + ], + address1 + ); + // ASSERT + expect(response.result).toBeErr(Cl.uint(ErrCode.ERR_RESOURCE_NOT_FOUND)); + }); + // not expecting ERR_USER_NOT_FOUND, not sure if we can force? + // it("pay-invoice() fails if user cannot be created or found", async () => {}) + // not expecting ERR_INVOICE_HASH_NOT_FOUND, same as above + // it("pay-invoice() fails if invoice hash cannot be found", async () => {}) + // not expecting ERR_SAVING_INVOICE in two spots, same as above + // it("pay-invoice() fails if invoice cannot be saved", async () => {}) + it("pay-invoice() succeeds and returns invoice count without memo", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const address1 = accounts.get("wallet_1")!; + const expectedCount = 1; + // ACT + // add a resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // pay invoice for resource + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + Cl.none(), // memo + ], + address1 + ); + // ASSERT + expect(response.result).toBeOk(Cl.uint(expectedCount)); + }); + + it("pay-invoice() succeeds and returns invoice count with memo", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const address1 = accounts.get("wallet_1")!; + const expectedCount = 1; + const memo = Buffer.from("This is a memo test!"); + // ACT + // add a resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // pay invoice for resource + const response = simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + Cl.some(Cl.buffer(memo)), // memo + ], + address1 + ); + // ASSERT + expect(response.result).toBeOk(Cl.uint(expectedCount)); + }); + + it("pay-invoice() succeeds and returns invoice count with memo over several blocks", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const deployer = accounts.get("deployer")!; + const address1 = accounts.get("wallet_1")!; + const address2 = accounts.get("wallet_2")!; + const address3 = accounts.get("wallet_3")!; + const expectedCount = 1; + const memo = Cl.none(); + // ACT + // add a resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // pay invoice once for 3 users + const blockResponses = [ + simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + memo, // memo + ], + address1 + ), + simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + memo, // memo + ], + address2 + ), + simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + memo, // memo + ], + address3 + ), + ]; + // progress by 5000 blocks + simnet.mineEmptyBlocks(5000); + // pay invoice again for 3 users + blockResponses.push( + simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + memo, // memo + ], + address1 + ), + simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + memo, // memo + ], + address2 + ), + simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + memo, // memo + ], + address3 + ) + ); + // ASSERT + for (let i = 0; i < blockResponses.length; i++) { + expect(blockResponses[i].result).toBeOk(Cl.uint(expectedCount + i)); + } + }); + + it("pay-invoice() succeeds and updates resource and user data", async () => { + // ARRANGE + const simnet = await initSimnet(); + const accounts = simnet.getAccounts(); + const address1 = accounts.get("wallet_1")!; + const address2 = accounts.get("wallet_2")!; + const deployer = accounts.get("deployer")!; + const memo = Cl.none(); + // ACT + // add a resource + simnet.callPublicFn( + "stacks-m2m-v1", + "add-resource", + testResource, + deployer + ); + // pay invoice for resource + simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + memo, // memo + ], + address1 + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // pay invoice again for resource + simnet.callPublicFn( + "stacks-m2m-v1", + "pay-invoice", + [ + Cl.uint(1), // resource index + memo, // memo + ], + address2 + ); + // progress the chain + simnet.mineEmptyBlocks(5000); + // get resource + const resourceResponse = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-resource", + [Cl.uint(1)], + deployer + ); + // get user + const userResponseOne = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-user-data-by-address", + [Cl.standardPrincipal(address1)], + deployer + ); + const userResponseTwo = simnet.callReadOnlyFn( + "stacks-m2m-v1", + "get-user-data-by-address", + [Cl.standardPrincipal(address2)], + deployer + ); + // ASSERT + expect(resourceResponse.result).toBeSome( + Cl.tuple({ + createdAt: Cl.uint(2), + description: Cl.stringUtf8("Generate a unique Bitcoin face."), + name: Cl.stringUtf8("Bitcoin Face"), + price: Cl.uint(defaultPrice), + totalSpent: Cl.uint(defaultPrice * 2), + totalUsed: Cl.uint(2), + }) + ); + expect(userResponseOne.result).toBeSome( + Cl.tuple({ + address: Cl.standardPrincipal(address1), + totalSpent: Cl.uint(defaultPrice), + totalUsed: Cl.uint(1), + }) + ); + expect(userResponseTwo.result).toBeSome( + Cl.tuple({ + address: Cl.standardPrincipal(address2), + totalSpent: Cl.uint(defaultPrice), + totalUsed: Cl.uint(1), + }) + ); + }); +});