diff --git a/ride4dapps/blockchain/README.md b/ride4dapps/blockchain/README.md new file mode 100644 index 0000000..a4798d4 --- /dev/null +++ b/ride4dapps/blockchain/README.md @@ -0,0 +1,43 @@ +**Rudechain** - Proof-of-Work блокчейн в виде dApp. Валюта - **Rude**. +Любая транзакция - это скрипт на специальном языке *RUDE*. Типов транзакций нет - поведение определяется скриптом. + +**RUDE** - простой интерпретируемый язык. Сейчас поддерживает две операции: +1. `SEND recipient amount` - отправка _руды_ на другой аккаунт Rudechain-а. +2. `SWAP [IN/OUT] amount` - ввод _руды_ с Waves-аккаунта в его Rude-аккаунт и, наоборот, вывод _руды_ в соответствующий ассет блокчейна Waves. + +В дальнейшем можно добавить в интерпретатор поддержку и других операций. + +Клиент доступен по ссылке: http://rudechain.mak.im/ (или можно локально запустить из Docker образа) \ +Для авторизации и регистрации **переключите Waves Keeper в режим Testnet!** + +Кратко о том, как это работает: +1. для работы с Rudechain нужно выполнить регистрацию (стоит 5 Waves, чтобы не случилось спама учеток). +2. любая операция с блокчейном - через отправку InvokeScript транзакции. +3. чтобы смайнить новый блок, нужно перебрать `nonce` (тип Long) так, чтобы получившийся хеш нового блока начинался с нескольких (**difficulty**) нулевых байт. +4. каждый блок ссылается на высоту блокчейна Waves. На одной высоте можно смайнить только один блок. +5. каждая транзакция представляет из себя **RUDE** скрипт, исполняемый майнером. В зависимости от сложности скрипта транзакции взимается газ. +6. при отправке транзакции пользователь указывает, сколько газа готов заплатить. При попадании в блок списывается только фактически потраченный газ. +7. после отправки транзакция валидируется и кладется в **UTX**. При валидации проверяется баланс и корректность скрипта. Средства для транзакции резервируются до её попадания в блок. +8. текущий майнер может обрабатывать **UTX**, отправляя InvokeScript на каждую неподтвержденную транзакцию. При обработке скрипт прогоняется через интерпретатор **RUDE**, а результат исполнения фиксируется как разультат InvokeScript-а. +9. если смайнен новый блок, то майнер нового блока получает награду - _руду_. Старый блок фиксируется, а его автор получает половину газа, затраченного на транзакции в нем. +10. если блоки майнятся часто - **difficulty** растет, реже - **difficulty** падает. + +Roadmap по дальнейшим улучшениям: + +Майнер +* сейчас есть баг, что неправильно считаются нулевые байты. В ближайшее время поправлю +* автоматизировать поведение майнера. Сейчас майнер не работает нонстоп, а разово выполняет попытку смайнить блок или обработать одну транзакцию из UTX (если аккаунт - майнер текущего блока) + +Клиент +* выводить подробную информацию о выбранном блоке, в т.ч. список его транзакций +* выводить информацию о выбранном аккаунте + +Язык RUDE +* поддержка последовательности команд в одном скрипте +* расширить набор команд +* возможно, сделать язык стековым + +Блокчейн +* наверно стоит пересмотреть саму идею, сделать еще чуть серьезнее. Например, сейчас у транзакций нет id - они просто пакуются в блок +* выровнять расчет сложности +* возможно, заменить пазл (что-нибудь интереснее, чем считать первые нулевые байты) \ No newline at end of file diff --git a/ride4dapps/blockchain/client/Dockerfile b/ride4dapps/blockchain/client/Dockerfile new file mode 100644 index 0000000..903d156 --- /dev/null +++ b/ride4dapps/blockchain/client/Dockerfile @@ -0,0 +1,11 @@ +FROM node:slim + +RUN npm install -g node-static + +WORKDIR /static +COPY index.html . +COPY favicon.ico . + +EXPOSE 80 +CMD static --host-address 0.0.0.0 -p 80 --gzip + diff --git a/ride4dapps/blockchain/client/favicon.ico b/ride4dapps/blockchain/client/favicon.ico new file mode 100644 index 0000000..11146b8 Binary files /dev/null and b/ride4dapps/blockchain/client/favicon.ico differ diff --git a/ride4dapps/blockchain/client/index.html b/ride4dapps/blockchain/client/index.html new file mode 100644 index 0000000..b29415d --- /dev/null +++ b/ride4dapps/blockchain/client/index.html @@ -0,0 +1,363 @@ + + + + + + + + Rudechain + + + + + + + + +
+ + + + + + + + + RUDECHAIN + + General Info +
    +
  • Height: {{height}}
  • +
  • Waves height: {{wavesHeight}}
  • +
  • Difficulty: {{base}}
  • +
