diff --git a/contracts/.eslintignore b/contracts/.eslintignore new file mode 100644 index 000000000..2bf7026ea --- /dev/null +++ b/contracts/.eslintignore @@ -0,0 +1 @@ +tests/clarigen-types.ts \ No newline at end of file diff --git a/contracts/Clarinet.toml b/contracts/Clarinet.toml index 0a009c036..bb40e908c 100644 --- a/contracts/Clarinet.toml +++ b/contracts/Clarinet.toml @@ -10,6 +10,11 @@ path = 'contracts/sbtc-bootstrap-signers.clar' clarity_version = 2 epoch = 2.5 +[contracts.sbtc-deposit] +path = 'contracts/sbtc-deposit.clar' +clarity_version = 2 +epoch = 2.5 + [contracts.sbtc-registry] path = 'contracts/sbtc-registry.clar' clarity_version = 2 diff --git a/contracts/contracts/sbtc-deposit.clar b/contracts/contracts/sbtc-deposit.clar new file mode 100644 index 000000000..c3cdd1183 --- /dev/null +++ b/contracts/contracts/sbtc-deposit.clar @@ -0,0 +1,59 @@ +;; sBTC Deposit contract + +;; constants +(define-constant txid-length u32) + +;; error codes +(define-constant ERR_TXID_LEN (err u300)) +(define-constant ERR_DEPOSIT_REPLAY (err u301)) + +;; data vars +;; + +;; data maps +;; + +;; public functions + +;; Accept a new deposit request +;; Note that this function can only be called by the current +;; bootstrap signer set address - it cannot be called by users directly. +;; +;; This function handles the validation & minting of sBTC, it then calls +;; into the sbtc-registry contract to update the state of the protocol +(define-public (complete-deposit-wrapper (txid (buff 32)) (vout-index uint) (amount uint) (recipient principal)) + (let + ( + (replay-fetch (contract-call? .sbtc-registry get-completed-deposit txid vout-index)) + ) + + ;; TODO + ;; Check that tx-sender is the bootstrap signer + + ;; Check that txid is the correct length + (asserts! (is-eq (len txid) txid-length) ERR_TXID_LEN) + + ;; Assert that the deposit has not already been completed (no replay) + (asserts! (is-none replay-fetch) ERR_DEPOSIT_REPLAY) + + ;; TODO + ;; Mint the sBTC to the recipient + + ;; Complete the deposit + (ok (contract-call? .sbtc-registry complete-deposit txid vout-index amount recipient)) + ) +) + +;; Accept multiple new deposit requests +;; Note that this function can only be called by the current +;; bootstrap signer set address - it cannot be called by users directly. +;; +;; This function handles the validation & minting of sBTC by handling multiple (up to 1000) deposits at a time, +;; it then calls into the sbtc-registry contract to update the state of the protocol + +;; read only functions +;; + +;; private functions +;; + diff --git a/contracts/contracts/sbtc-registry.clar b/contracts/contracts/sbtc-registry.clar index a12a781a4..5d330732d 100644 --- a/contracts/contracts/sbtc-registry.clar +++ b/contracts/contracts/sbtc-registry.clar @@ -3,7 +3,7 @@ ;; Error codes ;; Contract caller is not authorized -(define-constant ERR_UNAUTHORIZED u400) +(define-constant ERR_UNAUTHORIZED (err u400)) ;; Invalid request ID (define-constant ERR_INVALID_REQUEST_ID (err u401)) @@ -35,6 +35,37 @@ ;; the deposit was accepted. (define-map withdrawal-status uint bool) +;; Internal data structure to store completed +;; deposit requests & avoid replay attacks. +(define-map completed-deposits {txid: (buff 32), vout-index: uint} + { + amount: uint, + recipient: principal + } +) + +;; Read-only functions + +;; Get a withdrawal request by its ID. +;; +;; This function returns the fields of the withrawal +;; request, along with its status. +(define-read-only (get-withdrawal-request (id uint)) + (match (map-get? withdrawal-requests id) + request (some (merge request { + status: (map-get? withdrawal-status id) + })) + none + ) +) + +;; Get a completed deposit by its transaction ID & vout index. +;; +;; This function returns the fields of the completed-deposits map. +(define-read-only (get-completed-deposit (txid (buff 32)) (vout-index uint)) + (map-get? completed-deposits {txid: txid, vout-index: vout-index}) +) + ;; Public functions ;; Store a new withdrawal request. @@ -79,18 +110,31 @@ ) ) -;; Read-only functions - -;; Get a withdrawal request by its ID. +;; Store a new insert request. +;; Note that this function can only be called by other sBTC +;; contracts (specifically the current version of the deposit contract) +;; - it cannot be called by users directly. ;; -;; This function returns the fields of the withrawal -;; request, along with it's status. -(define-read-only (get-withdrawal-request (id uint)) - (match (map-get? withdrawal-requests id) - request (some (merge request { - status: (map-get? withdrawal-status id) - })) - none +;; This function does not handle validation or moving the funds. +;; Instead, it is purely for the purpose of storing the completed deposit. +(define-public (complete-deposit + (txid (buff 32)) + (vout-index uint) + (amount uint) + (recipient principal) + ) + (begin + (try! (validate-caller)) + (map-insert completed-deposits {txid: txid, vout-index: vout-index} { + amount: amount, + recipient: recipient + }) + (print { + topic: "completed-deposit", + txid: txid, + vout-index: vout-index + }) + (ok true) ) ) diff --git a/contracts/deployments/default.simnet-plan.yaml b/contracts/deployments/default.simnet-plan.yaml index 095f9b177..165bd9d2d 100644 --- a/contracts/deployments/default.simnet-plan.yaml +++ b/contracts/deployments/default.simnet-plan.yaml @@ -59,4 +59,9 @@ plan: emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM path: contracts/sbtc-registry.clar clarity-version: 2 + - emulated-contract-publish: + contract-name: sbtc-deposit + emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + path: contracts/sbtc-deposit.clar + clarity-version: 2 epoch: "2.5" diff --git a/contracts/tests/clarigen-types.ts b/contracts/tests/clarigen-types.ts index 9584c5121..df5789246 100644 --- a/contracts/tests/clarigen-types.ts +++ b/contracts/tests/clarigen-types.ts @@ -7,6 +7,85 @@ import type { } from "@clarigen/core"; export const contracts = { + sbtcDeposit: { + functions: { + completeDepositWrapper: { + name: "complete-deposit-wrapper", + access: "public", + args: [ + { name: "txid", type: { buffer: { length: 32 } } }, + { name: "vout-index", type: "uint128" }, + { name: "amount", type: "uint128" }, + { name: "recipient", type: "principal" }, + ], + outputs: { + type: { + response: { + ok: { + response: { + ok: "bool", + error: { response: { ok: "none", error: "uint128" } }, + }, + }, + error: "uint128", + }, + }, + }, + } as TypedAbiFunction< + [ + txid: TypedAbiArg, + voutIndex: TypedAbiArg, + amount: TypedAbiArg, + recipient: TypedAbiArg, + ], + Response>, bigint> + >, + }, + maps: {}, + variables: { + ERR_DEPOSIT_REPLAY: { + name: "ERR_DEPOSIT_REPLAY", + type: { + response: { + ok: "none", + error: "uint128", + }, + }, + access: "constant", + } as TypedAbiVariable>, + ERR_TXID_LEN: { + name: "ERR_TXID_LEN", + type: { + response: { + ok: "none", + error: "uint128", + }, + }, + access: "constant", + } as TypedAbiVariable>, + txidLength: { + name: "txid-length", + type: "uint128", + access: "constant", + } as TypedAbiVariable, + }, + constants: { + ERR_DEPOSIT_REPLAY: { + isOk: false, + value: 301n, + }, + ERR_TXID_LEN: { + isOk: false, + value: 300n, + }, + txidLength: 32n, + }, + non_fungible_tokens: [], + fungible_tokens: [], + epoch: "Epoch25", + clarity_version: "Clarity2", + contractName: "sbtc-deposit", + }, sbtcRegistry: { functions: { incrementLastWithdrawalRequestId: { @@ -19,8 +98,41 @@ export const contracts = { name: "validate-caller", access: "private", args: [], - outputs: { type: { response: { ok: "bool", error: "uint128" } } }, - } as TypedAbiFunction<[], Response>, + outputs: { + type: { + response: { + ok: "bool", + error: { response: { ok: "none", error: "uint128" } }, + }, + }, + }, + } as TypedAbiFunction<[], Response>>, + completeDeposit: { + name: "complete-deposit", + access: "public", + args: [ + { name: "txid", type: { buffer: { length: 32 } } }, + { name: "vout-index", type: "uint128" }, + { name: "amount", type: "uint128" }, + { name: "recipient", type: "principal" }, + ], + outputs: { + type: { + response: { + ok: "bool", + error: { response: { ok: "none", error: "uint128" } }, + }, + }, + }, + } as TypedAbiFunction< + [ + txid: TypedAbiArg, + voutIndex: TypedAbiArg, + amount: TypedAbiArg, + recipient: TypedAbiArg, + ], + Response> + >, createWithdrawalRequest: { name: "create-withdrawal-request", access: "public", @@ -39,7 +151,14 @@ export const contracts = { }, { name: "height", type: "uint128" }, ], - outputs: { type: { response: { ok: "uint128", error: "uint128" } } }, + outputs: { + type: { + response: { + ok: "uint128", + error: { response: { ok: "none", error: "uint128" } }, + }, + }, + }, } as TypedAbiFunction< [ amount: TypedAbiArg, @@ -54,7 +173,34 @@ export const contracts = { >, height: TypedAbiArg, ], - Response + Response> + >, + getCompletedDeposit: { + name: "get-completed-deposit", + access: "read_only", + args: [ + { name: "txid", type: { buffer: { length: 32 } } }, + { name: "vout-index", type: "uint128" }, + ], + outputs: { + type: { + optional: { + tuple: [ + { name: "amount", type: "uint128" }, + { name: "recipient", type: "principal" }, + ], + }, + }, + }, + } as TypedAbiFunction< + [ + txid: TypedAbiArg, + voutIndex: TypedAbiArg, + ], + { + amount: bigint; + recipient: string; + } | null >, getWithdrawalRequest: { name: "get-withdrawal-request", @@ -98,6 +244,30 @@ export const contracts = { >, }, maps: { + completedDeposits: { + name: "completed-deposits", + key: { + tuple: [ + { name: "txid", type: { buffer: { length: 32 } } }, + { name: "vout-index", type: "uint128" }, + ], + }, + value: { + tuple: [ + { name: "amount", type: "uint128" }, + { name: "recipient", type: "principal" }, + ], + }, + } as TypedAbiMap< + { + txid: Uint8Array; + voutIndex: number | bigint; + }, + { + amount: bigint; + recipient: string; + } + >, withdrawalRequests: { name: "withdrawal-requests", key: "uint128", @@ -150,9 +320,14 @@ export const contracts = { } as TypedAbiVariable>, ERR_UNAUTHORIZED: { name: "ERR_UNAUTHORIZED", - type: "uint128", + type: { + response: { + ok: "none", + error: "uint128", + }, + }, access: "constant", - } as TypedAbiVariable, + } as TypedAbiVariable>, lastWithdrawalRequestId: { name: "last-withdrawal-request-id", type: "uint128", @@ -164,7 +339,10 @@ export const contracts = { isOk: false, value: 401n, }, - ERR_UNAUTHORIZED: 400n, + ERR_UNAUTHORIZED: { + isOk: false, + value: 400n, + }, lastWithdrawalRequestId: 0n, }, non_fungible_tokens: [], @@ -176,8 +354,16 @@ export const contracts = { } as const; export const accounts = { - wallet_2: { - address: "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", + wallet_1: { + address: "ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5", + balance: "100000000000000", + }, + wallet_8: { + address: "ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP", + balance: "100000000000000", + }, + wallet_4: { + address: "ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND", balance: "100000000000000", }, deployer: { @@ -192,33 +378,26 @@ export const accounts = { address: "ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC", balance: "100000000000000", }, - wallet_5: { - address: "ST2REHHS5J3CERCRBEPMGH7921Q6PYKAADT7JP2VB", - balance: "100000000000000", - }, - wallet_7: { - address: "ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ", - balance: "100000000000000", - }, - wallet_8: { - address: "ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP", - balance: "100000000000000", - }, - wallet_1: { - address: "ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5", + wallet_2: { + address: "ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG", balance: "100000000000000", }, wallet_6: { address: "ST3AM1A56AK2C1XAFJ4115ZSV26EB49BVQ10MGCS0", balance: "100000000000000", }, - wallet_4: { - address: "ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND", + wallet_7: { + address: "ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ", + balance: "100000000000000", + }, + wallet_5: { + address: "ST2REHHS5J3CERCRBEPMGH7921Q6PYKAADT7JP2VB", balance: "100000000000000", }, } as const; export const identifiers = { + sbtcDeposit: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sbtc-deposit", sbtcRegistry: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sbtc-registry", } as const; @@ -229,6 +408,12 @@ export const simnet = { } as const; export const deployments = { + sbtcDeposit: { + devnet: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sbtc-deposit", + simnet: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sbtc-deposit", + testnet: null, + mainnet: null, + }, sbtcRegistry: { devnet: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sbtc-registry", simnet: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sbtc-registry", diff --git a/contracts/tests/helpers.ts b/contracts/tests/helpers.ts index a14902a09..2c4d5b9ad 100644 --- a/contracts/tests/helpers.ts +++ b/contracts/tests/helpers.ts @@ -12,6 +12,7 @@ export const bob = accounts.wallet_2.address; export const charlie = accounts.wallet_3.address; export const registry = contracts.sbtcRegistry; +export const deposit = contracts.sbtcDeposit; export const controllerId = `${accounts.deployer.address}.controller`; @@ -26,6 +27,7 @@ export function getWithdrawalRequest(id: number | bigint) { return mapGet(registry.identifier, registry.maps.withdrawalRequests, id); } + /** * Helper function to convert a BTC address string to a PoX address * in the "JS Native" format diff --git a/contracts/tests/sbtc-deposit.test.ts b/contracts/tests/sbtc-deposit.test.ts new file mode 100644 index 000000000..b1056510b --- /dev/null +++ b/contracts/tests/sbtc-deposit.test.ts @@ -0,0 +1,98 @@ +import { + alice, + deposit, + registry, +} from "./helpers"; +import { test, expect, describe } from "vitest"; +import { txOk, filterEvents, rov, txErr } from "@clarigen/test"; +import { CoreNodeEventType, cvToValue } from '@clarigen/core'; + + +describe("sBTC deposit contract", () => { + describe("complete deposit contract setup (err 300)", () => { + + test("Fail complete-deposit-wrapper invalid txid length", () => { + const receipt = txErr( + deposit.completeDepositWrapper({ + txid: new Uint8Array(31).fill(0), + voutIndex: 0, + amount: 0, + recipient: alice, + }), + alice + ); + expect(receipt.value).toEqual(deposit.constants.ERR_TXID_LEN.value); + }); + + test("Fail complete-deposit-wrapper replay deposit (err 301)", () => { + const receipt0 = txOk( + deposit.completeDepositWrapper({ + txid: new Uint8Array(32).fill(0), + voutIndex: 0, + amount: 0, + recipient: alice, + }), + alice + ); + expect(receipt0.value).toEqual(true); + const receipt1 = txErr( + deposit.completeDepositWrapper({ + txid: new Uint8Array(32).fill(0), + voutIndex: 0, + amount: 0, + recipient: alice, + }), + alice + ); + expect(receipt1.value).toEqual(deposit.constants.ERR_DEPOSIT_REPLAY.value); + }); + + test("Call complete-deposit-wrapper placeholder, check print", () => { + const receipt = txOk( + deposit.completeDepositWrapper({ + txid: new Uint8Array(32).fill(0), + voutIndex: 0, + amount: 0, + recipient: alice, + }), + alice + ); + const printEvents = filterEvents(receipt.events, CoreNodeEventType.ContractEvent); + const [print] = printEvents; + const printData = cvToValue<{ + topic: string; + txid: string; + voutIndex: bigint; + }>(print.data.value); + expect(printData).toStrictEqual({ + topic: "completed-deposit", + txid: new Uint8Array(32).fill(0), + voutIndex: 0n, + }); + }); + + test("Call get-complete-deposit placeholder", () => { + txOk( + deposit.completeDepositWrapper({ + txid: new Uint8Array(32).fill(0), + voutIndex: 0, + amount: 0, + recipient: alice, + }), + alice + ); + const receipt1 = rov( + registry.getCompletedDeposit({ + txid: new Uint8Array(32).fill(0), + voutIndex: 0, + }), + alice + ); + expect(receipt1).toStrictEqual({ + amount: 0n, + recipient: alice, + }); + }) + + }); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..a7d866b62 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "sbtc-bootstrap", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "sbtc-bootstrap" + } + } +}