Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add on chain blueprint class, its serialization, and a method to create the tx from the wallet #811

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
232 changes: 232 additions & 0 deletions __tests__/integration/configuration/bet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# Copyright 2023 Hathor Labs
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from math import floor
from typing import Optional, TypeAlias

from hathor.nanocontracts.blueprint import Blueprint
from hathor.nanocontracts.context import Context
from hathor.nanocontracts.exception import NCFail
from hathor.nanocontracts.types import (
Address,
NCAction,
NCActionType,
SignedData,
Timestamp,
TokenUid,
TxOutputScript,
public,
view,
)

Result: TypeAlias = str
Amount: TypeAlias = int


class InvalidToken(NCFail):
pass


class ResultAlreadySet(NCFail):
pass


class ResultNotAvailable(NCFail):
pass


class WithdrawalNotAllowed(NCFail):
pass


class DepositNotAllowed(NCFail):
pass


class TooManyActions(NCFail):
pass


class TooLate(NCFail):
pass


class InsufficientBalance(NCFail):
pass


class InvalidOracleSignature(NCFail):
pass


class Bet(Blueprint):
"""Bet blueprint with final result provided by an oracle.

The life cycle of contracts using this blueprint is the following:

1. [Owner ] Create a contract.
2. [User 1] `bet(...)` on result A.
3. [User 2] `bet(...)` on result A.
4. [User 3] `bet(...)` on result B.
5. [Oracle] `set_result(...)` as result A.
6. [User 1] `withdraw(...)`
7. [User 2] `withdraw(...)`

Notice that, in the example above, users 1 and 2 won.
"""

# Total bets per result.
bets_total: dict[Result, Amount]

# Total bets per (result, address).
bets_address: dict[tuple[Result, Address], Amount]

# Bets grouped by address.
address_details: dict[Address, dict[Result, Amount]]

# Amount that has already been withdrawn per address.
withdrawals: dict[Address, Amount]

# Total bets.
total: Amount

# Final result.
final_result: Optional[Result]

# Oracle script to set the final result.
oracle_script: TxOutputScript

# Maximum timestamp to make a bet.
date_last_bet: Timestamp

# Token for this bet.
token_uid: TokenUid

@public
def initialize(self, ctx: Context, oracle_script: TxOutputScript, token_uid: TokenUid,
date_last_bet: Timestamp) -> None:
if len(ctx.actions) != 0:
raise NCFail('must be a single call')
self.oracle_script = oracle_script
self.token_uid = token_uid
self.date_last_bet = date_last_bet
self.final_result = None
self.total = Amount(0)

@view
def has_result(self) -> bool:
"""Return True if the final result has already been set."""
return bool(self.final_result is not None)

def fail_if_result_is_available(self) -> None:
"""Fail the execution if the final result has already been set."""
if self.has_result():
raise ResultAlreadySet

def fail_if_result_is_not_available(self) -> None:
"""Fail the execution if the final result is not available yet."""
if not self.has_result():
raise ResultNotAvailable

def fail_if_invalid_token(self, action: NCAction) -> None:
"""Fail the execution if the token is invalid."""
if action.token_uid != self.token_uid:
token1 = self.token_uid.hex() if self.token_uid else None
token2 = action.token_uid.hex() if action.token_uid else None
raise InvalidToken(f'invalid token ({token1} != {token2})')

def _get_action(self, ctx: Context) -> NCAction:
"""Return the only action available; fails otherwise."""
if len(ctx.actions) != 1:
raise TooManyActions('only one action supported')
if self.token_uid not in ctx.actions:
raise InvalidToken(f'token different from {self.token_uid.hex()}')
return ctx.actions[self.token_uid]

@public
def bet(self, ctx: Context, address: Address, score: str) -> None:
"""Make a bet."""
action = self._get_action(ctx)
if action.type != NCActionType.DEPOSIT:
raise WithdrawalNotAllowed('must be deposit')
self.fail_if_result_is_available()
self.fail_if_invalid_token(action)
if ctx.timestamp > self.date_last_bet:
raise TooLate(f'cannot place bets after {self.date_last_bet}')
amount = Amount(action.amount)
self.total = Amount(self.total + amount)
if score not in self.bets_total:
self.bets_total[score] = amount
else:
self.bets_total[score] += amount
key = (score, address)
if key not in self.bets_address:
self.bets_address[key] = amount
else:
self.bets_address[key] += amount

