diff --git a/__tests__/integration/configuration/bet.py b/__tests__/integration/configuration/bet.py new file mode 100644 index 00000000..d5af8853 --- /dev/null +++ b/__tests__/integration/configuration/bet.py @@ -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 \ No newline at end of file diff --git a/__tests__/integration/configuration/privnet.yml b/__tests__/integration/configuration/privnet.yml index 45bcef74..108a204a 100644 --- a/__tests__/integration/configuration/privnet.yml +++ b/__tests__/integration/configuration/privnet.yml @@ -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 diff --git a/__tests__/integration/nanocontracts/bet.test.ts b/__tests__/integration/nanocontracts/bet.test.ts index 1c863553..dbc9e71d 100644 --- a/__tests__/integration/nanocontracts/bet.test.ts +++ b/__tests__/integration/nanocontracts/bet.test.ts @@ -1,3 +1,4 @@ +import fs from 'fs'; import { isEmpty } from 'lodash'; import { GenesisWalletHelper } from '../helpers/genesis-wallet.helper'; import { @@ -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 */ @@ -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; @@ -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 () => { @@ -516,7 +534,7 @@ 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); @@ -524,7 +542,7 @@ describe('full cycle of bet nano contract', () => { // Args as null await expect( hWallet.createAndSendNanoContractTransaction(NANO_CONTRACTS_INITIALIZE_METHOD, address0, { - blueprintId, + blueprintId: builtInBlueprintId, args: null, }) ).rejects.toThrow(NanoContractTransactionError); @@ -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], } ) @@ -546,7 +564,7 @@ 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); @@ -554,7 +572,7 @@ describe('full cycle of bet nano contract', () => { // 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); diff --git a/src/constants.ts b/src/constants.ts index 8818fb12..071e1d60 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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 @@ -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 diff --git a/src/nano_contracts/on_chain_blueprint.ts b/src/nano_contracts/on_chain_blueprint.ts new file mode 100644 index 00000000..4e6a0005 --- /dev/null +++ b/src/nano_contracts/on_chain_blueprint.ts @@ -0,0 +1,115 @@ +/* eslint-disable max-classes-per-file */ + +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import zlib from 'zlib'; +import { ON_CHAIN_BLUEPRINTS_INFO_VERSION, ON_CHAIN_BLUEPRINTS_VERSION } from '../constants'; +import Transaction from '../models/transaction'; +import { intToBytes } from '../utils/buffer'; + +export enum CodeKind { + PYTHON_GZIP = 'python+gzip', +} + +export class Code { + kind: CodeKind; + + content: Buffer; + + constructor(kind: CodeKind, content: Buffer) { + this.kind = kind; + this.content = content; + } + + serialize(): Buffer { + const arr: Buffer[] = []; + if (this.kind !== CodeKind.PYTHON_GZIP) { + throw new Error('Invalid code kind value'); + } + + const zcode = zlib.deflateSync(this.content); + arr.push(Buffer.from(this.kind, 'utf8')); + arr.push(intToBytes(0, 1)); + arr.push(zcode); + return Buffer.concat(arr); + } +} + +class OnChainBlueprint extends Transaction { + // Code object with content + code: Code; + + pubkey: Buffer; + + signature: Buffer | null; + + constructor(code: Code, pubkey: Buffer, signature: Buffer | null = null) { + super([], []); + this.version = ON_CHAIN_BLUEPRINTS_VERSION; + + this.code = code; + this.pubkey = pubkey; + this.signature = signature; + } + + /** + * Serialize funds fields + * Add the serialized fields to the array parameter + * + * @param {array} Array of buffer to push the serialized fields + * @param {addInputData} If should add input data or signature when serializing it + * + * @memberof OnChainBlueprint + * @inner + */ + serializeFundsFields(array: Buffer[], addInputData: boolean) { + super.serializeFundsFields(array, addInputData); + + // Info version + array.push(intToBytes(ON_CHAIN_BLUEPRINTS_INFO_VERSION, 1)); + + // Code + const serializedCode = this.code.serialize(); + array.push(intToBytes(serializedCode.length, 4)); + array.push(serializedCode); + + // Pubkey and signature + array.push(intToBytes(this.pubkey.length, 1)); + array.push(this.pubkey); + + if (this.signature !== null && addInputData) { + array.push(intToBytes(this.signature.length, 1)); + array.push(this.signature); + } else { + array.push(intToBytes(0, 1)); + } + } + + /** + * Serialize tx to bytes + * + * @memberof OnChainBlueprint + * @inner + */ + toBytes(): Buffer { + const arr: Buffer[] = []; + // Serialize first the funds part + // + this.serializeFundsFields(arr, true); + + // Graph fields + this.serializeGraphFields(arr); + + // Nonce + this.serializeNonce(arr); + + return Buffer.concat(arr); + } +} + +export default OnChainBlueprint; diff --git a/src/nano_contracts/utils.ts b/src/nano_contracts/utils.ts index f478e1ec..1d4cfab8 100644 --- a/src/nano_contracts/utils.ts +++ b/src/nano_contracts/utils.ts @@ -11,6 +11,7 @@ import transactionUtils from '../utils/transaction'; import SendTransaction from '../new/sendTransaction'; import HathorWallet from '../new/wallet'; import NanoContract from './nano_contract'; +import OnChainBlueprint from './on_chain_blueprint'; import Network from '../models/network'; import ScriptData from '../models/script_data'; import ncApi from '../api/nano'; @@ -33,7 +34,7 @@ import { NANO_CONTRACTS_VERSION, NANO_CONTRACTS_INITIALIZE_METHOD } from '../con * @param storage Wallet storage object */ export const prepareNanoSendTransaction = async ( - tx: NanoContract, + tx: NanoContract | OnChainBlueprint, pin: string, storage: IStorage ): Promise => { diff --git a/src/new/wallet.js b/src/new/wallet.js index d5881b3e..76bece50 100644 --- a/src/new/wallet.js +++ b/src/new/wallet.js @@ -49,6 +49,7 @@ import { MemoryStore, Storage } from '../storage'; import { deriveAddressP2PKH, deriveAddressP2SH, getAddressFromPubkey } from '../utils/address'; import NanoContractTransactionBuilder from '../nano_contracts/builder'; import { prepareNanoSendTransaction } from '../nano_contracts/utils'; +import OnChainBlueprint, { Code, CodeKind } from '../nano_contracts/on_chain_blueprint'; import { IHistoryTxSchema } from '../schemas'; import GLL from '../sync/gll'; import { checkTxMetadataChanged } from '../sync/utils'; @@ -3085,6 +3086,63 @@ class HathorWallet extends EventEmitter { const addressesToLoad = await scanPolicyStartAddresses(this.storage); await this.syncHistory(addressesToLoad.nextIndex, addressesToLoad.count); } + + /** + * @typedef {Object} CreateOnChainBlueprintTxOptions + * @property {string?} [pinCode] PIN to decrypt the private key. + */ + + /** + * Create and send an on chain blueprint transaction + * + * @param {string} code Blueprint code in utf-8 + * @param {string} address Address that will be used to sign the on chain blueprint transaction + * @param {CreateOnChainBlueprintTxOptions} [options] + * + * @returns {Promise} + */ + async createAndSendOnChainBlueprintTransaction(code, address, options = {}) { + const sendTransaction = await this.createOnChainBlueprintTransaction(code, address, options); + return sendTransaction.runFromMining(); + } + + /** + * Create an on chain blueprint transaction and return the SendTransaction object + * + * @param {string} code Blueprint code in utf-8 + * @param {string} address Address that will be used to sign the on chain blueprint transaction + * @param {CreateOnChainBlueprintTxOptions} [options] + * + * @returns {Promise} + */ + async createOnChainBlueprintTransaction(code, address, options) { + if (await this.storage.isReadonly()) { + throw new WalletFromXPubGuard('createOnChainBlueprintTransaction'); + } + const newOptions = { pinCode: null, ...options }; + const pin = newOptions.pinCode || this.pinCode; + if (!pin) { + throw new PinRequiredError(ERROR_MESSAGE_PIN_REQUIRED); + } + + // Get caller pubkey + const addressInfo = await this.storage.getAddressInfo(address); + if (!addressInfo) { + throw new NanoContractTransactionError( + `Address used to sign the transaction (${address}) does not belong to the wallet.` + ); + } + const pubkeyStr = await this.storage.getAddressPubkey(addressInfo.bip32AddressIndex); + const pubkey = Buffer.from(pubkeyStr, 'hex'); + + // Create code object from code data + const codeContent = Buffer.from(code, 'utf8'); + const codeObj = new Code(CodeKind.PYTHON_GZIP, codeContent); + + const tx = new OnChainBlueprint(codeObj, pubkey); + + return prepareNanoSendTransaction(tx, pin, this.storage); + } } // State constants. diff --git a/src/utils/transaction.ts b/src/utils/transaction.ts index d6065736..0a42d7b1 100644 --- a/src/utils/transaction.ts +++ b/src/utils/transaction.ts @@ -21,6 +21,7 @@ import { MERGED_MINED_BLOCK_VERSION, NANO_CONTRACTS_VERSION, POA_BLOCK_VERSION, + ON_CHAIN_BLUEPRINTS_VERSION, } from '../constants'; import Transaction from '../models/transaction'; import CreateTokenTransaction from '../models/create_token_transaction'; @@ -47,6 +48,7 @@ import ScriptData from '../models/script_data'; import helpers from './helpers'; import { getAddressType, getAddressFromPubkey } from './address'; import NanoContract from '../nano_contracts/nano_contract'; +import OnChainBlueprint from '../nano_contracts/on_chain_blueprint'; const transaction = { /** @@ -196,8 +198,8 @@ const transaction = { }); } - if (tx.version === NANO_CONTRACTS_VERSION) { - const { pubkey } = tx as NanoContract; + if (tx.version === NANO_CONTRACTS_VERSION || tx.version === ON_CHAIN_BLUEPRINTS_VERSION) { + const { pubkey } = tx as NanoContract | OnChainBlueprint; const address = getAddressFromPubkey(pubkey.toString('hex'), storage.config.getNetwork()); const addressInfo = await storage.getAddressInfo(address.base58); if (!addressInfo) { @@ -231,9 +233,9 @@ const transaction = { input.setData(inputData); } - if (tx.version === NANO_CONTRACTS_VERSION) { + if (tx.version === NANO_CONTRACTS_VERSION || tx.version === ON_CHAIN_BLUEPRINTS_VERSION) { // eslint-disable-next-line no-param-reassign - (tx as NanoContract).signature = signatures.ncCallerSignature; + (tx as NanoContract | OnChainBlueprint).signature = signatures.ncCallerSignature; } return tx; },