diff --git a/.github/workflows/prg_tests.yml b/.github/workflows/prg_tests.yml index b11f03e..26ae7e8 100644 --- a/.github/workflows/prg_tests.yml +++ b/.github/workflows/prg_tests.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: paths: - - contract/Xorshift128plus.cdc + - contracts/Xorshift128plus.cdc - prg_test.go - prg_test_helpers.go @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: '1.22.x' + go-version: "1.22.x" - uses: actions/cache@v1 with: path: ~/go/pkg/mod @@ -29,4 +29,3 @@ jobs: run: echo "/root/.local/bin" >> $GITHUB_PATH - name: Run tests run: make ci - \ No newline at end of file diff --git a/contracts/CoinToss.cdc b/contracts/CoinToss.cdc index 0f01529..d63515c 100644 --- a/contracts/CoinToss.cdc +++ b/contracts/CoinToss.cdc @@ -1,8 +1,8 @@ +import "Burner" import "FungibleToken" import "FlowToken" -import "RandomBeaconHistory" -import "Xorshift128plus" +import "RandomConsumer" /// CoinToss is a simple game contract showcasing the safe use of onchain randomness by way of a commit-reveal sheme. /// @@ -12,9 +12,12 @@ import "Xorshift128plus" /// NOTE: This contract is for demonstration purposes only and is not intended to be used in a production environment. /// access(all) contract CoinToss { - + /// The multiplier used to calculate the winnings of a successful coin toss + access(all) let multiplier: UFix64 /// The Vault used by the contract to store funds. access(self) let reserve: @FlowToken.Vault + /// The RandomConsumer.Consumer resource used to request & fulfill randomness + access(self) let consumer: @RandomConsumer.Consumer /// The canonical path for common Receipt storage /// Note: production systems would consider handling path collisions @@ -22,18 +25,24 @@ access(all) contract CoinToss { /* --- Events --- */ // - access(all) event CoinTossBet(betAmount: UFix64, commitBlock: UInt64, receiptID: UInt64) - access(all) event CoinTossReveal(betAmount: UFix64, winningAmount: UFix64, commitBlock: UInt64, receiptID: UInt64) + access(all) event CoinFlipped(betAmount: UFix64, commitBlock: UInt64, receiptID: UInt64) + access(all) event CoinRevealed(betAmount: UFix64, winningAmount: UFix64, commitBlock: UInt64, receiptID: UInt64) - /// The Receipt resource is used to store the bet amount and block height at which the bet was committed. + /// The Receipt resource is used to store the bet amount and the associated randomness request. By listing the + /// RandomConsumer.RequestWrapper conformance, this resource inherits all the default implementations of the + /// interface. This is why the Receipt resource has access to the getRequestBlock() and popRequest() functions + /// without explicitly defining them. /// - access(all) resource Receipt { + access(all) resource Receipt : RandomConsumer.RequestWrapper { + /// The amount bet by the user access(all) let betAmount: UFix64 - access(all) let commitBlock: UInt64 + /// The associated randomness request which contains the block height at which the request was made + /// and whether the request has been fulfilled. + access(all) var request: @RandomConsumer.Request? - init(betAmount: UFix64) { + init(betAmount: UFix64, request: @RandomConsumer.Request) { self.betAmount = betAmount - self.commitBlock = getCurrentBlock().height + self.request <- request } } @@ -42,19 +51,22 @@ access(all) contract CoinToss { /// In this method, the caller commits a bet. The contract takes note of the block height and bet amount, returning a /// Receipt resource which is used by the better to reveal the coin toss result and determine their winnings. /// - access(all) fun commitCoinToss(bet: @{FungibleToken.Vault}): @Receipt { + access(all) fun flipCoin(bet: @{FungibleToken.Vault}): @Receipt { pre { bet.balance > 0.0: - "Provided vault.balance=0.0 - must deposit a non-zero amount to commit to a coin toss" + "CoinToss.flipCoin: Cannot commit to the coin toss! The provided vault's balance is 0.0. " + .concat("A non-zero amount is required to commit to a coin toss") bet.getType() == Type<@FlowToken.Vault>(): - "Invalid vault type=".concat(bet.getType().identifier).concat(" - must provide a FLOW vault") + "CoinToss.flipCoin: Cannot commit coin toss! The type of the provided vault <".concat(bet.getType().identifier).concat("> is invalid. The vault must be a FlowToken Vault.") } + let request <- self.consumer.requestRandomness() let receipt <- create Receipt( - betAmount: bet.balance + betAmount: bet.balance, + request: <-request ) self.reserve.deposit(from: <-bet) - emit CoinTossBet(betAmount: receipt.betAmount, commitBlock: receipt.commitBlock, receiptID: receipt.uuid) + emit CoinFlipped(betAmount: receipt.betAmount, commitBlock: receipt.getRequestBlock()!, receiptID: receipt.uuid) return <- receipt } @@ -62,69 +74,58 @@ access(all) contract CoinToss { /* --- Reveal --- */ // /// Here the caller provides the Receipt given to them at commitment. The contract then "flips a coin" with - /// randomCoin(), providing the committed block height and salting with the Receipts unique identifier. - /// If result is 1, user loses, if it's 0 the user doubles their bet. Note that the caller could condition the + /// _randomCoin(), providing the Receipt's contained Request. + /// + /// If result is 1, user loses, but if it's 0 the user doubles their bet. Note that the caller could condition the /// revealing transaction, but they've already provided their bet amount so there's no loss for the contract if /// they do. /// - access(all) fun revealCoinToss(receipt: @Receipt): @{FungibleToken.Vault} { + access(all) fun revealCoin(receipt: @Receipt): @{FungibleToken.Vault} { pre { - receipt.commitBlock <= getCurrentBlock().height: - "Provided receipt committed at block height=".concat(receipt.commitBlock.toString()).concat( - " - must wait until at least the following block to reveal" - ) + receipt.request != nil: + "CoinToss.revealCoin: Cannot reveal the coin! The provided receipt has already been revealed." + receipt.getRequestBlock()! <= getCurrentBlock().height: + "CoinToss.revealCoin: Cannot reveal the coin! The provided receipt was committed for block height ".concat(receipt.getRequestBlock()!.toString()) + .concat(" which is greater than the current block height of ") + .concat(getCurrentBlock().height.toString()) + .concat(". The reveal can only happen after the committed block has passed.") } - let betAmount = receipt.betAmount - let commitBlock = receipt.commitBlock + let commitBlock = receipt.getRequestBlock()! let receiptID = receipt.uuid - // self.randomCoin() errors if commitBlock <= current block height in call to - // RandomBeaconHistory.sourceOfRandomness() - let coin = self.randomCoin(atBlockHeight: receipt.commitBlock, salt: receipt.uuid) + let coin = self._randomCoin(request: <-receipt.popRequest()) - destroy receipt + Burner.burn(<-receipt) - if coin == 1 { - emit CoinTossReveal(betAmount: betAmount, winningAmount: 0.0, commitBlock: commitBlock, receiptID: receiptID) - return <- FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>()) + // Deposit the reward into a reward vault if the coin toss was won + let reward <- FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>()) + if coin == 0 { + let winningsAmount = betAmount * self.multiplier + let winnings <- self.reserve.withdraw(amount: winningsAmount) + reward.deposit( + from: <-winnings + ) } - let reward <- self.reserve.withdraw(amount: betAmount * 2.0) - - emit CoinTossReveal(betAmount: betAmount, winningAmount: reward.balance, commitBlock: commitBlock, receiptID: receiptID) + emit CoinRevealed(betAmount: betAmount, winningAmount: reward.balance, commitBlock: commitBlock, receiptID: receiptID) return <- reward } - /// Helper method using RandomBeaconHistory to retrieve a source of randomness for a specific block height and the - /// given salt to instantiate a PRG object. A randomly generated UInt64 is then reduced by bitwise operation to - /// UInt8 value of 1 or 0 and returned. + /// Returns a random number between 0 and 1 using the RandomConsumer.Consumer resource contained in the contract. + /// For the purposes of this contract, a simple modulo operation could have been used though this is not the case + /// for all ranges. Using the Consumer.fulfillRandomInRange function ensures that we can get a random number + /// within any range without a risk of bias. /// - access(all) fun randomCoin(atBlockHeight: UInt64, salt: UInt64): UInt8 { - // query the Random Beacon history core-contract - if `blockHeight` <= current block height, panic & revert - let sourceOfRandomness = RandomBeaconHistory.sourceOfRandomness(atBlockHeight: atBlockHeight) - assert( - sourceOfRandomness.blockHeight == atBlockHeight, - message: "Invalid response: Requested blockHeight=".concat(atBlockHeight.toString()).concat( - " but received random source block height=".concat(sourceOfRandomness.blockHeight.toString()) - ) - ) - - // instantiate a PRG object, seeding a source of randomness with `salt` and returns a pseudo-random - // generator object. - let prg = Xorshift128plus.PRG( - sourceOfRandomness: sourceOfRandomness.value, - salt: salt.toBigEndianBytes() - ) - - // derive a 64-bit random using the PRG object and reduce to a UInt8 value of 1 or 0 - let rand = prg.nextUInt64() - - return UInt8(rand & 1) + access(self) fun _randomCoin(request: @RandomConsumer.Request): UInt8 { + return UInt8(self.consumer.fulfillRandomInRange(request: <-request, min: 0, max: 1)) } - init() { + init(multiplier: UFix64) { + // Initialize the contract with a multiplier for the winnings + self.multiplier = multiplier + // Create a FlowToken.Vault to store the contract's funds self.reserve <- FlowToken.createEmptyVault(vaultType: Type<@FlowToken.Vault>()) let seedVault = self.account.storage.borrow( from: /storage/flowTokenVault @@ -132,7 +133,11 @@ access(all) contract CoinToss { self.reserve.deposit( from: <-seedVault.withdraw(amount: 1000.0) ) + // Create a RandomConsumer.Consumer resource + self.consumer <-RandomConsumer.createConsumer() + // Set the ReceiptStoragePath to a unique path for this contract - appending the address to the identifier + // prevents storage collisions with other objects in user's storage self.ReceiptStoragePath = StoragePath(identifier: "CoinTossReceipt_".concat(self.account.address.toString()))! } } diff --git a/contracts/RandomConsumer.cdc b/contracts/RandomConsumer.cdc new file mode 100644 index 0000000..802c081 --- /dev/null +++ b/contracts/RandomConsumer.cdc @@ -0,0 +1,295 @@ +import "Burner" + +import "RandomBeaconHistory" +import "Xorshift128plus" + +/// This contract is intended to make it easy to consume randomness securely from the Flow protocol's random beacon. It provides +/// a simple construct to commit to a request, and reveal the randomness in a secure manner as well as helper functions to +/// generate random numbers in a range without bias. +/// +/// See an example implementation in the repository: https://github.com/onflow/random-coin-toss +/// +access(all) contract RandomConsumer { + + /* --- PATHS --- */ + // + /// Canonical path for Consumer storage + access(all) let ConsumerStoragePath: StoragePath + + /* --- EVENTS --- */ + // + access(all) event RandomnessRequested(requestUUID: UInt64, block: UInt64) + access(all) event RandomnessSourced(requestUUID: UInt64, block: UInt64, randomSource: [UInt8]) + access(all) event RandomnessFulfilled(requestUUID: UInt64, randomResult: UInt64) + + /////////////////// + // PUBLIC FUNCTIONS + /////////////////// + + /// Retrieves a revertible random number in the range [min, max]. By leveraging the Cadence's revertibleRandom + /// method, this function ensures that the random number is generated within range without risk of bias. + /// + /// @param min: The minimum value of the range + /// @param max: The maximum value of the range + /// + /// @return A random number in the range [min, max] + /// + access(all) fun getRevertibleRandomInRange(min: UInt64, max: UInt64): UInt64 { + return min + revertibleRandom(modulo: max - min + 1) + } + + /// Retrieves a random number in the range [min, max] using the provided PRG + /// to source additional randomness if needed + /// + /// @param prg: The PRG to use for random number generation + /// @param min: The minimum value of the range + /// @param max: The maximum value of the range + /// + /// @return A random number in the range [min, max] + /// + access(all) fun getNumberInRange(prg: Xorshift128plus.PRG, min: UInt64, max: UInt64): UInt64 { + pre { + min < max: + "RandomConsumer.getNumberInRange: Cannot get random number with the provided range! " + .concat(" The min must be less than the max. Provided min of ") + .concat(min.toString()).concat(" and max of ".concat(max.toString())) + } + let range = max - min // Calculate the inclusive range of the random number + let bitsRequired = UInt256(self._mostSignificantBit(range)) // Number of bits needed to cover the range + let mask: UInt256 = (1 << bitsRequired) - 1 // Create a bitmask to extract relevant bits + + let shiftLimit: UInt256 = 256 / bitsRequired // Number of shifts needed to cover 256 bits + var shifts: UInt256 = 0 // Initialize shift counter + + var candidate: UInt64 = 0 // Initialize candidate + var value: UInt256 = prg.nextUInt256() // Assign the first 256 bits of randomness + + while true { + candidate = UInt64(value & mask) // Apply the bitmask to extract bits + if candidate <= range { + break + } + + // Shift by the number of bits covered by the mask + value = value >> bitsRequired + shifts = shifts + 1 + + // Get a new value if we've exhausted the current one + if shifts == shiftLimit { + value = prg.nextUInt256() + shifts = 0 + } + } + + // Scale candidate to the range [min, max] + return min + candidate + } + + /// Returns a new Consumer resource + /// + /// @return A Consumer resource + /// + access(all) fun createConsumer(): @Consumer { + return <-create Consumer() + } + + /////////////////// + // CONSTRUCTS + /////////////////// + + access(all) entitlement Commit + access(all) entitlement Reveal + + /// Interface to allow for a Request to be contained within another resource. The existing default implementations + /// enable an implementing resource to simply list the conformance without any additional implementation aside from + /// the inner Request resource. However, implementations should properly consider the optional when interacting + /// with the inner resource outside of the default implementations. The post-conditions ensure that implementations + /// cannot act dishonestly even if they override the default implementations. + /// + access(all) resource interface RequestWrapper { + /// The Request contained within the resource + access(all) var request: @Request? + + /// Returns the block height of the Request contained within the resource + /// + /// @return The block height of the Request or nil if no Request is contained + /// + access(all) view fun getRequestBlock(): UInt64? { + post { + result == nil || result! == self.request?.block: + "RandomConsumer.RequestWrapper.getRequestBlock(): Must return nil or the block height of RequestWrapper.request" + } + return self.request?.block ?? nil + } + + /// Returns whether the Request contained within the resource can be fulfilled or not + /// + /// @return Whether the Request can be fulfilled + /// + access(all) view fun canFullfillRequest(): Bool { + post { + result == self.request?.canFullfill() ?? false: + "RandomConsumer.RequestWrapper.canFullfillRequest(): Must return the result of RequestWrapper.request.canFullfill()" + } + return self.request?.canFullfill() ?? false + } + + /// Pops the Request from the resource and returns it + /// + /// @return The Request that was contained within the resource + /// + access(Reveal) fun popRequest(): @Request { + pre { + self.request != nil: "RandomConsumer.RequestWrapper.popRequest(): Request must not be nil before popRequest" + } + post { + self.request == nil: + "RandomConsumer.RequestWrapper.popRequest(): Request must be nil after popRequest" + result.uuid == before((self.request?.uuid)!): + "RandomConsumer.RequestWrapper.popRequest(): Request uuid must match result uuid" + } + let req <- self.request <- nil + return <- req! + } + } + + /// A resource representing a request for randomness + /// + access(all) resource Request { + /// The block height at which the request was made + access(all) let block: UInt64 + /// Whether the request has been fulfilled + access(all) var fulfilled: Bool + + init() { + self.block = getCurrentBlock().height + self.fulfilled = false + } + + access(all) view fun canFullfill(): Bool { + return !self.fulfilled && getCurrentBlock().height >= self.block + } + + /// Returns the Flow's random source for the requested block height + /// + /// @return The random source for the requested block height containing at least 16 bytes (128 bits) of entropy + /// + access(contract) fun _fulfill(): [UInt8] { + pre { + !self.fulfilled: + "RandomConsumer.Request.fulfill(): The random request has already been fulfilled." + self.block < getCurrentBlock().height: + "RandomConsumer.Request.fulfill(): Cannot fulfill random request before the eligible block height of " + .concat((self.block + 1).toString()) + } + self.fulfilled = true + let res = RandomBeaconHistory.sourceOfRandomness(atBlockHeight: self.block).value + + emit RandomnessSourced(requestUUID: self.uuid, block: self.block, randomSource: res) + + return res + } + } + + /// This resource enables the easy implementation of secure randomness, implementing the commit-reveal pattern and + /// using a PRG to generate random numbers from the protocol's random source. + /// + access(all) resource Consumer { + + /* ----- COMMIT STEP ----- */ + // + /// Requests randomness, returning a Request resource + /// + /// @return A Request resource + /// + access(Commit) fun requestRandomness(): @Request { + let req <-create Request() + emit RandomnessRequested(requestUUID: req.uuid, block: req.block) + return <-req + } + + /* ----- REVEAL STEP ----- */ + // + /// Fulfills a random request, returning a random number + /// + /// @param request: The Request to fulfill + /// + /// @return A random number + /// + access(Reveal) fun fulfillRandomRequest(_ request: @Request): UInt64 { + let reqUUID = request.uuid + + // Create PRG from the provided request & generate a random number + let prg = self._getPRGFromRequest(request: <-request) + let res = prg.nextUInt64() + + emit RandomnessFulfilled(requestUUID: reqUUID, randomResult: res) + return res + } + + /// Fulfills a random request, returning a random number in the range [min, max] without bias. Developers may be + /// tempted to use a simple modulo operation to generate random numbers in a range, but this can introduce bias + /// when the range is not a multiple of the modulus. This function ensures that the random number is generated + /// without bias using a variation on rejection sampling. + /// + /// @param request: The Request to fulfill + /// @param min: The minimum value of the range + /// @param max: The maximum value of the range + /// + /// @return A random number in the range [min, max] + /// + access(Reveal) fun fulfillRandomInRange(request: @Request, min: UInt64, max: UInt64): UInt64 { + pre { + min < max: + "RandomConsumer.Consumer.fulfillRandomInRange(): Cannot fulfill random number with the provided range! " + .concat(" The min must be less than the max. Provided min of ") + .concat(min.toString()).concat(" and max of ".concat(max.toString())) + } + let reqUUID = request.uuid + + // Create PRG from the provided request & generate a random number & generate a random number in the range + let prg = self._getPRGFromRequest(request: <-request) + let res = RandomConsumer.getNumberInRange(prg: prg, min: min, max: max) + + emit RandomnessFulfilled(requestUUID: reqUUID, randomResult: res) + + return res + } + + /* --- INTERNAL --- */ + // + /// Creates a PRG from a Request, using the request's block height source of randomness and UUID as a salt + /// + /// @param request: The Request to use for PRG creation + /// + /// @return A PRG object + /// + access(self) fun _getPRGFromRequest(request: @Request): Xorshift128plus.PRG { + let source = request._fulfill() + let salt = request.uuid.toBigEndianBytes() + Burner.burn(<-request) + + return Xorshift128plus.PRG(sourceOfRandomness: source, salt: salt) + } + } + + /// Returs the most significant bit of a UInt64 + /// + /// @param x: The UInt64 to find the most significant bit of + /// + /// @return The most significant bit of x + /// + access(self) view fun _mostSignificantBit(_ x: UInt64): UInt8 { + var bits: UInt8 = 0 + var tmp: UInt64 = x + while tmp > 0 { + tmp = tmp >> 1 + bits = bits + 1 + } + return bits + } + + init() { + self.ConsumerStoragePath = StoragePath(identifier: "RandomConsumer_".concat(self.account.address.toString()))! + } +} diff --git a/contracts/Xorshift128plus.cdc b/contracts/Xorshift128plus.cdc index b326bb7..6461820 100644 --- a/contracts/Xorshift128plus.cdc +++ b/contracts/Xorshift128plus.cdc @@ -37,8 +37,8 @@ access(all) contract Xorshift128plus { let seed: [UInt8] = hash.slice(from: 0, upTo: 16) // Convert the seed bytes to two Word64 values for state initialization - let segment0: Word64 = Xorshift128plus.bigEndianBytesToWord64(bytes: seed, start: 0) - let segment1: Word64 = Xorshift128plus.bigEndianBytesToWord64(bytes: seed, start: 8) + let segment0: Word64 = Xorshift128plus._bigEndianBytesToWord64(bytes: seed, start: 0) + let segment1: Word64 = Xorshift128plus._bigEndianBytesToWord64(bytes: seed, start: 8) // Ensure the initial state is non-zero assert( @@ -70,6 +70,20 @@ access(all) contract Xorshift128plus { let randUInt64: UInt64 = UInt64(Word64(a) + Word64(b)) return randUInt64 } + + /// Advances the PRG state and generates the next UInt256 value by concatenating 4 UInt64 values + /// + /// @return The next UInt256 value + /// + access(all) + fun nextUInt256(): UInt256 { + var res = UInt256(self.nextUInt64()) + res = res | UInt256(self.nextUInt64()) << 64 + res = res | UInt256(self.nextUInt64()) << 128 + res = res | UInt256(self.nextUInt64()) << 192 + + return res + } } /// Helper function to convert an array of big endian bytes to Word64 @@ -79,7 +93,7 @@ access(all) contract Xorshift128plus { /// /// @return The Word64 value /// - access(contract) fun bigEndianBytesToWord64(bytes: [UInt8], start: Int): Word64 { + access(contract) fun _bigEndianBytesToWord64(bytes: [UInt8], start: Int): Word64 { pre { start + 8 <= bytes.length: "Defined start=".concat(start.toString()) diff --git a/flow.json b/flow.json index fbcad8f..273cd0b 100644 --- a/flow.json +++ b/flow.json @@ -4,15 +4,24 @@ "source": "./contracts/CoinToss.cdc", "aliases": { "emulator": "f8d6e0586b0a20c7", + "testing": "0000000000000007", "testnet": "d1299e755e8be5e7" } }, "RandomResultStorage": "./contracts/test/RandomResultStorage.cdc", + "RandomConsumer": { + "source": "./contracts/RandomConsumer.cdc", + "aliases": { + "emulator": "f8d6e0586b0a20c7", + "testing": "0000000000000007" + } + }, "Xorshift128plus": { "source": "./contracts/Xorshift128plus.cdc", "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "45caec600164c9e6", + "testing": "0000000000000007", "testnet": "ed24dbe901028c5c" } } @@ -113,6 +122,7 @@ "emulator": "127.0.0.1:3569", "mainnet": "access.mainnet.nodes.onflow.org:9000", "previewnet": "access.previewnet.nodes.onflow.org:9000", + "testing": "127.0.0.1:3569", "testnet": "access.devnet.nodes.onflow.org:9000" }, "accounts": { @@ -126,7 +136,7 @@ }, "emulator-account": { "address": "f8d6e0586b0a20c7", - "key": "baecdf71ce11309b2dc51df3c9e71379e81134d364e600425312f6599a5f1cdf" + "key": "a30efdca0c2198cc4d4e013f62072fc3013f9f44c4e1a2ebad09f642588168ad" }, "tooling-mainnet": { "address": "45caec600164c9e6", @@ -149,7 +159,12 @@ "emulator": { "emulator-account": [ "Xorshift128plus", - "CoinToss", + "RandomConsumer", { + "name": "CoinToss", + "args": [ + { "type": "UFix64","value": "2.0" } + ] + }, "RandomResultStorage" ] }, diff --git a/tests/coin_toss_tests.cdc b/tests/coin_toss_tests.cdc new file mode 100644 index 0000000..8e3fa2f --- /dev/null +++ b/tests/coin_toss_tests.cdc @@ -0,0 +1,96 @@ +import Test +import BlockchainHelpers +import "test_helpers.cdc" + +import "CoinToss" +import "RandomConsumer" + +access(all) let serviceAccount = Test.serviceAccount() +access(all) let coinToss = Test.getAccount(0x0000000000000007) + +access(all) let multiplier = 2.0 + +access(all) +fun setup() { + + mintFlow(to: coinToss, amount: 20_000_000.0) + + var err = Test.deployContract( + name: "Xorshift128plus", + path: "../contracts/Xorshift128plus.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + err = Test.deployContract( + name: "RandomConsumer", + path: "../contracts/RandomConsumer.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + err = Test.deployContract( + name: "CoinToss", + path: "../contracts/CoinToss.cdc", + arguments: [multiplier] + ) + Test.expect(err, Test.beNil()) +} + +access(all) +fun testCommitSucceeds() { + let user = Test.createAccount() + mintFlow(to: user, amount: 1000.0) + + let betAmount: UFix64 = 100.0 + + let commitRes = executeTransaction( + "../transactions/coin-toss/0_flip_coin.cdc", + [betAmount], + user + ) + Test.expect(commitRes, Test.beSucceeded()) + + let requestedEvts = Test.eventsOfType(Type()) + Test.assertEqual(1, requestedEvts.length) + let flippedEvts = Test.eventsOfType(Type()) + Test.assertEqual(1, flippedEvts.length) + + let balance = getCadenceBalance(user.address) + Test.assertEqual(900.0, balance) +} + +access(all) +fun testCommitAndRevealSucceeds() { + let user = Test.createAccount() + mintFlow(to: user, amount: 1000.0) + + let betAmount: UFix64 = 100.0 + + let commitRes = executeTransaction( + "../transactions/coin-toss/0_flip_coin.cdc", + [betAmount], + user + ) + Test.expect(commitRes, Test.beSucceeded()) + + let requestedEvts = Test.eventsOfType(Type()) + Test.assertEqual(2, requestedEvts.length) + let flippedEvts = Test.eventsOfType(Type()) + Test.assertEqual(2, flippedEvts.length) + + let balance = getCadenceBalance(user.address) + Test.assertEqual(900.0, balance) + + let revealRes = executeTransaction( + "../transactions/coin-toss/1_reveal_coin.cdc", + [], + user + ) + Test.expect(revealRes, Test.beSucceeded()) + + let sourcedEvts = Test.eventsOfType(Type()) + let fulfilledEvts = Test.eventsOfType(Type()) + let revealedEvts = Test.eventsOfType(Type()) + Test.assertEqual(1, sourcedEvts.length) + Test.assertEqual(1, fulfilledEvts.length) + Test.assertEqual(1, revealedEvts.length) +} \ No newline at end of file diff --git a/tests/scripts/get_cadence_balance.cdc b/tests/scripts/get_cadence_balance.cdc new file mode 100644 index 0000000..30757bb --- /dev/null +++ b/tests/scripts/get_cadence_balance.cdc @@ -0,0 +1,10 @@ +import "FlowToken" + +access(all) +fun main(address: Address): UFix64 { + return getAuthAccount(address) + .storage + .borrow<&FlowToken.Vault>( + from: /storage/flowTokenVault + )!.balance +} diff --git a/tests/test_helpers.cdc b/tests/test_helpers.cdc index 3e4009d..07edd7d 100644 --- a/tests/test_helpers.cdc +++ b/tests/test_helpers.cdc @@ -22,4 +22,14 @@ fun getEVMBalance(_ evmAddressHex: String): UFix64 { ) Test.expect(res, Test.beSucceeded()) return res.returnValue! as! UFix64 +} + +access(all) +fun getCadenceBalance(_ address: Address): UFix64 { + let res = _executeScript( + "./scripts/get_cadence_balance.cdc", + [address] + ) + Test.expect(res, Test.beSucceeded()) + return res.returnValue! as! UFix64 } \ No newline at end of file diff --git a/transactions/coin-toss/0_commit_coin_toss.cdc b/transactions/coin-toss/0_flip_coin.cdc similarity index 94% rename from transactions/coin-toss/0_commit_coin_toss.cdc rename to transactions/coin-toss/0_flip_coin.cdc index 49d7fc2..d277c44 100644 --- a/transactions/coin-toss/0_commit_coin_toss.cdc +++ b/transactions/coin-toss/0_flip_coin.cdc @@ -13,7 +13,7 @@ transaction(betAmount: UFix64) { let bet <- flowVault.withdraw(amount: betAmount) // Commit my bet and get a receipt - let receipt <- CoinToss.commitCoinToss(bet: <-bet) + let receipt <- CoinToss.flipCoin(bet: <-bet) // Check that I don't already have a receipt stored if signer.storage.type(at: CoinToss.ReceiptStoragePath) != nil { diff --git a/transactions/coin-toss/1_reveal_coin_toss.cdc b/transactions/coin-toss/1_reveal_coin.cdc similarity index 92% rename from transactions/coin-toss/1_reveal_coin_toss.cdc rename to transactions/coin-toss/1_reveal_coin.cdc index 719bc36..f54498a 100644 --- a/transactions/coin-toss/1_reveal_coin_toss.cdc +++ b/transactions/coin-toss/1_reveal_coin.cdc @@ -12,7 +12,7 @@ transaction { ?? panic("No Receipt found in storage at path=".concat(CoinToss.ReceiptStoragePath.toString())) // Reveal by redeeming my receipt - fingers crossed! - let winnings <- CoinToss.revealCoinToss(receipt: <-receipt) + let winnings <- CoinToss.revealCoin(receipt: <-receipt) if winnings.balance > 0.0 { // Deposit winnings into my FlowToken Vault