# Update dict indexed by address
partial = self.address_details.get(address, {})
partial.update({
score: self.bets_address[key]
})

self.address_details[address] = partial

@public
def set_result(self, ctx: Context, result: SignedData[Result]) -> None:
"""Set final result. This method is called by the oracle."""
self.fail_if_result_is_available()
if not result.checksig(self.oracle_script):
raise InvalidOracleSignature
self.final_result = result.data

@public
def withdraw(self, ctx: Context) -> None:
"""Withdraw tokens after the final result is set."""
action = self._get_action(ctx)
if action.type != NCActionType.WITHDRAWAL:
raise DepositNotAllowed('action must be withdrawal')
self.fail_if_result_is_not_available()
self.fail_if_invalid_token(action)
address = Address(ctx.address)
allowed = self.get_max_withdrawal(address)
if action.amount > allowed:
raise InsufficientBalance(f'withdrawal amount is greater than available (max: {allowed})')
if address not in self.withdrawals:
self.withdrawals[address] = action.amount
else:
self.withdrawals[address] += action.amount

@view
def get_max_withdrawal(self, address: Address) -> Amount:
"""Return the maximum amount available for withdrawal."""
total = self.get_winner_amount(address)
withdrawals = self.withdrawals.get(address, Amount(0))
return total - withdrawals

@view
def get_winner_amount(self, address: Address) -> Amount:
"""Return how much an address has won."""
self.fail_if_result_is_not_available()
if self.final_result not in self.bets_total:
return Amount(0)
result_total = self.bets_total[self.final_result]
if result_total == 0:
return Amount(0)
address_total = self.bets_address.get((self.final_result, address), 0)
percentage = address_total / result_total
return Amount(floor(percentage * self.total))

__blueprint__ = Bet
3 changes: 3 additions & 0 deletions __tests__/integration/configuration/privnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ REWARD_SPEND_MIN_BLOCKS: 1
CHECKPOINTS: []

ENABLE_NANO_CONTRACTS: true
ENABLE_ON_CHAIN_BLUEPRINTS: true
NC_ON_CHAIN_BLUEPRINT_ALLOWED_ADDRESSES:
- WRuTfYoDqnrn8F4hdAuiiyosR4Pp9Q6CEx
BLUEPRINTS:
3cb032600bdf7db784800e4ea911b10676fa2f67591f82bb62628c234e771595: Bet

