Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Check tokens balance before TX signing #181

Merged
merged 9 commits into from
Jul 13, 2022
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package org.ergoplatform.appkit

import org.ergoplatform.appkit.JavaHelpers._
import org.ergoplatform.{ErgoScriptPredef, ErgoBox, UnsignedErgoLikeTransaction}
import org.ergoplatform.appkit.impl.{BlockchainContextImpl, InputBoxImpl, UnsignedTransactionBuilderImpl, UnsignedTransactionImpl}
import org.ergoplatform.settings.ErgoAlgos
import sigmastate.helpers.NegativeTesting
import org.scalatest.{Matchers, PropSpec}
import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks
import sigmastate.TestsBase
import sigmastate.eval.Colls
import sigmastate.helpers.TestingHelpers.createBox

import java.util
import java.util.Collections
import util.{List => JList}

class AppkitProvingInterpreterSpec extends PropSpec
with Matchers
with ScalaCheckDrivenPropertyChecks
with AppkitTestingCommon
with HttpClientTesting
with NegativeTesting
with TestsBase {

val oneErg = 1000L * 1000 * 1000

def createBoxOps(ctx: BlockchainContext, prover: ErgoProver, inputs: IndexedSeq[ErgoBox]) = {
val ops = new BoxOperations(ctx, Collections.singletonList(prover.getAddress), prover) {
override def loadTop(): util.List[InputBox] = {
val is = inputs.map(b => new InputBoxImpl(b): InputBox)
.convertTo[JList[InputBox]]
is
}
}
ops.withAmountToSpend(oneErg * 2)
}

/** This method creates an UnsignedTransaction instance directly bypassing builders and
* their consistency logic. This allows to create invalid transactions for the tests
* below.
*/
def createUnsignedTransaction(
ctx: BlockchainContext, prover: ErgoProver,
inputs: IndexedSeq[ErgoBox],
outputs: IndexedSeq[ErgoBox],
tokensToBurn: IndexedSeq[ErgoToken]
) = {
val txB = ctx.newTxBuilder().asInstanceOf[UnsignedTransactionBuilderImpl]
val stateContext = txB.createErgoLikeStateContext
val changeAddress = prover.getAddress.getErgoAddress

val boxesToSpend = inputs
.map(b => new InputBoxImpl(b))
.map(b => ExtendedInputBox(b.getErgoBox, b.getExtension))
.convertTo[util.List[ExtendedInputBox]]
val boxesToSpendSeq = JavaHelpers.toIndexedSeq(boxesToSpend)
val tx = new UnsignedErgoLikeTransaction(
inputs = boxesToSpendSeq.map(_.toUnsignedInput),
dataInputs = IndexedSeq(),
outputCandidates = outputs
)
val unsigned = new UnsignedTransactionImpl(
tx, boxesToSpend, new util.ArrayList[ErgoBox](), changeAddress, stateContext,
ctx.asInstanceOf[BlockchainContextImpl], tokensToBurn.convertTo[JList[ErgoToken]])
unsigned
}

property("rejecting transaction with unbalanced tokens") {
val ergoClient = createMockedErgoClient(MockData(Nil, Nil))
ergoClient.execute { ctx: BlockchainContext =>
val prover = ctx.newProverBuilder()
.withMnemonic(mnemonic, SecretString.empty())
.build()
val tree1 = ErgoScriptPredef.TrueProp(ergoTreeHeaderInTests)
val tree2 = ErgoScriptPredef.FalseProp(ergoTreeHeaderInTests)
val token1 = (ErgoAlgos.hash("id1"), 10L)
val token2 = (ErgoAlgos.hash("id2"), 20L)
val ergoToken1 = Iso.isoErgoTokenToPair.from(token1)
val ergoToken2 = Iso.isoErgoTokenToPair.from(token2)

val input1 = createBox(oneErg + Parameters.MinFee, tree1, additionalTokens = Seq(token1))
val input2 = createBox(oneErg, tree2, additionalTokens = Seq(token2))

// successful reduction with balanced tokens
{
val ops = createBoxOps(ctx, prover, IndexedSeq(input1, input2))
val tokens = new util.ArrayList[ErgoToken]()
tokens.add(ergoToken1)
tokens.add(ergoToken2)
val unsigned = ops
.withTokensToSpend(tokens)
.putToContractTxUnsigned(address.toErgoContract)
val reduced = prover.reduce(unsigned, 0)
reduced.getInputBoxesIds.size() shouldBe 2
reduced.getOutputs.size() shouldBe 2 // output + feeOut
}

// Transaction tries to burn tokens when no burning was requested
{
val output1 = createBox(oneErg * 2 + Parameters.MinFee, tree1, additionalTokens = Seq(token1))
val unsigned = createUnsignedTransaction(ctx, prover, IndexedSeq(input1, input2), IndexedSeq(output1), IndexedSeq.empty)
assertExceptionThrown(
prover.reduce(unsigned, 0),
{
case e: TokenBalanceException =>
val cond1 = exceptionLike[TokenBalanceException]("Transaction tries to burn tokens when no burning was requested")
cond1(e) && e.tokensDiff.exists(t => t == (Colls.fromArray(token2._1), -token2._2))
case _ => false
}
)
}

// Transaction tries to burn tokens when no burning was requested
{
val output1 = createBox(oneErg * 2 + Parameters.MinFee, tree1, additionalTokens = Seq(token1, token2.copy(_2 = 10)))
val unsigned = createUnsignedTransaction(ctx, prover, IndexedSeq(input1, input2), IndexedSeq(output1), IndexedSeq.empty)
assertExceptionThrown(
prover.reduce(unsigned, 0),
{
case e: TokenBalanceException =>
val cond1 = exceptionLike[TokenBalanceException]("Transaction tries to burn tokens when no burning was requested")
cond1(e) && e.tokensDiff.exists(t => t == (Colls.fromArray(token2._1), -10))
case _ => false
}
)
}

// Transaction tries to burn tokens when no burning was requested
// Inputs: (note, same token in two boxes)
// Box1: Token1, Amount1
// Box2: Token1. Amount2
//
// Outputs:
// Box1: Token1, Amount1
{
// another input with the same token as input1
val input2_with_token1 = createBox(oneErg, tree2, additionalTokens = Seq(token1.copy(_2 = 20)))
val output1 = createBox(oneErg * 2 + Parameters.MinFee, tree1, additionalTokens = Seq(token1))

val unsigned = createUnsignedTransaction(ctx, prover,
IndexedSeq(input1, input2_with_token1),
IndexedSeq(output1), tokensToBurn = IndexedSeq.empty)

assertExceptionThrown(
prover.reduce(unsigned, 0),
{
case e: TokenBalanceException =>
val cond1 = exceptionLike[TokenBalanceException]("Transaction tries to burn tokens when no burning was requested")
cond1(e) && e.tokensDiff.exists(t => t == (Colls.fromArray(token1._1), -20))
case _ => false
}
)
}

// invalid burning even when burning was requested
{
val output1 = createBox(oneErg * 2 + Parameters.MinFee, tree1, additionalTokens = Seq(token1, token2.copy(_2 = 10)))
val unsigned = createUnsignedTransaction(ctx, prover,
IndexedSeq(input1, input2), IndexedSeq(output1),
tokensToBurn = IndexedSeq(ergoToken1))
assertExceptionThrown(
prover.reduce(unsigned, 0),
{
case e: TokenBalanceException =>
val cond1 = exceptionLike[TokenBalanceException](
"Transaction tries to burn tokens, but not how it was requested")
val ok = cond1(e)
val token2_BurningWasNotRequested = e.tokensDiff.exists(t => t == (Colls.fromArray(token2._1), 10))
val token1_WasRequestedButNotBurned = e.tokensDiff.exists(t => t == (Colls.fromArray(token1._1), -10))
ok && token2_BurningWasNotRequested && token1_WasRequestedButNotBurned
case _ => false
}
)
}

// attempt to mint more than 1 token
{
val input1 = createBox(oneErg, tree1) // no tokens
val output1 = createBox(oneErg * 2 + Parameters.MinFee, tree1, additionalTokens = Seq(token1, token2))
val unsigned = createUnsignedTransaction(ctx, prover,
IndexedSeq(input1), IndexedSeq(output1), tokensToBurn = IndexedSeq.empty)
assertExceptionThrown(
prover.reduce(unsigned, 0),
{
case e: TokenBalanceException =>
val cond1 = exceptionLike[TokenBalanceException](
"Only one token can be minted in a transaction")
val ok = cond1(e)
val token1_mint_attempted = e.tokensDiff.exists(t => t == (Colls.fromArray(token1._1), token1._2))
val token2_mint_attempted = e.tokensDiff.exists(t => t == (Colls.fromArray(token2._1), token2._2))
ok && token1_mint_attempted && token2_mint_attempted && e.tokensDiff.length == 2
case _ => false
}
)
}

// attempt to mint 1 token but with invalid id
{
val input1 = createBox(oneErg, tree1) // no tokens
val output1 = createBox(oneErg * 2 + Parameters.MinFee, tree1, additionalTokens = Seq(token1))
val unsigned = createUnsignedTransaction(ctx, prover,
IndexedSeq(input1), IndexedSeq(output1), tokensToBurn = IndexedSeq.empty)
assertExceptionThrown(
prover.reduce(unsigned, 0),
{
case e: TokenBalanceException =>
val cond1 = exceptionLike[TokenBalanceException](
"Cannot mint a token with invalid id")
val ok = cond1(e)
val token1_mint_attempted = e.tokensDiff.exists(t => t == (Colls.fromArray(token1._1), token1._2))
ok && token1_mint_attempted && e.tokensDiff.length == 1
case _ => false
}
)
}

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,10 @@ class ChangeOutputSpec extends PropSpec with Matchers

ergoClient.execute { ctx: BlockchainContext =>

val input0 = ctx.newTxBuilder.outBoxBuilder.registers(
ErgoValue.of(gY), ErgoValue.of(gXY)
).value(30000000).contract(ctx.compileContract(
ConstantsBuilder.empty(),
val input0 = ctx.newTxBuilder.outBoxBuilder
.registers(ErgoValue.of(gY), ErgoValue.of(gXY))
.value(30000000)
.contract(ctx.compileContract(ConstantsBuilder.empty(),
"""{
| val gY = SELF.R4[GroupElement].get
| val gXY = SELF.R5[GroupElement].get
Expand All @@ -146,7 +146,7 @@ class ChangeOutputSpec extends PropSpec with Matchers
val txB = ctx.newTxBuilder().preHeader(ph) // for issuing token
val tokenBox = txB.outBoxBuilder
.value(15000000) // value of token box, doesn't really matter
.tokens(new ErgoToken(tokenId, tokenAmount)) // amount of token issuing
.tokens(new ErgoToken(tokenId, tokenAmount)) // mint a token with the given amount
.contract(ctx.compileContract(
// contract of the box containing tokens, just has to be spendable
ConstantsBuilder.empty(), "{sigmaProp(1 < 2)}"
Expand Down
40 changes: 19 additions & 21 deletions appkit/src/test/scala/org/ergoplatform/appkit/TxBuilderSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import sigmastate.interpreter.HintsBag

import java.io.File
import java.math.BigInteger
import java.util
import java.util.Arrays

class TxBuilderSpec extends PropSpec with Matchers
Expand Down Expand Up @@ -43,6 +44,13 @@ class TxBuilderSpec extends PropSpec with Matchers
|}""".stripMargin)
}

def loadStorageE2(): (SecretStorage, util.List[Address]) = {
val storage = SecretStorage.loadFrom("storage/E2.json")
storage.unlock("abc")
val senders = Arrays.asList(storage.getAddressFor(NetworkType.MAINNET))
(storage, senders)
}

property("ContextVar id should be in range") {
for (id <- 0 to Byte.MaxValue) {
ContextVar.of(id.toByte, 10)
Expand Down Expand Up @@ -265,15 +273,12 @@ class TxBuilderSpec extends PropSpec with Matchers
val ergoClient = createMockedErgoClient(data)

val reducedTx: ReducedTransaction = ergoClient.execute { ctx: BlockchainContext =>
val storage = SecretStorage.loadFrom("storage/E2.json")
storage.unlock("abc")

val (_, senders) = loadStorageE2()
val recipient = address
val pkContract = recipient.toErgoContract

val amountToSend = 1000000
val pkContract = recipient.toErgoContract

val senders = Arrays.asList(storage.getAddressFor(NetworkType.MAINNET))
val unsigned = BoxOperations.createForSenders(senders, ctx)
.withAmountToSpend(amountToSend)
.withMessage("Test message")
Expand Down Expand Up @@ -337,18 +342,14 @@ class TxBuilderSpec extends PropSpec with Matchers

a[NotEnoughErgsException] shouldBe thrownBy {
ergoClient.execute { ctx: BlockchainContext =>
val storage = SecretStorage.loadFrom("storage/E2.json")
storage.unlock("abc")

val recipient = address
val (_, senders) = loadStorageE2()
val recipientContract = address.toErgoContract

val amountToSend = 1000000
val pkContract = recipient.toErgoContract

val senders = Arrays.asList(storage.getAddressFor(NetworkType.MAINNET))
val unsigned = BoxOperations.createForSenders(senders, ctx).withAmountToSpend(amountToSend)
val unsigned = BoxOperations.createForSenders(senders, ctx)
.withAmountToSpend(amountToSend)
.withInputBoxesLoader(new ExplorerAndPoolUnspentBoxesLoader())
.putToContractTxUnsigned(pkContract)
.putToContractTxUnsigned(recipientContract)

val prover = ctx.newProverBuilder.build // prover without secrets
val reduced = prover.reduce(unsigned, 0)
Expand All @@ -363,16 +364,12 @@ class TxBuilderSpec extends PropSpec with Matchers
val ergoClient = createMockedErgoClient(data)

ergoClient.execute { ctx: BlockchainContext =>
val storage = SecretStorage.loadFrom("storage/E2.json")
storage.unlock("abc")

val (_, senders) = loadStorageE2()
val recipient = address
val pkContract = recipient.toErgoContract

// send 1 ERG
val amountToSend = 1000L * 1000 * 1000
val pkContract = recipient.toErgoContract

val senders = Arrays.asList(storage.getAddressFor(NetworkType.MAINNET))

// first box: 1 ERG + tx fee + token that will cause a change
val input1 = ctx.newTxBuilder.outBoxBuilder
Expand All @@ -386,7 +383,8 @@ class TxBuilderSpec extends PropSpec with Matchers
.contract(pkContract)
.build().convertToInputWith(mockTxId, 1)

val operations = BoxOperations.createForSenders(senders, ctx).withAmountToSpend(amountToSend)
val operations = BoxOperations.createForSenders(senders, ctx)
.withAmountToSpend(amountToSend)
.withTokensToSpend(Arrays.asList(new ErgoToken(mockTxId, 1)))
.withInputBoxesLoader(new MockedBoxesLoader(Arrays.asList(input1, input2)))
val inputsSelected = operations.loadTop()
Expand Down
Loading