+
+
+ +
+ + + + ACCOUNT + + Account Info +
    +
  • Name: {{user.name}}
  • +
  • Total balance: {{user.totalBalance}}
  • +
  • Available balance: {{user.availableBalance}}
  • +
  • Asset balance in Waves: {{user.assetBalance}}
  • +
  • Registered at height: {{user.registrationHeight}}
  • +
+ + Create Transaction + + + + + + + + + Sign and send + + +
+ + Please register to be able to mine blocks and send transactions + + + + + + Register + + + + +

You're not logged in

+ Auth +
+
+ +
+
+
+ + +

UTX ({{ utxSize }})

+ + + {{ tx.script }} + Sender: {{ tx.sender }} + + + {{ tx.gas }} gas + + + +
+ + + +

Blocks (last {{blocksLimit}})

+ + + + Block {{block.height}} is mined by {{block.miner}} + with {{block.txs.length}} txs and {{block.gas}} gas + + hash: {{ block.hash }} + + + {{ block.refHeight }} + {{ block.timestamp }} + + + +
+
+
+ Github +
+
+ + + + + \ No newline at end of file diff --git a/ride4dapps/blockchain/dapp.ride b/ride4dapps/blockchain/dapp.ride new file mode 100644 index 0000000..e7321aa --- /dev/null +++ b/ride4dapps/blockchain/dapp.ride @@ -0,0 +1,413 @@ +{-# STDLIB_VERSION 3 #-} +{-# CONTENT_TYPE DAPP #-} +{-# SCRIPT_TYPE ACCOUNT #-} + +# SPECIFY BEFORE SETTING THE CONTRACT! +let registrationCost = 5 +let initBalance = 5 +let blockMinerReward = 10 +let utxLimit = 100 + +let keyAccountPrefix = "@" +let keyAddressPrefix = "$" +let keyAssetId = "assetId" +let keyHeight = "height" +let keyLast = "last" +let keyUtx = "utx" +let keyUtxSize = "utx-size" + +func h() = getIntegerValue(this, keyHeight) +func assetId() = this.getStringValue(keyAssetId).fromBase58String() + +# hash, timestamp, refHeight, minerAccount, nonce, prevHash, difficulty, gas +func blockInfo(h: Int) = this.getStringValue(if h == -1 || h == h() then keyLast else h.toString()) +func blockHash(h: Int) = { + let blInfo = blockInfo(h) + let right = blInfo.indexOf(",").extract() + blInfo.take(right) +} +func blockTimestamp(h: Int) = { + let blInfo = blockInfo(h) + let left = blInfo.indexOf(",").extract() + 1 + let right = blInfo.indexOf(",", left).extract() + blInfo.drop(left).take(right - left) +} +func blockReferenceHeight(h: Int) = { + let blInfo = blockInfo(h) + let left = blInfo.indexOf(",", blInfo.indexOf(",").extract() + 1).extract() + 1 + let right = blInfo.indexOf(",", left).extract() + blInfo.drop(left).take(right - left).parseIntValue() +} +func blockMinerAccount(h: Int) = { + let blInfo = blockInfo(h) + let left = blInfo.indexOf(",", blInfo.indexOf(",", blInfo.indexOf(",").extract() + 1).extract() + 1).extract() + 1 + let right = blInfo.indexOf(",", left).extract() + blInfo.drop(left).take(right - left) +} +func blockNonce(h: Int) = { + let blInfo = blockInfo(h) + let left = blInfo.indexOf(",", blInfo.indexOf(",", blInfo.indexOf(",", blInfo.indexOf(",").extract() + 1).extract() + 1).extract() + 1).extract() + 1 + let right = blInfo.indexOf(",", left).extract() + blInfo.drop(left).take(right - left) +} +func blockPrevHash(h: Int) = { + let blInfo = blockInfo(h) + let left = blInfo.indexOf(",", + blInfo.indexOf(",", + blInfo.indexOf(",", + blInfo.indexOf(",", + blInfo.indexOf(",").extract() + 1 + ).extract() + 1 + ).extract() + 1 + ).extract() + 1 + ).extract() + 1 + let right = blInfo.indexOf(",", left).extract() + blInfo.drop(left).take(right - left) +} +func blockDifficulty(h: Int) = { + let blInfo = blockInfo(h) + let left = blInfo.indexOf(",", + blInfo.indexOf(",", + blInfo.indexOf(",", + blInfo.indexOf(",", + blInfo.indexOf(",", + blInfo.indexOf(",").extract() + 1 + ).extract() + 1 + ).extract() + 1 + ).extract() + 1 + ).extract() + 1 + ).extract() + 1 + let right = blInfo.indexOf(",", left).extract() + blInfo.drop(left).take(right - left).parseIntValue() +} +func blockGasReward(h: Int) = { + let blHeaders = blockInfo(h).split(";")[0].split(",") + blHeaders[7].parseIntValue() +} +func blockTxs(h: Int) = { + let blInfo = blockInfo(h) + let semicolon = blInfo.indexOf(";") + if isDefined(semicolon) then + blInfo.drop(semicolon.extract() + 1) + else "" +} + +func address(addr: Address) = addr.bytes.toBase58String() +func isRegistered(addr: Address) = isDefined(this.getString(keyAddressPrefix + address(addr))) +func isTaken(name: String) = isDefined(this.getString(keyAccountPrefix + name)) +func accountOf(addr: Address) = this.getStringValue(keyAddressPrefix + address(addr)) +func accountInfo(name: String) = this.getStringValue(keyAccountPrefix + name) + +func addressOf(account: String) = { + let accInfo = accountInfo(account) + let right = accInfo.indexOf(",").extract() + take(accInfo, right) +} +func regHeightOf(account: String) = { + let accInfo = accountInfo(account) + let left = accInfo.indexOf(",").extract() + 1 + let right = accInfo.indexOf(",", left).extract() + accInfo.drop(left).take(right - left).parseIntValue() +} +func totalBalanceOf(account: String) = { + let accInfo = accountInfo(account) + let left = accInfo.indexOf(",", accInfo.indexOf(",").extract() + 1).extract() + 1 + let right = accInfo.indexOf(",", left).extract() + accInfo.drop(left).take(right - left).parseIntValue() +} +func availableBalanceOf(account: String) = { + let accInfo = accountInfo(account) + let left = accInfo.indexOf(",", accInfo.indexOf(",", accInfo.indexOf(",").extract() + 1).extract() + 1).extract() + 1 + let right = accInfo.size() + accInfo.drop(left).take(right - left).parseIntValue() +} + +func estimate(script: String) = { + let words = script.split(" ") + + if words[0] == "SEND" then + let gasRequired = 1 + let amount = words[2].parseIntValue() + [gasRequired, amount] + else if words[0] == "SWAP" then + let gasRequired = 2 + let direction = words[1].split(" ")[1] + let amount = if direction == "IN" then 0 else words[2].parseIntValue() + [gasRequired, amount] + else [1000000, 0] # unreachable state because validated +} + +func evaluate(script: String, inv: Invocation) = { + let words = script.split(" ") + func send(recipient: String, amount: Int) = { + ScriptResult( + WriteSet([ + DataEntry(keyAccountPrefix + recipient, addressOf(recipient) + "," + regHeightOf(recipient).toString() + "," + + (totalBalanceOf(recipient) + amount).toString() + "," + (availableBalanceOf(recipient) + amount).toString()) + ]), + TransferSet([]) + ) + } + func swap(acc: String, direction: String, amount: Int) = { + if direction == "IN" then + ScriptResult( + WriteSet([ + DataEntry(keyAccountPrefix + acc, addressOf(acc) + "," + regHeightOf(acc).toString() + "," + + (totalBalanceOf(acc) + amount).toString() + "," + (availableBalanceOf(acc) + amount).toString()) + ]), + TransferSet([]) + ) + else + ScriptResult( + WriteSet([ + DataEntry(keyAccountPrefix + acc, addressOf(acc) + "," + regHeightOf(acc).toString() + "," + + (totalBalanceOf(acc) - amount).toString() + "," + availableBalanceOf(acc).toString()) + ]), + TransferSet([ + ScriptTransfer(inv.caller, amount, assetId()) + ]) + ) + } + + if words[0] == "SEND" then + send(words[1].split(" ")[1], words[2].parseIntValue()) + else if words[0] == "SWAP" then + swap(accountOf(inv.caller), words[1].split(" ")[1], words[2].parseIntValue()) + else throw("can't evaluate script") # unreachable state because validated +} + +func validate(acc: String, gas: Int, script: String, inv: Invocation, checkBalance: Boolean) = { + let words = script.split(" ") + + if words[0] == "SEND" then + if words.size() != 3 then "Missed args: SEND recipient amount" + else + let gasRequired = estimate(script)[0] + let recipient = words[1].split(" ")[1] + let amount = words[2].parseIntValue() + + if !isTaken(recipient) then "recipient '" + recipient + "' doesn't exist" + else if recipient == acc then "sender can't do SEND to itself" + else if amount < 1 then "amount " + amount.toString() + " must be a positive number" + else if !(gas > 0) then "Gas amount must be positive!" + else if gas < gasRequired then "Not enough gas: actual " + gas.toString() + " but " + gasRequired.toString() + " estimated" + else if checkBalance && availableBalanceOf(acc) < gas + amount then "Not enough available balance for payment and gas" + else "" + else if words[0] == "SWAP" then + if words.size() != 3 then "Missed args: SWAP [IN/OUT] amount" + else + let gasRequired = estimate(script)[0] + let direction = words[1].split(" ")[1] + let amount = words[2].parseIntValue() + + if direction == "IN" then + if !isDefined(inv.payment) then "Payment is required for the transaction!" + else + let pmt = extract(inv.payment) + if pmt.amount != amount || pmt.assetId != assetId() then "Required payment is exactly " + amount.toString() + " of asset " + assetId().toBase58String() + else if !(gas > 0) then "Gas amount must be positive!" + else if gas < gasRequired then "Not enough gas: actual " + gas.toString() + " but " + gasRequired.toString() + " estimated" + else if checkBalance && availableBalanceOf(acc) < gas then "Not enough available balance for gas" + else "" + else if direction == "OUT" then + if !(gas > 0) then "Gas amount must be positive!" + else if gas < gasRequired then "Not enough gas: actual " + gas.toString() + " but " + gasRequired.toString() + " estimated" + else if checkBalance && availableBalanceOf(acc) < gas + amount then "Not enough available balance for SWAP and gas" + else "" + else "Argument \"direction\" must be \"IN\" or \"OUT\"" + else + "unknown command " + words[0] +} + +@Callable(i) +func genesis(assetId: String) = { + let asset = assetInfo(assetId.fromBase58String()) + if i.caller != this then + throw("Rudechain can be created only by the dApp") + else if isDefined(this.getString(keyLast)) + || isDefined(this.getInteger(keyHeight)) + || isDefined(this.getInteger(keyUtx)) + || isDefined(this.getInteger(keyUtxSize)) + then + throw("Rudechain is already created") + else if !isDefined(asset) then throw("Asset '" + assetId + "' doesn't exist!") + else if this.assetBalance(assetId.fromBase58String()) != asset.extract().totalAmount || asset.extract().decimals != 0 || asset.extract().issuer != this.bytes then + throw("Incorrect asset. It must be issued by the dApp with 0 decimals and the dApp must have the entire quantity") + else + let minerName = "dapp" + let gHeight = 0 + # hash, timestamp, refHeight, minerAccount, nonce, prevHash, difficulty, gas + let hash = (lastBlock.timestamp.toString() + lastBlock.height.toString() + minerName + "0").toBytes().blake2b256().toBase58String() + let genesisBlock = hash + "," + lastBlock.timestamp.toString() + "," + lastBlock.height.toString() + "," + minerName + ",0,0,1,0" + WriteSet([ + DataEntry(keyAssetId, assetId), + DataEntry(keyLast, genesisBlock), + DataEntry(keyHeight, gHeight), + DataEntry(keyUtx, ""), + DataEntry(keyUtxSize, 0), + DataEntry(keyAddressPrefix + address(this), "dapp"), + DataEntry(keyAccountPrefix + "dapp", address(this) + "," + gHeight.toString() + ",0,0") + ]) +} + +@Callable(i) +func register(name: String) = { + let validChars = "abcdefghijklmnopqrstuvwxyz0123456789" + + if (!isDefined(i.payment) || isDefined(i.payment.extract().assetId) || i.payment.extract().amount != registrationCost * 100000000) then + throw("Registration costs " + registrationCost.toString() + " Waves!") + else if !(name.size() > 1 && name.size() <= 8 + && isDefined(validChars.indexOf(name.take(1))) + && isDefined(validChars.indexOf(name.drop(1).take(1))) + && (if (name.size() > 2) then isDefined(validChars.indexOf(name.drop(2).take(1))) else true) + && (if (name.size() > 3) then isDefined(validChars.indexOf(name.drop(3).take(1))) else true) + && (if (name.size() > 4) then isDefined(validChars.indexOf(name.drop(4).take(1))) else true) + && (if (name.size() > 5) then isDefined(validChars.indexOf(name.drop(5).take(1))) else true) + && (if (name.size() > 6) then isDefined(validChars.indexOf(name.drop(6).take(1))) else true) + && (if (name.size() > 7) then isDefined(validChars.indexOf(name.drop(7).take(1))) else true)) + then + throw("Account name must have [2..8] length and contain only [a-z0-9] chars") + else if isRegistered(i.caller) then + throw("Address of the caller is already registered as '" + accountOf(i.caller) + "'") + else if isTaken(name) then + throw("Account name '" + name + "' is already taken") + else + WriteSet([ + DataEntry(keyAddressPrefix + address(i.caller), name), + DataEntry(keyAccountPrefix + name, address(i.caller) + "," + h().toString() + "," + initBalance.toString() + "," + initBalance.toString()) + ]) +} + +@Callable(i) +func mine(nonce: Int) = { + let delta = lastBlock.height - blockReferenceHeight(-1) + let difficulty = blockDifficulty(-1) + let newDifficulty = if delta == 1 then difficulty + 3 else if delta == 2 || delta == 3 then difficulty + 1 else if difficulty - (delta / 2) > 0 then difficulty - (delta / 2) else 1 + + let hash = blake2b256(( + lastBlock.timestamp.toString() + + lastBlock.height.toString() + + accountOf(i.caller) + + nonce.toString() + + blockPrevHash(-1) + ).toBytes()) + + let byte0LeadingZeros = Alias("zerobytes").getIntegerValue(hash.take(1).toBase58String()) + let byte1LeadingZeros = Alias("zerobytes").getIntegerValue(hash.take(2).takeRight(1).toBase58String()) + let byte2LeadingZeros = Alias("zerobytes").getIntegerValue(hash.take(3).takeRight(1).toBase58String()) + let byte3LeadingZeros = Alias("zerobytes").getIntegerValue(hash.take(4).takeRight(1).toBase58String()) + let byte4LeadingZeros = Alias("zerobytes").getIntegerValue(hash.take(5).takeRight(1).toBase58String()) + let byte5LeadingZeros = Alias("zerobytes").getIntegerValue(hash.take(6).takeRight(1).toBase58String()) + let byte6LeadingZeros = Alias("zerobytes").getIntegerValue(hash.take(7).takeRight(1).toBase58String()) + let byte7LeadingZeros = Alias("zerobytes").getIntegerValue(hash.take(8).takeRight(1).toBase58String()) + + let firstZeroBits = if byte0LeadingZeros != 8 then byte0LeadingZeros else ( 8 + + if byte1LeadingZeros != 8 then byte1LeadingZeros else ( 8 + + if byte2LeadingZeros != 8 then byte2LeadingZeros else ( 8 + + if byte3LeadingZeros != 8 then byte3LeadingZeros else ( 8 + + if byte4LeadingZeros != 8 then byte4LeadingZeros else ( 8 + + if byte5LeadingZeros != 8 then byte5LeadingZeros else ( 8 + + if byte6LeadingZeros != 8 then byte6LeadingZeros else ( 8 + + byte7LeadingZeros))))))) + + if i.caller == this then throw("The dApp can't mine!") + else if !isRegistered(i.caller) then throw("Miner must be registered!") + else if delta == 0 then throw("Can't mine on same reference height as last block: " + lastBlock.height.toString()) + else if firstZeroBits < newDifficulty then throw("Hash has difficulty " + firstZeroBits.toString() + ", but at least " + newDifficulty.toString() + " is required") + else + let prevMinerAccount = blockMinerAccount(-1) + let newMinerAccount = accountOf(i.caller) + let newHeight = h() + 1 + let newBlock = hash.toBase58String() + + "," + lastBlock.timestamp.toString() + + "," + lastBlock.height.toString() + + "," + newMinerAccount + + "," + nonce.toString() + + "," + blockPrevHash(-1) + + "," + newDifficulty.toString() + + ",0" + + WriteSet([ + DataEntry(keyHeight, newHeight), + DataEntry(keyAccountPrefix + prevMinerAccount, addressOf(prevMinerAccount) + "," + regHeightOf(prevMinerAccount).toString() + "," + + (totalBalanceOf(prevMinerAccount) + blockGasReward(-1)).toString() + "," + (availableBalanceOf(prevMinerAccount) + blockGasReward(-1)).toString()), + DataEntry(h().toString(), blockInfo(-1)), + DataEntry(keyAccountPrefix + newMinerAccount, addressOf(newMinerAccount) + "," + regHeightOf(newMinerAccount).toString() + "," + + (totalBalanceOf(newMinerAccount) + blockMinerReward).toString() + "," + (availableBalanceOf(newMinerAccount) + blockMinerReward).toString()), + DataEntry(keyLast, newBlock) + ]) +} + +@Callable(i) +func utxProcessing() = { + let utx = this.getStringValue(keyUtx) + let utxSize = this.getIntegerValue(keyUtxSize) + + if i.caller.bytes != addressOf(blockMinerAccount(-1)).fromBase58String() then throw("Only the current miner can processing UTX!") + else if utxSize == 0 then WriteSet([]) + else + let tx = utx.split(";")[0] + let txFields = tx.split(",") + + let txSenderAccount = txFields[0] + let txGas = txFields[1].split(",")[1].parseIntValue() + let txScript = txFields[2] + + let txSender = addressOf(txSenderAccount) + let validation = validate(txSender, txGas, txScript, i, false) + let costs = estimate(txScript) + + if validation.size() > 0 then + WriteSet([DataEntry(keyUtxSize, utxSize - 1), DataEntry(keyUtx, utx.drop(tx.size() + 1))]) + else + let increasedReward = blockGasReward(-1) + costs[0] + let txs = if isDefined(blockInfo(-1).indexOf(";")) then ";" + blockTxs(-1) else "" + let result = evaluate(txScript, i) + + ScriptResult( + WriteSet( + DataEntry(keyLast, blockHash(-1) + "," + blockTimestamp(-1) + "," + blockReferenceHeight(-1).toString() + "," + blockMinerAccount(-1) + "," + + blockNonce(-1) + "," + blockPrevHash(-1) + "," + blockDifficulty(-1).toString() + "," + increasedReward.toString() + txs + ";" + tx) + ::DataEntry(keyUtxSize, utxSize - 1) + ::DataEntry(keyUtx, utx.drop(tx.size() + 1)) + ::DataEntry( keyAccountPrefix + txSenderAccount, txSender + "," + regHeightOf(txSenderAccount).toString() + "," + + (totalBalanceOf(txSenderAccount) - costs[0] - costs[1]).toString() + "," + + (availableBalanceOf(txSenderAccount) - costs[0] + txGas).toString() ) + ::result.data.data + ), + TransferSet(result.transfers.transfers) + ) +} + +@Callable(i) +func transaction(gas: Int, script: String) = { + if i.caller == this then throw("The Rudechain dApp can't send transactions!") + else if !isRegistered(i.caller) then throw("Only registered accounts can send transactions!") + else if this.getIntegerValue(keyUtxSize) == utxLimit then throw("UTX size limit reached! Please try later") + else + let sender = accountOf(i.caller) + let txBody = sender + "," + gas.toString() + "," + script + let validation = validate(sender, gas, script, i, true) + let costs = estimate(script) + let reserved = costs[0] + costs[1] + + if validation.size() > 0 then throw(validation) + else + let utxPool = this.getStringValue(keyUtx) + let utxSize = this.getIntegerValue(keyUtxSize) + let newUtxPool = if (utxSize > 0) then utxPool + ";" + txBody else txBody + WriteSet([ + DataEntry(keyUtx, newUtxPool), + DataEntry(keyUtxSize, utxSize + 1), + DataEntry(keyAccountPrefix + sender, addressOf(sender) + "," + regHeightOf(sender).toString() + "," + totalBalanceOf(sender).toString() + "," + + (availableBalanceOf(sender) - reserved).toString()) + ]) +} + +@Verifier(tx) +func verify() = { + match tx { + case d:DataTransaction => false # rudechain can be changed only via dApp actions + case _ => tx.bodyBytes.sigVerify(tx.proofs[0], tx.senderPublicKey) + } +} diff --git a/ride4dapps/blockchain/dapp_test.js b/ride4dapps/blockchain/dapp_test.js new file mode 100644 index 0000000..6f42d59 --- /dev/null +++ b/ride4dapps/blockchain/dapp_test.js @@ -0,0 +1,31 @@ +const pubKey = '' // TODO SET! + +describe('Blockchain tests', () => { + + it('genesis', async function(){ + const b = await balance(); + const h = await currentHeight() + + const invS = invokeScript({ dApp: address(env.accounts[1]), call: {function: "genesis", args: []} }) + await broadcast(invS) + }) + + it('register', async function(){ + const name = "bob1" + const invS = invokeScript({ dApp: address(env.accounts[1]), call: {function: "register", args: [{type: "string", value: name}]}, payment: [{amount: 500000000, assetId: null}] }) + await broadcast(invS) + }) + + it('send tx', async function(){ + const script64 = Base64.encode("SEND alice 2") + const gaz = 1 + const sig = sign(pubKey + gaz + script64) + const invS = invokeScript({ dApp: address(env.accounts[1]), call: {function: "transaction", args: [ + {type: "string", value: sig}, + {type: "integer", value: gaz}, + {type: "string", value: script64} + ]} }) + await broadcast(invS) + }) + +}) \ No newline at end of file diff --git a/ride4dapps/blockchain/miner/.gitignore b/ride4dapps/blockchain/miner/.gitignore new file mode 100644 index 0000000..5eba97c --- /dev/null +++ b/ride4dapps/blockchain/miner/.gitignore @@ -0,0 +1,15 @@ +.idea/ +*.iml +*.ipr +*.iws + +.settings/ +.classpath +.project + +out/ +target/ +*.jar + +seed.conf + diff --git a/ride4dapps/blockchain/miner/pom.xml b/ride4dapps/blockchain/miner/pom.xml new file mode 100644 index 0000000..6c1fbed --- /dev/null +++ b/ride4dapps/blockchain/miner/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + com.example + miner + 0.1 + + + ${project.basedir}/src + + + ${project.basedir}/resources + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + 8 + 8 + + + + maven-assembly-plugin + 3.1.1 + + + jar-with-dependencies + + + + Main + + + + + + pack-all + package + + single + + + + + + + + + + com.wavesplatform + wavesj + 0.14.1 + + + + diff --git a/ride4dapps/blockchain/miner/src/Main.java b/ride4dapps/blockchain/miner/src/Main.java new file mode 100644 index 0000000..107d0b5 --- /dev/null +++ b/ride4dapps/blockchain/miner/src/Main.java @@ -0,0 +1,24 @@ +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Paths; + +import static java.util.Arrays.asList; + +public class Main { + + public static void main(String[] args) throws IOException, URISyntaxException, InterruptedException { + RudechainMiner rc = new RudechainMiner( + Files.readAllLines(Paths.get("seed.conf")).get(0) + ); + + if (args.length == 0 || !asList("mine", "utx").contains(args[0])) + System.err.println("Provide command 'mine' or 'utx'!"); + else if ("mine".equals(args[0])) { + rc.tryToMineOnce(); + } else if ("utx".equals(args[0])) { + rc.utxProcessing(); + } + } + +} diff --git a/ride4dapps/blockchain/miner/src/RudechainMiner.java b/ride4dapps/blockchain/miner/src/RudechainMiner.java new file mode 100644 index 0000000..9453d08 --- /dev/null +++ b/ride4dapps/blockchain/miner/src/RudechainMiner.java @@ -0,0 +1,159 @@ +import com.wavesplatform.wavesj.*; +import com.wavesplatform.wavesj.json.WavesJsonMapper; +import com.wavesplatform.wavesj.transactions.InvokeScriptTransaction; +import com.wavesplatform.wavesj.transactions.InvokeScriptTransaction.FunctionalArg; +import com.wavesplatform.wavesj.transactions.InvokeScriptTransaction.FunctionCall; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; + +import static com.wavesplatform.wavesj.Hash.*; +import static java.lang.Integer.parseInt; +import static java.lang.System.currentTimeMillis; +import static java.time.format.DateTimeFormatter.ofPattern; +import static java.util.Arrays.asList; +import static java.util.Map.Entry.comparingByKey; +import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.stream.Collectors.*; +import static java.util.Map.Entry.comparingByValue; + +public class RudechainMiner { + + Node node; + PrivateKeyAccount miner; + String minerAccount; + String rudechain; + + public RudechainMiner(String seedText) throws URISyntaxException, IOException { + node = new Node("https://testnode4.wavesnodes.com", 'T'); //TODO host to conf file + miner = PrivateKeyAccount.fromSeed(seedText, 0, (byte) 'T'); //TODO chainId to conf file + rudechain = "3MwgRB2FJzbquEZEnySc2jwhpaZnoWvCMJX"; //TODO to conf file + System.out.println(miner.getAddress()); + minerAccount = node.getDataByKey( + rudechain, "$" + miner.getAddress()).getValue().toString(); + } + + void tryToMineOnce() throws IOException, InterruptedException { + log("Preparing..."); + + long nonce = 0, to = Long.MIN_VALUE; + + int difficulty = parseInt(node.getDataByKey(rudechain, "last").getValue().toString().split(",")[6]); + BlockHeader refLast = node.getLastBlockHeader(); + String[] blockHeaders; + while (true) { + int wavesHeight = node.getHeight(); + blockHeaders = node.getDataByKey(rudechain, "last") + .getValue().toString().split(";")[0].split(","); + int refHeight = parseInt(blockHeaders[2]); + int delta = wavesHeight - refHeight; + + if (refHeight == wavesHeight) { + Thread.sleep(1000); + continue; + } else if (asList(2, 3).contains(delta) || difficulty == 1) { + difficulty += 1; + break; + } else if (delta == 1) { + difficulty += 3; + break; + } else if (difficulty - (delta / 2) > 0) { + difficulty -= delta / 2; + break; + } else { + difficulty = 1; + } + } + //TODO if I'm a new miner - spy and process utx until height arise. Else mine again + //TODO When height is up - mine again + log("Start mining with difficulty " + difficulty); + String hash; + do { + nonce--; + + //timestamp, refHeight, minerAccountName, nonce, prevHash (of current last block) + String source = refLast.getTimestamp() + refLast.getHeight() + minerAccount + nonce + blockHeaders[0]; + hash = Base58.encode( + Hash.hash(source.getBytes(), 0, source.getBytes().length, BLAKE2B256) + ); + byte[] hashBytes = (hash + source).getBytes(); + + short zeros = 0; + for (byte b : hashBytes) { + if (b == 0) { + zeros += 8; + } else { + zeros += 7 - (int) Math.floor(mathLog2(b)); //TODO пропадают 8, 16, 24, 32 + break; + } + } + if (zeros < difficulty) { + continue; + } + + log("generated '" + hash + "' with first " + zeros + " zero bytes from source '" + source + "'"); + break; + } while (nonce > to); + + String id = sendKeyBlock(nonce); + log(id); + + boolean isTxInBlockchain = waitForTx(id); + + if (!isTxInBlockchain) { + throw new IOException("can't mine key block: tx '" + id + "' is not in blockchain"); + } else log("you're the miner now!"); + + } + + String sendKeyBlock(long nonce) throws IOException { + InvokeScriptTransaction tx = new InvokeScriptTransaction((byte) 'T', miner, rudechain, + new FunctionCall("mine").addArg(nonce), + new ArrayList<>(), 500000, null, System.currentTimeMillis(), new ArrayList<>() + ).sign(miner); + return node.send(tx); + } + + void utxProcessing() throws IOException, InterruptedException { + String utx = node.getDataByKey(rudechain, "utx").getValue().toString(); + if (utx.length() < 2) { + log("UTX is empty now"); + } else { + InvokeScriptTransaction tx = new InvokeScriptTransaction((byte) 'T', miner, rudechain, + "utxProcessing", 500000, null, System.currentTimeMillis() + ).sign(miner); + String id = node.send(tx); + boolean isTxInBlockchain = waitForTx(id); + log(String.valueOf(isTxInBlockchain)); + } + } + + private boolean waitForTx(String id) throws InterruptedException { + boolean isTxInBlockchain = false; + for (int attempt = 0; attempt < 120; attempt++) { + try { + node.getTransaction(id); + isTxInBlockchain = true; + break; + } catch (IOException e) { + Thread.sleep(1000); + } + } + return isTxInBlockchain; + } + + double mathLog2(int num) { + return Math.log10(num) / Math.log10(2); + } + + private void log(String message) { + String time = LocalDateTime.now().format(ofPattern("yyyy-MM-dd'T'HH:mm:ss")); + System.out.print(time + "> " + message + "\n"); + } + +} \ No newline at end of file