Expand Down
40 changes: 29 additions & 11 deletions __tests__/integration/nanocontracts/bet.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fs from 'fs';
import { isEmpty } from 'lodash';
import { GenesisWalletHelper } from '../helpers/genesis-wallet.helper';
import {
Expand Down Expand Up @@ -25,7 +26,7 @@ import { OutputType } from '../../../src/wallet/types';
import NanoContractTransactionParser from '../../../src/nano_contracts/parser';

let fundsTx;
const blueprintId = '3cb032600bdf7db784800e4ea911b10676fa2f67591f82bb62628c234e771595';
const builtInBlueprintId = '3cb032600bdf7db784800e4ea911b10676fa2f67591f82bb62628c234e771595';

describe('full cycle of bet nano contract', () => {
/** @type HathorWallet */
Expand Down Expand Up @@ -62,7 +63,7 @@ describe('full cycle of bet nano contract', () => {
expect(isEmpty(txAfterExecution.meta.first_block)).not.toBeNull();
};

const executeTests = async wallet => {
const executeTests = async (wallet, blueprintId) => {
const address0 = await wallet.getAddressAtIndex(0);
const address1 = await wallet.getAddressAtIndex(1);
const dateLastBet = dateFormatter.dateToTimestamp(new Date()) + 6000;
Expand Down Expand Up @@ -481,14 +482,31 @@ describe('full cycle of bet nano contract', () => {
expect(wallet.storage.processHistory.mock.calls.length).toBe(1);
};

it('bet deposit', async () => {
await executeTests(hWallet);
it('bet deposit built in', async () => {
await executeTests(hWallet, builtInBlueprintId);
});

// The hathor-core and the wallet-lib are still not ready for
// using nano contracts with a Multisig wallet
it.skip('bet deposit with multisig wallet', async () => {
await executeTests(mhWallet);
it.skip('bet deposit built in with multisig wallet', async () => {
await executeTests(mhWallet, builtInBlueprintId);
});

it('bet deposit on chain blueprint', async () => {
// For now the on chain blueprints needs a signature from a specific address
// so we must always generate the same seed
const seed =
'bicycle dice amused car lock outdoor auto during nest accident soon sauce slot enact hand they member source job forward vibrant lab catch coach';
const ocbWallet = await generateWalletHelper({ seed });
const address0 = await ocbWallet.getAddressAtIndex(0);
await GenesisWalletHelper.injectFunds(ocbWallet, address0, 1000n);
// Use the bet blueprint code
const code = fs.readFileSync('./__tests__/integration/configuration/bet.py', 'utf8');
const tx = await ocbWallet.createAndSendOnChainBlueprintTransaction(code, address0);
// Wait for the tx to be confirmed, so we can use the on chain blueprint
await waitTxConfirmed(ocbWallet, tx.hash);
// Execute the bet blueprint tests
await executeTests(ocbWallet, tx.hash);
});

it('handle errors', async () => {
Expand Down Expand Up @@ -516,15 +534,15 @@ describe('full cycle of bet nano contract', () => {
// Missing last argument
await expect(
hWallet.createAndSendNanoContractTransaction(NANO_CONTRACTS_INITIALIZE_METHOD, address0, {
blueprintId,
blueprintId: builtInBlueprintId,
args: [bufferToHex(oracleData), NATIVE_TOKEN_UID],
})
).rejects.toThrow(NanoContractTransactionError);

// Args as null
await expect(
hWallet.createAndSendNanoContractTransaction(NANO_CONTRACTS_INITIALIZE_METHOD, address0, {
blueprintId,
blueprintId: builtInBlueprintId,
args: null,
})
).rejects.toThrow(NanoContractTransactionError);
Expand All @@ -537,7 +555,7 @@ describe('full cycle of bet nano contract', () => {
NANO_CONTRACTS_INITIALIZE_METHOD,
addressNewWallet,
{
blueprintId,
blueprintId: builtInBlueprintId,
args: [bufferToHex(oracleData), NATIVE_TOKEN_UID, dateLastBet],
}
)
Expand All @@ -546,15 +564,15 @@ describe('full cycle of bet nano contract', () => {
// Oracle data is expected to be a hexa
await expect(
hWallet.createAndSendNanoContractTransaction(NANO_CONTRACTS_INITIALIZE_METHOD, address0, {
blueprintId,
blueprintId: builtInBlueprintId,
args: ['error', NATIVE_TOKEN_UID, dateLastBet],
})
).rejects.toThrow(NanoContractTransactionError);

// Date last bet is expected to be an integer
await expect(
hWallet.createAndSendNanoContractTransaction(NANO_CONTRACTS_INITIALIZE_METHOD, address0, {
blueprintId,
blueprintId: builtInBlueprintId,
args: ['123', NATIVE_TOKEN_UID, 'error'],
})
).rejects.toThrow(NanoContractTransactionError);
Expand Down
13 changes: 13 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export const NANO_CONTRACTS_VERSION = 4;
*/
export const POA_BLOCK_VERSION = 5;

/**
* On chain blueprints transaction version field
*/
export const ON_CHAIN_BLUEPRINTS_VERSION = 6;

/**
* Nano Contracts information version
* If we decide to change the serialization of nano information
Expand All @@ -96,6 +101,14 @@ export const NANO_CONTRACTS_INFO_VERSION = 1;
*/
export const NANO_CONTRACTS_INITIALIZE_METHOD = 'initialize';

/**
* On chain blueprints information version
* If we decide to change the serialization of the object information
* data, then we can change this version, so we can
* correctly deserialize all the on chain blueprint transactions
*/
export const ON_CHAIN_BLUEPRINTS_INFO_VERSION = 1;

/**
* Create token information version
* so far we expect name and symbol
Expand Down
Loading
Loading