contracts craps, zaCoin
This commit is contained in:
parent
7ab6c67c46
commit
b0b09657c1
14 changed files with 339 additions and 17 deletions
1
Craps/config.json
Executable file
1
Craps/config.json
Executable file
|
@ -0,0 +1 @@
|
||||||
|
{"name":"Craps","abi":{"methods":[{"name":"_deploy","offset":0,"parameters":[{"name":"data","type":"Any"},{"name":"isUpdate","type":"Boolean"}],"returntype":"Void","safe":false},{"name":"onNEP17Payment","offset":165,"parameters":[{"name":"from","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Void","safe":false},{"name":"playCraps","offset":95,"parameters":[{"name":"bet","type":"Integer"},{"name":"firstSum","type":"Integer"},{"name":"secondSum","type":"Integer"}],"returntype":"Void","safe":false}],"events":[]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null}
|
|
@ -1,22 +1,99 @@
|
||||||
package Craps
|
package Craps
|
||||||
|
|
||||||
import (
|
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/runtime"
|
"github.com/nspcc-dev/neo-go/pkg/interop/runtime"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/interop/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func IsWinner(firstSum int, secondSum int) bool {
|
const (
|
||||||
if (!((firstSum >= 3 && firstSum <= 18) && (secondSum >= 3 && firstSum <= 18))){
|
gasDecimals = 1_0000_0000
|
||||||
panic("first and second sum should be from 3 to 36")
|
initialBalance = 3000
|
||||||
|
zaCoinHashKey = "zaCoinHash"
|
||||||
|
)
|
||||||
|
|
||||||
|
func _deploy(data interface{}, isUpdate bool) {
|
||||||
|
if isUpdate {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sum := 0
|
// Parse hash of forint contract from incoming data
|
||||||
|
args := data.(struct {
|
||||||
|
zaCoinHash interop.Hash160
|
||||||
|
})
|
||||||
|
|
||||||
for i:=0; i<3; i++ {
|
if len(args.zaCoinHash) != interop.Hash160Len {
|
||||||
crap := randomInRange(1, 6)
|
panic("invalid hash of zaCoin contract")
|
||||||
runtime.Notify("Crup number: %d,Random Number: %d", i+1, randomNumber)
|
|
||||||
sum += crup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ctx := storage.GetContext()
|
||||||
|
storage.Put(ctx, zaCoinHashKey, args.zaCoinHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlayCraps(bet int, firstSum int, secondSum int) {
|
||||||
|
ctx := storage.GetContext()
|
||||||
|
playerOwner := runtime.GetScriptContainer().Sender
|
||||||
|
isWin := isWinner(firstSum, secondSum)
|
||||||
|
if (isWin){
|
||||||
|
changePlayerBalance(ctx, playerOwner, bet)
|
||||||
|
} else {
|
||||||
|
changePlayerBalance(ctx, playerOwner, -bet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWinner(firstSum int, secondSum int) bool {
|
||||||
|
//if (!((firstSum >= 3 && firstSum <= 18) && (secondSum >= 3 && firstSum <= 18))){
|
||||||
|
// panic("first and second sum should be from 3 to 18")
|
||||||
|
//}
|
||||||
|
|
||||||
|
//sum := 0
|
||||||
|
|
||||||
|
//for i:=0; i<3; i++ {
|
||||||
|
// crap := rand.Intn(5) + 1
|
||||||
|
// runtime.Notify("Crup number: %d,Random Number: %d", i+1, crap)
|
||||||
|
// sum += crap
|
||||||
|
//}
|
||||||
|
var sum int
|
||||||
|
sum = runtime.GetRandom()
|
||||||
|
|
||||||
return sum == firstSum || sum == secondSum
|
return sum == firstSum || sum == secondSum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func OnNEP17Payment(from interop.Hash160, amount int, data any) {
|
||||||
|
ctx := storage.GetContext()
|
||||||
|
zaCoinHash := storage.Get(ctx, zaCoinHashKey).(interop.Hash160)
|
||||||
|
|
||||||
|
callingHash := runtime.GetCallingScriptHash()
|
||||||
|
if !callingHash.Equals(zaCoinHash) {
|
||||||
|
panic("only ZC is accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func changePlayerBalance(ctx storage.Context, playerOwner interop.Hash160, balanceChange int) {
|
||||||
|
zaCoinHash := storage.Get(ctx, zaCoinHashKey).(interop.Hash160)
|
||||||
|
playerContract := runtime.GetExecutingScriptHash()
|
||||||
|
|
||||||
|
var from, to interop.Hash160
|
||||||
|
var transferAmount int
|
||||||
|
if balanceChange > 0 {
|
||||||
|
// Transfer funds from contract to player owner
|
||||||
|
from = playerContract
|
||||||
|
to = playerOwner
|
||||||
|
transferAmount = balanceChange
|
||||||
|
} else {
|
||||||
|
// Transfer funds from player owner to contract
|
||||||
|
from = playerOwner
|
||||||
|
to = playerContract
|
||||||
|
transferAmount = -balanceChange // We flip sender/receiver, but keep amount positive
|
||||||
|
}
|
||||||
|
|
||||||
|
transferred := contract.Call(zaCoinHash, "transfer", contract.All, from, to, transferAmount, nil).(bool)
|
||||||
|
if !transferred {
|
||||||
|
panic("failed to transfer zaCoins")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
BIN
Craps/craps.nef
Executable file
BIN
Craps/craps.nef
Executable file
Binary file not shown.
|
@ -1,11 +1,5 @@
|
||||||
name: Craps
|
name: Craps
|
||||||
sourceurl: http://example.com/
|
|
||||||
safemethods: []
|
|
||||||
supportedstandards: []
|
supportedstandards: []
|
||||||
events:
|
events:
|
||||||
- name: Hello world!
|
|
||||||
parameters:
|
|
||||||
- name: args
|
|
||||||
type: Array
|
|
||||||
permissions:
|
permissions:
|
||||||
- methods: '*'
|
- methods: '*'
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
module Craps
|
module Craps
|
||||||
require (
|
|
||||||
github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231020160724-c3955f87d1b5
|
go 1.21.4
|
||||||
)
|
|
||||||
|
require github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231121104256-0493ddbd70b2
|
||||||
|
|
||||||
|
require github.com/nspcc-dev/neo-go v0.104.0 // indirect
|
||||||
|
|
6
Craps/go.sum
Normal file
6
Craps/go.sum
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
github.com/nspcc-dev/neo-go v0.104.0 h1:FGj3Z46yABcFIAI1SCLd1jQSoh+B00h/2VAgEgY1JKQ=
|
||||||
|
github.com/nspcc-dev/neo-go v0.104.0/go.mod h1:omsUK5PAtG2/nQ3/evs95QEg3wtkj3LH53e0NKtXVwQ=
|
||||||
|
github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231020160724-c3955f87d1b5 h1:09CpI5uwsxb1EeFPIKQRwwWlfCmDD/Dwwh01lPiQScM=
|
||||||
|
github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231020160724-c3955f87d1b5/go.mod h1:J/Mk6+nKeKSW4wygkZQFLQ6SkLOSGX5Ga0RuuuktEag=
|
||||||
|
github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231121104256-0493ddbd70b2 h1:hPVF8iMmsQ15GSemj1ma6C9BkwfAugEXsUAVTEniK5M=
|
||||||
|
github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231121104256-0493ddbd70b2/go.mod h1:J/Mk6+nKeKSW4wygkZQFLQ6SkLOSGX5Ga0RuuuktEag=
|
1
ZaCoin/config.json
Executable file
1
ZaCoin/config.json
Executable file
|
@ -0,0 +1 @@
|
||||||
|
{"name":"ZaCoin","abi":{"methods":[{"name":"balanceOf","offset":583,"parameters":[{"name":"holder","type":"Hash160"}],"returntype":"Integer","safe":true},{"name":"decimals","offset":559,"parameters":[],"returntype":"Integer","safe":true},{"name":"isUsableAddress","offset":254,"parameters":[{"name":"addr","type":"ByteArray"}],"returntype":"Boolean","safe":false},{"name":"mint","offset":633,"parameters":[{"name":"to","type":"Hash160"}],"returntype":"Void","safe":false},{"name":"symbol","offset":554,"parameters":[],"returntype":"String","safe":true},{"name":"totalSupply","offset":564,"parameters":[],"returntype":"Integer","safe":true},{"name":"transfer","offset":606,"parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Boolean","safe":false}],"events":[{"name":"Transfer","parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"}]}]},"features":{},"groups":[{"pubkey":"027171df30177d401c638fb2ddc14f9dbda323291e363ba4f7c3b19a8b44c8ba0a","signature":"2YQ2Jy/KZJlg9lQyguFyOpKfY4L2HYvwHkb6XkiALhVlPFVUKXvKMF5I2u9dnIFCLvI0W9h/D/46edTwPCpLjg=="}],"permissions":[{"contract":"*","methods":["onNEP17Payment"]}],"supportedstandards":["NEP-17"],"trusts":[],"extra":null}
|
14
ZaCoin/config.yml
Normal file
14
ZaCoin/config.yml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
name: "ZaCoin"
|
||||||
|
supportedstandards: ["NEP-17"]
|
||||||
|
safemethods: ["balanceOf", "decimals", "symbol", "totalSupply"]
|
||||||
|
events:
|
||||||
|
- name: Transfer
|
||||||
|
parameters:
|
||||||
|
- name: from
|
||||||
|
type: Hash160
|
||||||
|
- name: to
|
||||||
|
type: Hash160
|
||||||
|
- name: amount
|
||||||
|
type: Integer
|
||||||
|
permissions:
|
||||||
|
- methods: ["onNEP17Payment"]
|
5
ZaCoin/go.mod
Normal file
5
ZaCoin/go.mod
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
module ZaCoin
|
||||||
|
|
||||||
|
go 1.21.4
|
||||||
|
|
||||||
|
require github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231020160724-c3955f87d1b5
|
2
ZaCoin/go.sum
Normal file
2
ZaCoin/go.sum
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231020160724-c3955f87d1b5 h1:09CpI5uwsxb1EeFPIKQRwwWlfCmDD/Dwwh01lPiQScM=
|
||||||
|
github.com/nspcc-dev/neo-go/pkg/interop v0.0.0-20231020160724-c3955f87d1b5/go.mod h1:J/Mk6+nKeKSW4wygkZQFLQ6SkLOSGX5Ga0RuuuktEag=
|
160
ZaCoin/token.go
Normal file
160
ZaCoin/token.go
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
/*
|
||||||
|
The implementation of the token logic has been copied "as is" from neo-go:
|
||||||
|
https://github.com/nspcc-dev/neo-go/blob/7a1bf77585a37302ddfae78b2c88ed472f66cbcc/examples/token/nep17/nep17.go
|
||||||
|
|
||||||
|
The code is subject to the following license:
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2018-2023 NeoSPCC (@nspcc-dev), Anthony De Meulemeester (@anthdm), City of Zion community (@CityOfZion)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package ZaCoin
|
||||||
|
|
||||||
|
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/management"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/interop/runtime"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/interop/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Token holds all token info
|
||||||
|
type Token struct {
|
||||||
|
// Token name
|
||||||
|
Name string
|
||||||
|
// Ticker symbol
|
||||||
|
Symbol string
|
||||||
|
// Amount of decimals
|
||||||
|
Decimals int
|
||||||
|
// Token owner address
|
||||||
|
Owner []byte
|
||||||
|
// Total tokens * multiplier
|
||||||
|
TotalSupply int
|
||||||
|
// Storage key for circulation value
|
||||||
|
CirculationKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIntFromDB is a helper that checks for nil result of storage.Get and returns
|
||||||
|
// zero as the default value.
|
||||||
|
func getIntFromDB(ctx storage.Context, key []byte) int {
|
||||||
|
var res int
|
||||||
|
val := storage.Get(ctx, key)
|
||||||
|
if val != nil {
|
||||||
|
res = val.(int)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSupply gets the token totalSupply value from VM storage
|
||||||
|
func (t Token) GetSupply(ctx storage.Context) int {
|
||||||
|
return getIntFromDB(ctx, []byte(t.CirculationKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BalanceOf gets the token balance of a specific address
|
||||||
|
func (t Token) BalanceOf(ctx storage.Context, holder []byte) int {
|
||||||
|
return getIntFromDB(ctx, holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer token from one user to another
|
||||||
|
func (t Token) Transfer(ctx storage.Context, from, to interop.Hash160, amount int, data any) bool {
|
||||||
|
amountFrom := t.CanTransfer(ctx, from, to, amount)
|
||||||
|
if amountFrom == -1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if amountFrom == 0 {
|
||||||
|
storage.Delete(ctx, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
if amountFrom > 0 {
|
||||||
|
diff := amountFrom - amount
|
||||||
|
storage.Put(ctx, from, diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
amountTo := getIntFromDB(ctx, to)
|
||||||
|
totalAmountTo := amountTo + amount
|
||||||
|
if totalAmountTo != 0 {
|
||||||
|
storage.Put(ctx, to, totalAmountTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.Notify("Transfer", from, to, amount)
|
||||||
|
if to != nil && management.GetContract(to) != nil {
|
||||||
|
contract.Call(to, "onNEP17Payment", contract.All, from, amount, data)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanTransfer returns the amount it can transfer
|
||||||
|
func (t Token) CanTransfer(ctx storage.Context, from []byte, to []byte, amount int) int {
|
||||||
|
if len(to) != 20 || !IsUsableAddress(from) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
amountFrom := getIntFromDB(ctx, from)
|
||||||
|
if amountFrom < amount {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell Transfer the result is equal - special case since it uses Delete
|
||||||
|
if amountFrom == amount {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// return amountFrom value back to Transfer, reduces extra Get
|
||||||
|
return amountFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUsableAddress checks if the sender is either the correct Neo address or SC address
|
||||||
|
func IsUsableAddress(addr []byte) bool {
|
||||||
|
if len(addr) == 20 {
|
||||||
|
|
||||||
|
if runtime.CheckWitness(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a smart contract is calling scripthash
|
||||||
|
callingScriptHash := runtime.GetCallingScriptHash()
|
||||||
|
if callingScriptHash.Equals(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint initial supply of tokens
|
||||||
|
func (t Token) Mint(ctx storage.Context, to interop.Hash160) bool {
|
||||||
|
if !IsUsableAddress(t.Owner) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
minted := storage.Get(ctx, []byte("minted"))
|
||||||
|
if minted != nil && minted.(bool) == true {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.Put(ctx, to, t.TotalSupply)
|
||||||
|
storage.Put(ctx, []byte("minted"), true)
|
||||||
|
storage.Put(ctx, []byte(t.CirculationKey), t.TotalSupply)
|
||||||
|
var from interop.Hash160
|
||||||
|
runtime.Notify("Transfer", from, to, t.TotalSupply)
|
||||||
|
return true
|
||||||
|
}
|
BIN
ZaCoin/zaCoin.nef
Executable file
BIN
ZaCoin/zaCoin.nef
Executable file
Binary file not shown.
58
ZaCoin/zacoin_contract.go
Normal file
58
ZaCoin/zacoin_contract.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package ZaCoin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/interop"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/interop/lib/address"
|
||||||
|
"github.com/nspcc-dev/neo-go/pkg/interop/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getToken() Token {
|
||||||
|
// Owner of the wallet is wallets/game-wallet.json нету еще
|
||||||
|
owner := address.ToHash160("NXbLSnHA8dNuMUPUSNNivx7XFucN1w5bRq")
|
||||||
|
token := Token{
|
||||||
|
Name: "ZaCoin",
|
||||||
|
Symbol: "ZC",
|
||||||
|
Decimals: 0,
|
||||||
|
Owner: owner,
|
||||||
|
TotalSupply: 1000000,
|
||||||
|
CirculationKey: "TokenCirculation",
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbol returns the token symbol.
|
||||||
|
func Symbol() string {
|
||||||
|
return getToken().Symbol
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decimals returns the number of digits after decimal point.
|
||||||
|
func Decimals() int {
|
||||||
|
return getToken().Decimals
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalSupply returns the total amount of tokens.
|
||||||
|
func TotalSupply() int {
|
||||||
|
ctx := storage.GetReadOnlyContext()
|
||||||
|
return getToken().GetSupply(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BalanceOf returns the amount of tokens owned by the specified address.
|
||||||
|
func BalanceOf(holder interop.Hash160) int {
|
||||||
|
ctx := storage.GetReadOnlyContext()
|
||||||
|
return getToken().BalanceOf(ctx, holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer moves token from one address to another.
|
||||||
|
func Transfer(from interop.Hash160, to interop.Hash160, amount int, data any) bool {
|
||||||
|
ctx := storage.GetContext()
|
||||||
|
return getToken().Transfer(ctx, from, to, amount, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint generates initial supply of tokens.
|
||||||
|
func Mint(to interop.Hash160) {
|
||||||
|
ctx := storage.GetContext()
|
||||||
|
minted := getToken().Mint(ctx, to)
|
||||||
|
if !minted {
|
||||||
|
panic("failed to mint initial supply")
|
||||||
|
}
|
||||||
|
}
|
1
wallets/game-wallet.json
Normal file
1
wallets/game-wallet.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
{"version":"1.0","accounts":[{"address":"NXbLSnHA8dNuMUPUSNNivx7XFucN1w5bRq","key":"6PYRRKFgimr5vtwYQYd9fKjEQPZtkP66tWJzswxmDGwXyU1qUhvXcNbBJY","label":"frida","contract":{"script":"DCECcXHfMBd9QBxjj7LdwU+dvaMjKR42O6T3w7Gai0TIugpBVuezJw==","parameters":[{"name":"parameter0","type":"Signature"}],"deployed":false},"lock":false,"isDefault":false}],"scrypt":{"n":16384,"r":8,"p":8},"extra":{"Tokens":null}}
|
Loading…
Add table
Reference in a new issue