diff --git a/l6/.gitignore b/l6/.gitignore new file mode 100644 index 0000000..898073e --- /dev/null +++ b/l6/.gitignore @@ -0,0 +1,3 @@ +*.nef +config.json +bin diff --git a/l6/data.json b/l6/data.json new file mode 100644 index 0000000..a8d4f08 --- /dev/null +++ b/l6/data.json @@ -0,0 +1,42 @@ +{ + "store": { + "item": [ + { + "id": 0, + "category": "Headgear", + "title": "Bucket Hat", + "price": 400 + }, + { + "id": 1, + "category": "Headgear", + "title": "Fugu Bell Hat", + "price": 1700 + }, + { + "id": 2, + "category": "Clothing", + "title": "Lumberjack Shirt", + "price": 800 + }, + { + "id": 3, + "category": "Clothing", + "title": "Pink Hoodie", + "price": 3400 + }, + { + "id": 4, + "category": "Shoes", + "title": "Cyan Trainers", + "price": 700 + }, + { + "id": 5, + "category": "Shoes", + "title": "Red Hi-Tops", + "price": 1800 + } + ] + } +} \ No newline at end of file diff --git a/l6/docs/README.md b/l6/docs/README.md new file mode 100644 index 0000000..e9ab4e9 --- /dev/null +++ b/l6/docs/README.md @@ -0,0 +1,149 @@ +## Prerequisites + +To run this example we will need: + + * Wallet with sufficient GAS on it. In our example we will be using wallet with address `NP8wjGz3Wvxe4gUAkTbK2McR95Y4LM2jMW`. + +## Running the example + +In this section we will assume that: + + * Our wallet is located next to `web3-course` directory. + * We run all commands in directory `l6`. + +### 1. Compile contracts + +To compile the contracts we run the following commands: + +```sh +$ neo-go contract compile -in player/player_contract.go --config player/config.yml --out player/player.nef --manifest player/config.json + +$ neo-go contract compile -in merchant/merchant_contract.go --config merchant/config.yml --out merchant/merchant.nef --manifest merchant/confi +g.json +``` + +### 2. Assign player contract to the group + +Now we will create a wallet for a group that will contain both contracts. It is possible to skip this step and use wallet `frostfs-aio/morph/node-wallet.json`, but for the sake of purity it is recommended to use a separate wallet. + +To create a wallet, we run the following command. When prompted, enter any name for the account, we will use `l6-game` as group name for this example: + +```sh +$ neo-go wallet init -w ../../group-wallet.json -a +Enter the name of the account > l6-game +Enter new password > +Confirm password > +``` + +Copy account address from the command's output, we will need it in a moment: + +```json +{ + "version": "1.0", + "accounts": [ + { + "address": "NgdcCGcR7QveKn9yjQgpemtgtn5zLn33b6", <-- NOTE: take this value + ... +``` + +Now we need to assign group to contracts' manifest with the following command (pay attention that sender is address of our wallet with GAS, but --wallet and --address are referring to the group wallet that we've just created): + +```sh +$ neo-go contract manifest add-group -n player/player.nef -m player/config.json --sender NP8wjGz3Wvxe4gUAkTbK2McR95Y4LM2jMW --wallet ../../group-wallet.json --address NYThT8RYFhkBzJfzMwCe67A63p2WoDev9X +``` + +Now our contracts are ready to be deployed. + +### 3. Deploy contracts + +Now we can deploy player contract: + +```sh +$ $ neo-go contract deploy -r http://localhost:30333 -w ../../wallet.json --in player/player.nef --manifest player/config.json +``` + +Copy contract hash from the command output and convert it to LE address, for example: + +```sh +... +Contract: 25b7b493c37fc740bac6603281de01f451847805 + +$ neo-go util convert '25b7b493c37fc740bac6603281de01f451847805' +BE ScriptHash to Address NPMQFvjSdBWotKA1PrsrkAtiSzyRXPhDF9 +LE ScriptHash to Address NLQtwNfn4LW3As2r2YfkaRZTpoiTuRfEc5 <-- NOTE: take this value +... +``` + +Now we can deploy merchant contract, it takes hash of player contract as a parameter to its' _deploy method: + +```sh +$ neo-go contract deploy -r http://localhost:30333 -w ../../wallet.json --in merchant/merchant.nef --manifest merchant/config.json [ hash160:N +LQtwNfn4LW3As2r2YfkaRZTpoiTuRfEc5 ] +``` + +Copy contract hash from the command output and convert it to LE address, for example: + +```sh +... +Contract: 443686682d4a3a59408a6b44b766baed894ad679 <-- NOTE: take this value + +$ neo-go util convert '443686682d4a3a59408a6b44b766baed894ad679' +BE ScriptHash to Address NS8eTGN8cNabMe6gG9zAZhHyMueuLfM94j +LE ScriptHash to Address NX2BfBKZaq2eS28Bn4oDA5uuKFKvB2UpxJ <-- take this value +``` + +### 4. Create a new player and trade for in-game currency + +To create a new player, invoke `newPlayer` function on the player contract and specify name of the player. In example below we create a player with the name `demo`: + +```sh +$ neo-go contract invokefunction -r http://localhost:30333 -w ../../wallet.json 25b7b493c37fc740bac6603281de01f451847805 newPlayer string:demo +``` + +Try to buy sword with scope None, it should fail: + +```sh +$ neo-go contract invokefunction -r http://localhost:30333 -w ../../wallet.json 25b7b493c37fc740bac6603281de01f451847805 buyItem string:demo string:Sword -- 'NP8wjGz3Wvxe4gUAkTbK2McR95Y4LM2jMW:None' +``` + +Try to do the same with scope CalledByEntry, it should work: + +```sh +$ neo-go contract invokefunction -r http://localhost:30333 -w ../../wallet.json 25b7b493c37fc740bac6603281de01f451847805 buyItem string:demo string:Sword -- 'NP8wjGz3Wvxe4gUAkTbK2McR95Y4LM2jMW:CalledByEntry' +``` + +Try to sell item: + +```sh +$ neo-go contract invokefunction -r http://localhost:30333 -w ../../wallet.json 25b7b493c37fc740bac6603281de01f451847805 sellItem string:demo string:Sword -- 'NP8wjGz3Wvxe4gUAkTbK2McR95Y4LM2jMW:CalledByEntry' +``` + +Also you can try scopes `CustomContracts` and `CustomGroups`, they should both work too: + +```sh +$ neo-go contract invokefunction -r http://localhost:30333 -w ../../wallet.json 25b7b493c37fc740bac6603281de01f451847805 buyItemForGas string:demo int:1 int:23 hash160:NX2BfBKZaq2eS28Bn4oDA5uuKFKvB2UpxJ -- 'NP8wjGz3Wvxe4gUAkTbK2McR95Y4LM2jMW:CustomContracts:25b7b493c37fc740bac6603281de01f451847805' + +$ neo-go contract invokefunction -r http://localhost:30333 -w ../../wallet.json 25b7b493c37fc740bac6603281de01f451847805 buyItem string:demo string:Sword -- 'NP8wjGz3Wvxe4gUAkTbK2McR95Y4LM2jMW:CustomGroups:029dd9b3639a23ea7ad4356fe7abb4f5a01eaea9f2e5c7138f3e174d21b03f682f' +``` + +### 5. Trade for gas + +Transfer gas to account of merchant contract (you will need LE address of merchant contract that we've captured during deployment): + +```sh +$ neo-go wallet nep17 transfer -r http://localhost:30333 -w ../../frostfs-aio/morph/node-wallet.json --from Nhfg3TbpwogLvDGVvAvqyThbsHgoSU +Kwtn --to NX2BfBKZaq2eS28Bn4oDA5uuKFKvB2UpxJ --token GAS --amount 1000 +``` + +Sell Sword to merchant for 20 GAS: + +```sh +$ neo-go contract invokefunction -r http://localhost:30333 -w ../../wallet.json 25b7b493c37fc740bac6603281de01f451847805 sellItemForGas string:demo string:Sword int:20 hash160:NX2BfBKZaq2eS28Bn4oDA5uuKFKvB2UpxJ -- 'NP8wjGz3Wvxe4gUAkTbK2McR95Y4LM2jMW:CalledByEntry' +``` + +Buy back sword from merchant for 23 GAS: + +```sh +$ neo-go contract invokefunction -r http://localhost:30333 -w ../../wallet.json 25b7b493c37fc740bac6603281de01f451847805 buyItemForGas str +ing:demo int:1 int:23 hash160:NX2BfBKZaq2eS28Bn4oDA5uuKFKvB2UpxJ -- 'NP8wjGz3Wvxe4gUAkTbK2McR95Y4LM2jMW:CustomContracts:d2a4cff31913016155e38e474a2c06d08be276cf:25b7b493c37fc740bac6603281de01f451847805' +``` diff --git a/l6/docs/blog - thou shalt check their witnesses.pdf b/l6/docs/blog - thou shalt check their witnesses.pdf new file mode 100644 index 0000000..aa52ee8 Binary files /dev/null and b/l6/docs/blog - thou shalt check their witnesses.pdf differ diff --git a/l6/docs/slides.pdf b/l6/docs/slides.pdf new file mode 100644 index 0000000..9bdedff Binary files /dev/null and b/l6/docs/slides.pdf differ diff --git a/l6/gameclient/main.go b/l6/gameclient/main.go new file mode 100644 index 0000000..acd5318 --- /dev/null +++ b/l6/gameclient/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "context" + "fmt" + "math/big" + "os" + "strconv" + "time" + + "l6/player/client" + + "github.com/nspcc-dev/neo-go/pkg/core/transaction" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neo-go/pkg/interop/native/gas" + "github.com/nspcc-dev/neo-go/pkg/rpcclient" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/actor" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/wallet" +) + +type args struct { + walletPath string + walletPassword string + playerHash util.Uint160 + lotID int + lotPrice int + merchantHash util.Uint160 + playerName string + endpoint string +} + +func main() { + args, err := parseArgs() + if err != nil { + fmt.Printf("Invalid arguments: %v\n", err) + os.Exit(-1) + } + + ctx := context.Background() + client, txWaiter, err := initPlayerClient(ctx, args.endpoint, args.walletPath, args.walletPassword, args.playerHash) + if err != nil { + fmt.Printf("Cannot connect to blockchain: %v\n", err) + os.Exit(-1) + } + + err = buyItem(client, txWaiter, args.playerName, args.lotID, args.lotPrice, args.merchantHash) + if err != nil { + fmt.Printf("Transaction error: %v\n", err) + os.Exit(-1) + } + + fmt.Printf("Done\n") +} + +func parseArgs() (*args, error) { + walletPath := os.Args[1] + walletPassword := os.Args[2] + playerHash, err := util.Uint160DecodeStringLE(os.Args[3]) + if err != nil { + return nil, fmt.Errorf("failed to parse playerHash: %w", err) + } + merchantHash, err := util.Uint160DecodeStringLE(os.Args[4]) + if err != nil { + return nil, fmt.Errorf("failed to parse merchantHash: %w", err) + } + endpoint := os.Args[5] + + playerName := os.Args[6] + lotID, err := strconv.Atoi(os.Args[7]) + if err != nil { + return nil, fmt.Errorf("failed to parse lotID: %w", err) + } + lotPrice, err := strconv.Atoi(os.Args[8]) + if err != nil { + return nil, fmt.Errorf("failed to parse lotPrice: %w", err) + } + + return &args{ + walletPath: walletPath, + walletPassword: walletPassword, + playerHash: playerHash, + merchantHash: merchantHash, + endpoint: endpoint, + + playerName: playerName, + lotID: lotID, + lotPrice: lotPrice, + }, nil +} + +func initPlayerClient(ctx context.Context, rpcEndpoint, walletPath, walletPassword string, playerHash util.Uint160) (*client.Contract, actor.Waiter, error) { + rpcClient, err := rpcclient.New(ctx, rpcEndpoint, rpcclient.Options{ + DialTimeout: time.Second * 5, + RequestTimeout: time.Second * 5, + }) + if err != nil { + return nil, nil, err + } + + wal, err := wallet.NewWalletFromFile(walletPath) + if err != nil { + return nil, nil, fmt.Errorf("cannot open wallet: %w", err) + } + if len(wal.Accounts) == 0 { + return nil, nil, fmt.Errorf("no accounts in wallet") + } + for _, account := range wal.Accounts { + if err := account.Decrypt(walletPassword, keys.NEP2ScryptParams()); err != nil { + return nil, nil, fmt.Errorf("cannot unlock wallet: %w", err) + } + } + + gasRule := transaction.ConditionAnd([]transaction.WitnessCondition{ + toPtr(transaction.ConditionCalledByContract(playerHash)), + toPtr(transaction.ConditionScriptHash(util.Uint160([]byte(gas.Hash)))), + }) + + acc := wal.Accounts[0] + act, err := actor.New(rpcClient, []actor.SignerAccount{{ + Signer: transaction.Signer{ + Account: acc.Contract.ScriptHash(), + Scopes: transaction.CalledByEntry | transaction.Rules, + Rules: []transaction.WitnessRule{ + { + Action: transaction.WitnessAllow, + Condition: &gasRule, + }, + }, + }, + Account: acc, + }}) + if err != nil { + return nil, nil, fmt.Errorf("cannot init actor: %w", err) + } + return client.New(act, playerHash), act.Waiter, nil +} + +func toPtr[T any](value T) *T { + return &value +} + +func buyItem( + playerClient *client.Contract, + txWaiter actor.Waiter, + playerName string, + lotID int, + lotPrice int, + merchantHash util.Uint160, +) error { + tx, vub, err := playerClient.BuyItemForGas( + playerName, + big.NewInt(int64(lotID)), + big.NewInt(int64(lotPrice)), + merchantHash, + ) + _, err = txWaiter.Wait(tx, vub, err) + return err +} diff --git a/l6/go.mod b/l6/go.mod new file mode 100644 index 0000000..ef2ed04 --- /dev/null +++ b/l6/go.mod @@ -0,0 +1,22 @@ +module l6 + +go 1.20 + +require ( + github.com/nspcc-dev/neo-go v0.103.1 + github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231027092558-8ed6d97085d3 +) + +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/hashicorp/golang-lru v0.6.0 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/nspcc-dev/go-ordered-json v0.0.0-20220111165707-25110be27d22 // indirect + github.com/nspcc-dev/rfc6979 v0.2.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/text v0.13.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/l6/go.sum b/l6/go.sum new file mode 100644 index 0000000..7e491db --- /dev/null +++ b/l6/go.sum @@ -0,0 +1,39 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= +github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/nspcc-dev/go-ordered-json v0.0.0-20220111165707-25110be27d22 h1:n4ZaFCKt1pQJd7PXoMJabZWK9ejjbLOVrkl/lOUmshg= +github.com/nspcc-dev/go-ordered-json v0.0.0-20220111165707-25110be27d22/go.mod h1:79bEUDEviBHJMFV6Iq6in57FEOCMcRhfQnfaf0ETA5U= +github.com/nspcc-dev/neo-go v0.103.1 h1:BfRBceHUu8jSc1KQy7CzmQ/pa+xzAmgcyteGf0/IGgM= +github.com/nspcc-dev/neo-go v0.103.1/go.mod h1:MD7MPiyshUwrE5n1/LzxeandbItaa/iLW/bJb6gNs/U= +github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231027092558-8ed6d97085d3 h1:ybQcK5pTNAR+wQU3k4cGeOZN6OCiVcQkbgR3Zl6NFPU= +github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231027092558-8ed6d97085d3/go.mod h1:J/Mk6+nKeKSW4wygkZQFLQ6SkLOSGX5Ga0RuuuktEag= +github.com/nspcc-dev/rfc6979 v0.2.0 h1:3e1WNxrN60/6N0DW7+UYisLeZJyfqZTNOjeV/toYvOE= +github.com/nspcc-dev/rfc6979 v0.2.0/go.mod h1:exhIh1PdpDC5vQmyEsGvc4YDM/lyQp/452QxGq/UEso= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/syndtr/goleveldb v1.0.1-0.20210305035536-64b5b1c73954 h1:xQdMZ1WLrgkkvOZ/LDQxjVxMLdby7osSh4ZEVa5sIjs= +github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo= +github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74/go.mod h1:RmMWU37GKR2s6pgrIEB4ixgpVCt/cf7dnJv3fuH1J1c= +go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/l6/merchant/config.yml b/l6/merchant/config.yml new file mode 100644 index 0000000..ed61056 --- /dev/null +++ b/l6/merchant/config.yml @@ -0,0 +1,6 @@ +name: "Merchant" +supportedstandards: [] +events: +safemethods: ["getItemName", "getLotsForSale"] +permissions: + - methods: ["onNEP17Payment", "transfer"] diff --git a/l6/merchant/merchant_contract.go b/l6/merchant/merchant_contract.go new file mode 100644 index 0000000..2e83758 --- /dev/null +++ b/l6/merchant/merchant_contract.go @@ -0,0 +1,146 @@ +package player + +import ( + "github.com/nspcc-dev/neo-go/pkg/interop" + "github.com/nspcc-dev/neo-go/pkg/interop/iterator" + "github.com/nspcc-dev/neo-go/pkg/interop/native/gas" + "github.com/nspcc-dev/neo-go/pkg/interop/native/std" + "github.com/nspcc-dev/neo-go/pkg/interop/runtime" + "github.com/nspcc-dev/neo-go/pkg/interop/storage" + "github.com/nspcc-dev/neo-go/pkg/interop/util" +) + +type Lot struct { + lotID int + itemName string + price int + gasPrice int +} + +const ( + lotsPrefix = "lots" + lastIDKey = "lastID" + playerHashKey = "playerHash" + + maxPrice = 100 + gasDecimals = 1_0000_0000 +) + +func _deploy(data interface{}, isUpdate bool) { + if isUpdate { + return + } + + // Parse player contract hash from incoming data + args := data.(struct { + playerHash interop.Hash160 + }) + if len(args.playerHash) != interop.Hash160Len { + panic("invalid hash of player contract") + } + + ctx := storage.GetContext() + storage.Put(ctx, lastIDKey, 0) + storage.Put(ctx, playerHashKey, args.playerHash) +} + +func GetItemName(lotID int) string { + return getLot(storage.GetReadOnlyContext(), lotID).itemName +} + +func GetLotsForSale() []Lot { + lots := make([]Lot, 0) + + ctx := storage.GetReadOnlyContext() + it := storage.Find(ctx, lotsPrefix, storage.ValuesOnly|storage.DeserializeValues) + for iterator.Next(it) { + lot := iterator.Value(it).(Lot) + lots = append(lots, lot) + } + return lots +} + +func Sell(itemName string, price int) { + ctx := storage.GetContext() + playerHash := storage.Get(ctx, playerHashKey).(interop.Hash160) + + if !runtime.GetCallingScriptHash().Equals(playerHash) { + panic("can be called from player hash only") + } + if price > maxPrice { + panic("unacceptable price") + } + + // Pay for the item + if price > 0 { + gasAmount := price * gasDecimals + success := gas.Transfer(runtime.GetExecutingScriptHash(), runtime.GetScriptContainer().Sender, gasAmount, nil) + if !success { + panic("failed to transfer gas to player") + } + } + + // Place lot in the sale list + createLot(ctx, itemName, price) +} + +func OnNEP17Payment(from interop.Hash160, amount int, data any) { + defer func() { + if r := recover(); r != nil { + runtime.Log(r.(string)) + util.Abort() + } + }() + + callingHash := runtime.GetCallingScriptHash() + if !callingHash.Equals(gas.Hash) { + panic("only GAS is accepted") + } + + if data == nil { + runtime.Log("received operational GAS") + return + } + + ctx := storage.GetContext() + + lotID := data.(int) + lot := getLot(ctx, lotID) + + if amount < lot.gasPrice { + panic("gas amount does not cover lot price") + } + + // That's what evil merchant might attempt to do + // gas.Transfer(from, runtime.GetExecutingScriptHash(), 10*gasDecimals, nil) + + // Lot must be removed (we can't list it for sale anymore) + deleteLot(ctx, lotID) +} + +func getLot(ctx storage.Context, lotID int) Lot { + itemData := storage.Get(ctx, lotsPrefix+std.Itoa10(lotID)).([]byte) + if itemData == nil { + panic("item not found") + } + return std.Deserialize(itemData).(Lot) +} + +func createLot(ctx storage.Context, itemName string, price int) { + lastID := storage.Get(ctx, lastIDKey).(int) + nextID := lastID + 1 + storage.Put(ctx, lastIDKey, nextID) + + resellPrice := price + 1 + price/10 + newItem := Lot{ + itemName: itemName, + price: resellPrice, + gasPrice: resellPrice * gasDecimals, + lotID: nextID, + } + storage.Put(ctx, lotsPrefix+std.Itoa10(nextID), std.Serialize(newItem)) +} + +func deleteLot(ctx storage.Context, lotID int) { + storage.Delete(ctx, lotsPrefix+std.Itoa10(lotID)) +} diff --git a/l6/player/client/client.go b/l6/player/client/client.go new file mode 100644 index 0000000..29bc3c8 --- /dev/null +++ b/l6/player/client/client.go @@ -0,0 +1,183 @@ +package client + +import ( + "math/big" + + "github.com/nspcc-dev/neo-go/pkg/core/transaction" + "github.com/nspcc-dev/neo-go/pkg/util" +) + +// Actor is used by Contract to call state-changing methods. +type Actor interface { + MakeCall(contract util.Uint160, method string, params ...any) (*transaction.Transaction, error) + MakeRun(script []byte) (*transaction.Transaction, error) + MakeUnsignedCall(contract util.Uint160, method string, attrs []transaction.Attribute, params ...any) (*transaction.Transaction, error) + MakeUnsignedRun(script []byte, attrs []transaction.Attribute) (*transaction.Transaction, error) + SendCall(contract util.Uint160, method string, params ...any) (util.Uint256, uint32, error) + SendRun(script []byte) (util.Uint256, uint32, error) +} + +// Contract implements all contract methods. +type Contract struct { + actor Actor + hash util.Uint160 +} + +// New creates an instance of Contract using Hash and the given Actor. +func New(actor Actor, hash util.Uint160) *Contract { + return &Contract{actor, hash} +} + +// Balance creates a transaction invoking `balance` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) Balance(playerName string) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "balance", playerName) +} + +// BalanceTransaction creates a transaction invoking `balance` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) BalanceTransaction(playerName string) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "balance", playerName) +} + +// BalanceUnsigned creates a transaction invoking `balance` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) BalanceUnsigned(playerName string) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "balance", nil, playerName) +} + +// BuyItem creates a transaction invoking `buyItem` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) BuyItem(playerName string, itemName string) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "buyItem", playerName, itemName) +} + +// BuyItemTransaction creates a transaction invoking `buyItem` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) BuyItemTransaction(playerName string, itemName string) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "buyItem", playerName, itemName) +} + +// BuyItemUnsigned creates a transaction invoking `buyItem` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) BuyItemUnsigned(playerName string, itemName string) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "buyItem", nil, playerName, itemName) +} + +// BuyItemForGas creates a transaction invoking `buyItemForGas` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) BuyItemForGas(playerName string, lotID *big.Int, itemPrice *big.Int, merchantHash util.Uint160) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "buyItemForGas", playerName, lotID, itemPrice, merchantHash) +} + +// BuyItemForGasTransaction creates a transaction invoking `buyItemForGas` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) BuyItemForGasTransaction(playerName string, lotID *big.Int, itemPrice *big.Int, merchantHash util.Uint160) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "buyItemForGas", playerName, lotID, itemPrice, merchantHash) +} + +// BuyItemForGasUnsigned creates a transaction invoking `buyItemForGas` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) BuyItemForGasUnsigned(playerName string, lotID *big.Int, itemPrice *big.Int, merchantHash util.Uint160) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "buyItemForGas", nil, playerName, lotID, itemPrice, merchantHash) +} + +// Items creates a transaction invoking `items` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) Items(playerName string) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "items", playerName) +} + +// ItemsTransaction creates a transaction invoking `items` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) ItemsTransaction(playerName string) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "items", playerName) +} + +// ItemsUnsigned creates a transaction invoking `items` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) ItemsUnsigned(playerName string) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "items", nil, playerName) +} + +// NewPlayer creates a transaction invoking `newPlayer` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) NewPlayer(playerName string) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "newPlayer", playerName) +} + +// NewPlayerTransaction creates a transaction invoking `newPlayer` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) NewPlayerTransaction(playerName string) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "newPlayer", playerName) +} + +// NewPlayerUnsigned creates a transaction invoking `newPlayer` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) NewPlayerUnsigned(playerName string) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "newPlayer", nil, playerName) +} + +// SellItem creates a transaction invoking `sellItem` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) SellItem(playerName string, itemName string) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "sellItem", playerName, itemName) +} + +// SellItemTransaction creates a transaction invoking `sellItem` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) SellItemTransaction(playerName string, itemName string) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "sellItem", playerName, itemName) +} + +// SellItemUnsigned creates a transaction invoking `sellItem` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) SellItemUnsigned(playerName string, itemName string) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "sellItem", nil, playerName, itemName) +} + +// SellItemForGas creates a transaction invoking `sellItemForGas` method of the contract. +// This transaction is signed and immediately sent to the network. +// The values returned are its hash, ValidUntilBlock value and error if any. +func (c *Contract) SellItemForGas(playerName string, itemName string, itemPrice *big.Int, merchantHash util.Uint160) (util.Uint256, uint32, error) { + return c.actor.SendCall(c.hash, "sellItemForGas", playerName, itemName, itemPrice, merchantHash) +} + +// SellItemForGasTransaction creates a transaction invoking `sellItemForGas` method of the contract. +// This transaction is signed, but not sent to the network, instead it's +// returned to the caller. +func (c *Contract) SellItemForGasTransaction(playerName string, itemName string, itemPrice *big.Int, merchantHash util.Uint160) (*transaction.Transaction, error) { + return c.actor.MakeCall(c.hash, "sellItemForGas", playerName, itemName, itemPrice, merchantHash) +} + +// SellItemForGasUnsigned creates a transaction invoking `sellItemForGas` method of the contract. +// This transaction is not signed, it's simply returned to the caller. +// Any fields of it that do not affect fees can be changed (ValidUntilBlock, +// Nonce), fee values (NetworkFee, SystemFee) can be increased as well. +func (c *Contract) SellItemForGasUnsigned(playerName string, itemName string, itemPrice *big.Int, merchantHash util.Uint160) (*transaction.Transaction, error) { + return c.actor.MakeUnsignedCall(c.hash, "sellItemForGas", nil, playerName, itemName, itemPrice, merchantHash) +} diff --git a/l6/player/config.yml b/l6/player/config.yml new file mode 100644 index 0000000..b6d7eef --- /dev/null +++ b/l6/player/config.yml @@ -0,0 +1,5 @@ +name: "Player" +supportedstandards: [] +events: +permissions: + - methods: "*" diff --git a/l6/player/player_contract.go b/l6/player/player_contract.go new file mode 100644 index 0000000..46e31a4 --- /dev/null +++ b/l6/player/player_contract.go @@ -0,0 +1,172 @@ +package player + +import ( + "github.com/nspcc-dev/neo-go/pkg/interop" + "github.com/nspcc-dev/neo-go/pkg/interop/contract" + "github.com/nspcc-dev/neo-go/pkg/interop/native/gas" + "github.com/nspcc-dev/neo-go/pkg/interop/native/std" + "github.com/nspcc-dev/neo-go/pkg/interop/neogointernal" + "github.com/nspcc-dev/neo-go/pkg/interop/runtime" + "github.com/nspcc-dev/neo-go/pkg/interop/storage" + "github.com/nspcc-dev/neo-go/pkg/interop/util" +) + +const gasDecimals = 1_0000_0000 + +var itemPrices = map[string]int{ + "Sword": 200, + "Shortbow": 100, + "Longbow": 300, +} + +type Player struct { + balance int + owner interop.Hash160 + itemCount map[string]int +} + +func NewPlayer(playerName string) { + ctx := storage.GetContext() + + existingPlayer := storage.Get(ctx, playerName) + if existingPlayer != nil { + panic("player already exists") + } + + player := Player{ + balance: 3000, + owner: runtime.GetScriptContainer().Sender, + itemCount: make(map[string]int), + } + savePlayer(ctx, playerName, player) +} + +func Balance(playerName string) int { + p := getPlayer(storage.GetReadOnlyContext(), playerName) + return p.balance +} + +func Items(playerName string) []string { + p := getPlayer(storage.GetReadOnlyContext(), playerName) + + items := make([]string, len(p.itemCount)) + for itemName, count := range p.itemCount { + items = append(items, itemName+" "+std.Itoa10(count)) + } + return items +} + +func BuyItem(playerName string, itemName string) { + ctx := storage.GetContext() + player := getPlayer(ctx, playerName) + if !runtime.CheckWitness(player.owner) { + panic("player not witnessed") + } + + itemPrice := itemPrices[itemName] + if player.balance < itemPrice { + panic("insufficient balance") + } + + addItemToInventory(player, itemName) + player.balance -= itemPrice + + savePlayer(ctx, playerName, player) +} + +func SellItem(playerName string, itemName string) { + ctx := storage.GetContext() + player := getPlayer(ctx, playerName) + if !runtime.CheckWitness(player.owner) { + panic("player not witnessed") + } + + removeItemFromInventory(player, itemName) + player.balance += itemPrices[itemName] + + savePlayer(ctx, playerName, player) +} + +func BuyItemForGas(playerName string, lotID int, itemPrice int, merchantHash interop.Hash160) { + ctx := storage.GetContext() + player := getPlayer(ctx, playerName) + if !runtime.CheckWitness(player.owner) { + panic("player not witnessed") + } + + itemName := contract.Call(merchantHash, "getItemName", contract.ReadOnly, lotID).(string) + + gasPrice := itemPrice * gasDecimals + success := gas.Transfer(runtime.GetScriptContainer().Sender, merchantHash, gasPrice, lotID) + // If we were to use custom token instead of GAS, we would call it like a regular contract: + // success := contract.Call(, "transfer", contract.All, runtime.GetScriptContainer().Sender, merchantHash, gasPrice, lotID).(bool) + + if !success { + panic("failed to transfer gas to merchant") + } + + addItemToInventory(player, itemName) + savePlayer(ctx, playerName, player) +} + +func SellItemForGas(playerName string, itemName string, itemPrice int, merchantHash interop.Hash160) { + ctx := storage.GetContext() + player := getPlayer(ctx, playerName) + if !runtime.CheckWitness(player.owner) { + panic("player not witnessed") + } + + removeItemFromInventory(player, itemName) + + balanceBefore := gas.BalanceOf(runtime.GetScriptContainer().Sender) + contract.Call(merchantHash, "sell", contract.All, itemName, itemPrice) + balanceAfter := gas.BalanceOf(runtime.GetScriptContainer().Sender) + + gasProfit := (balanceAfter - balanceBefore) + if gasProfit < itemPrice*gasDecimals { + runtime.Log("merchant payment does not cover item price: " + std.Itoa10(gasProfit) + " < " + std.Itoa10(itemPrice*gasDecimals)) + util.Abort() + } + + savePlayer(ctx, playerName, player) +} + +func getPlayer(ctx storage.Context, playerName string) Player { + data := storage.Get(ctx, playerName) + if data == nil { + panic("player not found") + } + + return std.Deserialize(data.([]byte)).(Player) +} + +func savePlayer(ctx storage.Context, playerName string, player Player) { + storage.Put(ctx, playerName, std.Serialize(player)) +} + +func hasItemInInventory(player Player, itemName string) bool { + // Standard syntax for go map does not work: _, exists := player.itemCount[itemName] + // So, we use HASKEY command to do this: + hasKey := neogointernal.Opcode2("HASKEY", player.itemCount, itemName).(bool) + if !hasKey { + return false + } + return player.itemCount[itemName] > 0 +} + +func addItemToInventory(player Player, itemName string) { + if !hasItemInInventory(player, itemName) { + player.itemCount[itemName] = 0 + } + player.itemCount[itemName] += 1 +} + +func removeItemFromInventory(player Player, itemName string) { + if !hasItemInInventory(player, itemName) { + panic("player has no specified item") + } + player.itemCount[itemName] -= 1 + if player.itemCount[itemName] == 0 { + delete(player.itemCount, itemName) + } +}