diff --git a/common/shared/src/main/scala/scalan/util/CollectionUtil.scala b/common/shared/src/main/scala/scalan/util/CollectionUtil.scala index b9b79dd65c..634355ad06 100644 --- a/common/shared/src/main/scala/scalan/util/CollectionUtil.scala +++ b/common/shared/src/main/scala/scalan/util/CollectionUtil.scala @@ -128,6 +128,9 @@ object CollectionUtil { } implicit class AnyOps[A](val x: A) extends AnyVal { + /** Performs a specified action on the source value and returns the result. */ + def update(updater: A => A): A = updater(x) + /** Traverses the tree structure in a depth-first manner using the provided function to generate child nodes. * * @param f a function that takes a node of type A and returns a list of its children diff --git a/core-lib/shared/src/main/scala/special/sigma/SigmaDsl.scala b/core-lib/shared/src/main/scala/special/sigma/SigmaDsl.scala index 186aa59bd7..462c5382ab 100644 --- a/core-lib/shared/src/main/scala/special/sigma/SigmaDsl.scala +++ b/core-lib/shared/src/main/scala/special/sigma/SigmaDsl.scala @@ -192,8 +192,7 @@ trait BigInt { def |(that: BigInt): BigInt = or(that) } -/** Base class for points on elliptic curves. - */ +/** Base class for points on elliptic curves. */ trait GroupElement { /** Checks if the provided element is an identity element. */ def isIdentity: Boolean @@ -381,7 +380,6 @@ trait Box { trait AvlTree { /** Returns digest of the state represented by this tree. * Authenticated tree digest = root hash bytes ++ tree height - * @since 2.0 */ def digest: Coll[Byte] @@ -529,10 +527,8 @@ trait AvlTreeVerifier { } -/** Only header fields that can be predicted by a miner. - * @since 2.0 - */ -trait PreHeader { // Testnet2 +/** Only header fields that can be predicted by a miner. */ +trait PreHeader { /** Block version, to be increased on every soft and hardfork. */ def version: Byte @@ -556,9 +552,7 @@ trait PreHeader { // Testnet2 def votes: Coll[Byte] } -/** Represents data of the block header available in Sigma propositions. - * @since 2.0 - */ +/** Represents data of the block header available in Sigma propositions. */ trait Header { /** Bytes representation of ModifierId of this Header */ def id: Coll[Byte] diff --git a/interpreter/shared/src/main/scala/org/ergoplatform/ErgoAddress.scala b/interpreter/shared/src/main/scala/org/ergoplatform/ErgoAddress.scala index 6d6371494b..f68c4b4723 100644 --- a/interpreter/shared/src/main/scala/org/ergoplatform/ErgoAddress.scala +++ b/interpreter/shared/src/main/scala/org/ergoplatform/ErgoAddress.scala @@ -88,6 +88,13 @@ sealed trait ErgoAddress { def networkPrefix: NetworkPrefix } +object ErgoAddress { + def fromSigmaBoolean(sb: SigmaBoolean)(implicit encoder: ErgoAddressEncoder): ErgoAddress = sb match { + case pk: ProveDlog => P2PKAddress(pk) + case _ => Pay2SAddress(ErgoTree.fromSigmaBoolean(sb)) + } +} + /** Implementation of pay-to-public-key [[ErgoAddress]]. */ class P2PKAddress(val pubkey: ProveDlog, val pubkeyBytes: Array[Byte]) diff --git a/interpreter/shared/src/main/scala/sigmastate/NodePosition.scala b/interpreter/shared/src/main/scala/sigmastate/NodePosition.scala new file mode 100644 index 0000000000..0fcc02b57c --- /dev/null +++ b/interpreter/shared/src/main/scala/sigmastate/NodePosition.scala @@ -0,0 +1,41 @@ +package sigmastate + +/** + * Data type which encodes position of a node in a tree. + * + * Position is encoded like following (the example provided is for CTHRESHOLD(2, Seq(pk1, pk2, pk3 && pk4)) : + * + * 0 + * / | \ + * / | \ + * 0-0 0-1 0-2 + * /| + * / | + * / | + * / | + * 0-2-0 0-2-1 + * + * So a hint associated with pk1 has a position "0-0", pk4 - "0-2-1" . + * + * Please note that "0" prefix is for a crypto tree. There are several kinds of trees during evaluation. + * Initial mixed tree (ergoTree) would have another prefix. + * + * @param positions - positions from root (inclusive) in top-down order + */ +case class NodePosition(positions: Seq[Int]) { + def child(childIdx: Int): NodePosition = NodePosition(positions :+ childIdx) + def ++(path: Seq[Int]): NodePosition = NodePosition(positions ++ path) + override def toString: String = positions.mkString("-") +} + +object NodePosition { + /** + * Prefix to encode node positions in a crypto tree. + */ + val CryptoTreePrefix = NodePosition(Seq(0)) + + /** + * Prefix to encode node positions in an ErgoTree instance. + */ + val ErgoTreePrefix = NodePosition(Seq(1)) +} \ No newline at end of file diff --git a/interpreter/shared/src/main/scala/sigmastate/UnprovenTree.scala b/interpreter/shared/src/main/scala/sigmastate/UnprovenTree.scala index a01c937a1f..fc326cd754 100644 --- a/interpreter/shared/src/main/scala/sigmastate/UnprovenTree.scala +++ b/interpreter/shared/src/main/scala/sigmastate/UnprovenTree.scala @@ -34,47 +34,6 @@ trait ProofTreeConjecture extends ProofTree { val children: Seq[ProofTree] } -/** - * Data type which encodes position of a node in a tree. - * - * Position is encoded like following (the example provided is for CTHRESHOLD(2, Seq(pk1, pk2, pk3 && pk4)) : - * - * 0 - * / | \ - * / | \ - * 0-0 0-1 0-2 - * /| - * / | - * / | - * / | - * 0-2-0 0-2-1 - * - * So a hint associated with pk1 has a position "0-0", pk4 - "0-2-1" . - * - * Please note that "0" prefix is for a crypto tree. There are several kinds of trees during evaluation. - * Initial mixed tree (ergoTree) would have another prefix. - * - * @param positions - positions from root (inclusive) in top-down order - */ -case class NodePosition(positions: Seq[Int]) { - - def child(childIdx: Int): NodePosition = NodePosition(positions :+ childIdx) - - override def toString: String = positions.mkString("-") -} - -object NodePosition { - /** - * Prefix to encode node positions in a crypto tree. - */ - val CryptoTreePrefix = NodePosition(Seq(0)) - - /** - * Prefix to encode node positions in an ErgoTree instance. - */ - val ErgoTreePrefix = NodePosition(Seq(1)) -} - /** * A node of a sigma-tree used by the prover. See ProverInterpreter comments and the * ErgoScript white-paper https://ergoplatform.org/docs/ErgoScript.pdf , Appendix A, for details diff --git a/interpreter/shared/src/main/scala/sigmastate/Values.scala b/interpreter/shared/src/main/scala/sigmastate/Values.scala index 1e9114a782..4239e1a2bb 100644 --- a/interpreter/shared/src/main/scala/sigmastate/Values.scala +++ b/interpreter/shared/src/main/scala/sigmastate/Values.scala @@ -735,8 +735,15 @@ object Values { trait SigmaBoolean { /** Unique id of the node class used in serialization of SigmaBoolean. */ val opCode: OpCode + /** Size of the proposition tree (number of nodes). */ def size: Int + + /** Recursively collect all the leaves of this sigma expression into `buf`. + * @param position - position of this node in the tree + * @param buf - buffer to collect leaves into + */ + def collectLeaves(position: NodePosition, buf: mutable.ArrayBuffer[PositionedLeaf]): Unit } object SigmaBoolean { @@ -1006,6 +1013,20 @@ object Values { s"ProveDHTuple(${showECPoint(gv)}, ${showECPoint(hv)}, ${showECPoint(uv)}, ${showECPoint(vv)})" case _ => sb.toString } + + /** Traverses the tree and returns all leaves nodes of sigma proposition tree. */ + def leaves(): Seq[PositionedLeaf] = { + val buf = mutable.ArrayBuffer.empty[PositionedLeaf] + sb.collectLeaves(NodePosition.CryptoTreePrefix, buf) + buf.toSeq + } + + /** Traverses the tree and returns all DISTINCT leaves of sigma proposition tree. */ + def distinctLeaves: Set[SigmaLeaf] = { + val buf = mutable.ArrayBuffer.empty[PositionedLeaf] + sb.collectLeaves(NodePosition.CryptoTreePrefix, buf) + buf.iterator.map(_.leaf).toSet + } } sealed trait BlockItem extends NotReadyValue[SType] { diff --git a/interpreter/shared/src/main/scala/sigmastate/basics/DiffieHellmanTupleProtocol.scala b/interpreter/shared/src/main/scala/sigmastate/basics/DiffieHellmanTupleProtocol.scala index e39508bf4f..509be25a67 100644 --- a/interpreter/shared/src/main/scala/sigmastate/basics/DiffieHellmanTupleProtocol.scala +++ b/interpreter/shared/src/main/scala/sigmastate/basics/DiffieHellmanTupleProtocol.scala @@ -1,17 +1,18 @@ package sigmastate.basics import java.math.BigInteger - import sigmastate.crypto.BigIntegers import sigmastate.Values.Value.PropositionCode import sigmastate._ import sigmastate.basics.VerifierMessage.Challenge import sigmastate.eval.SigmaDsl import CryptoConstants.EcPointType -import sigmastate.serialization.{OpCodes, GroupElementSerializer} +import sigmastate.serialization.{GroupElementSerializer, OpCodes} import sigmastate.serialization.OpCodes.OpCode import special.sigma.SigmaProp +import scala.collection.mutable + trait DiffieHellmanTupleProtocol extends SigmaProtocol[DiffieHellmanTupleProtocol] { override type A = FirstDHTupleProverMessage diff --git a/interpreter/shared/src/main/scala/sigmastate/interpreter/Hint.scala b/interpreter/shared/src/main/scala/sigmastate/interpreter/Hint.scala index ab2530f653..844ee51a12 100644 --- a/interpreter/shared/src/main/scala/sigmastate/interpreter/Hint.scala +++ b/interpreter/shared/src/main/scala/sigmastate/interpreter/Hint.scala @@ -1,7 +1,7 @@ package sigmastate.interpreter import java.math.BigInteger -import sigmastate.{NodePosition, SigmaLeaf, UncheckedTree} +import sigmastate.{NodePosition, PositionedLeaf, SigmaLeaf, UncheckedTree} import sigmastate.Values.SigmaBoolean import sigmastate.basics.FirstProverMessage import sigmastate.basics.VerifierMessage.Challenge @@ -125,8 +125,18 @@ case class HintsBag(hints: Seq[Hint]) { def ++(other: HintsBag): HintsBag = HintsBag(other.hints ++ hints) - override def toString: String = s"HintsBag(${hints.mkString("\n")})" + /** @return a new bag with hints satisfying the predicate `p`. */ + def filter(p: Hint => Boolean): HintsBag = HintsBag(hints.filter(p)) + + /** @return true if there is a proof in this bag for the given leaf of sigma proposition. */ + def hasProofFor(pl: PositionedLeaf): Boolean = { + hints.exists { + case RealSecretProof(image, _, _, position) => pl.leaf == image && pl.position == position + case _ => false + } + } + override def toString: String = s"HintsBag(${hints.mkString("\n")})" } object HintsBag { diff --git a/interpreter/shared/src/main/scala/sigmastate/interpreter/ProverInterpreter.scala b/interpreter/shared/src/main/scala/sigmastate/interpreter/ProverInterpreter.scala index e9dfa6b052..9fd3486367 100644 --- a/interpreter/shared/src/main/scala/sigmastate/interpreter/ProverInterpreter.scala +++ b/interpreter/shared/src/main/scala/sigmastate/interpreter/ProverInterpreter.scala @@ -74,7 +74,7 @@ trait ProverInterpreter extends Interpreter with ProverUtils { // Prover Step 2: If the root of the tree is marked "simulated" then the prover does not have enough witnesses // to perform the proof. Abort. - assert(step1.real, s"Tree root should be real but was $step1") + require(step1.real, s"Tree root should be real but was $step1") // Prover Step 3: Change some "real" nodes to "simulated" to make sure each node // has the right number of simulated children. diff --git a/interpreter/shared/src/main/scala/sigmastate/interpreter/ProverUtils.scala b/interpreter/shared/src/main/scala/sigmastate/interpreter/ProverUtils.scala index e2b0fe8f5a..7b77b96f64 100644 --- a/interpreter/shared/src/main/scala/sigmastate/interpreter/ProverUtils.scala +++ b/interpreter/shared/src/main/scala/sigmastate/interpreter/ProverUtils.scala @@ -80,7 +80,7 @@ trait ProverUtils extends Interpreter { realSecretsToExtract: Seq[SigmaBoolean], simulatedSecretsToExtract: Seq[SigmaBoolean] = Seq.empty): HintsBag = { val reduced = fullReduction(ergoTree, context, Interpreter.emptyEnv) - bagForMultisig(context, reduced.value, proof, realSecretsToExtract, simulatedSecretsToExtract) + bagForMultisig(reduced.value, proof, realSecretsToExtract, simulatedSecretsToExtract) } /** @@ -89,15 +89,13 @@ trait ProverUtils extends Interpreter { * * See DistributedSigSpecification for examples of usage. * - * @param context - context used to reduce the proposition * @param sigmaTree - public key (in form of a sigma-tree) * @param proof - signature for the key * @param realSecretsToExtract - public keys of secrets with real proofs * @param simulatedSecretsToExtract - public keys of secrets with simulated proofs * @return - bag of OtherSecretProven and OtherCommitment hints */ - def bagForMultisig(context: CTX, - sigmaTree: SigmaBoolean, + def bagForMultisig(sigmaTree: SigmaBoolean, proof: Array[Byte], realSecretsToExtract: Seq[SigmaBoolean], simulatedSecretsToExtract: Seq[SigmaBoolean]): HintsBag = { diff --git a/interpreter/shared/src/main/scala/sigmastate/trees.scala b/interpreter/shared/src/main/scala/sigmastate/trees.scala index 533229f652..510aae75c6 100644 --- a/interpreter/shared/src/main/scala/sigmastate/trees.scala +++ b/interpreter/shared/src/main/scala/sigmastate/trees.scala @@ -32,13 +32,27 @@ import scala.collection.mutable.ArrayBuffer */ trait SigmaConjecture extends SigmaBoolean { def children: Seq[SigmaBoolean] + + override def collectLeaves(position: NodePosition, buf: mutable.ArrayBuffer[PositionedLeaf]): Unit = { + cfor(0)(_ < children.length, _ + 1) { i => + children(i).collectLeaves(position.child(i), buf) + } + } } /** * Basic trait for leafs of crypto-trees, such as [[sigmastate.basics.DLogProtocol.ProveDlog]] and [[sigmastate.basics.ProveDHTuple]] instances */ -trait SigmaLeaf extends SigmaBoolean +trait SigmaLeaf extends SigmaBoolean { + override def collectLeaves(position: NodePosition, buf: mutable.ArrayBuffer[PositionedLeaf]): Unit = + buf += PositionedLeaf(position, this) +} +/** Represents leaf and its position in a SigmaBoolean tree. */ +case class PositionedLeaf(position: NodePosition, leaf: SigmaLeaf) +object PositionedLeaf { + def at(path: Int*)(leaf: SigmaLeaf) = PositionedLeaf(NodePosition.CryptoTreePrefix ++ path, leaf) +} /** * AND conjunction for sigma propositions @@ -130,6 +144,7 @@ case class CTHRESHOLD(k: Int, children: Seq[SigmaBoolean]) extends SigmaConjectu abstract class TrivialProp(val condition: Boolean) extends SigmaBoolean with Product1[Boolean] { override def _1: Boolean = condition override def canEqual(that: Any): Boolean = that != null && that.isInstanceOf[TrivialProp] + override def collectLeaves(position: NodePosition, buf: mutable.ArrayBuffer[PositionedLeaf]): Unit = () // not a leaf } object TrivialProp { // NOTE: the corresponding unapply is missing because any implementation (even using Nullable) diff --git a/interpreter/shared/src/test/scala/sigmastate/SigmaProtocolSpecification.scala b/interpreter/shared/src/test/scala/sigmastate/SigmaProtocolSpecification.scala index 669cb09da5..023058318f 100644 --- a/interpreter/shared/src/test/scala/sigmastate/SigmaProtocolSpecification.scala +++ b/interpreter/shared/src/test/scala/sigmastate/SigmaProtocolSpecification.scala @@ -1,10 +1,16 @@ package sigmastate +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks +import sigmastate.TrivialProp.{FalseProp, TrueProp} +import sigmastate.Values.SigmaBoolean +import sigmastate.basics.DLogProtocol.ProveDlog +import sigmastate.basics.ProveDHTuple import sigmastate.basics.VerifierMessage.Challenge import sigmastate.crypto.{GF2_192, GF2_192_Poly} +import sigmastate.utils.Helpers import special.sigma.SigmaTestingData -class SigmaProtocolSpecification extends SigmaTestingData { +class SigmaProtocolSpecification extends SigmaTestingData with ScalaCheckPropertyChecks { property("CThresholdUncheckedNode equality") { val c1 = Challenge @@ Coll[Byte](1) @@ -24,4 +30,44 @@ class SigmaProtocolSpecification extends SigmaTestingData { assertResult(true)(n4 != n5) } + property("collecting SigmaBoolean leaves") { + + val dlog1 = ProveDlog(Helpers.decodeECPoint("0297c44a12f4eb99a85d298fa3ba829b5b42b9f63798c980ece801cc663cc5fc9e")) + val dlog2 = ProveDlog(Helpers.decodeECPoint("02af645874c3b53465a5e9d820eb207d6001258c3b708f0d31d7c2e342833dce64")) + val dht1 = ProveDHTuple( + Helpers.decodeECPoint("021b4c0f54304b9c427d5c87846dd56a2fa361cd34a6cb8a2090aef043c9383198"), + Helpers.decodeECPoint("026826a4a9d0ec937c24d72da381ee6b5e74e49fb79a6a23a03fe0aa2cab3448ba"), + Helpers.decodeECPoint("02535153378ce30df1b31137680748de728f8512d29dfeeb1f331ac6a787cd00d8"), + Helpers.decodeECPoint("03d00d0174cdffd7ce3b77ef45ef9573c18fb76929fb3340f7ceea8d0be9bf5c4a") + ) + val and = CAND(Seq(dlog1, dlog2)) + val or = COR(Seq(dlog1, dlog2)) + val th = CTHRESHOLD(1, Seq(dlog1, dht1)) + val th2 = CTHRESHOLD(2, Seq(TrueProp, and, or, th, dlog1, dht1)) + + def position(path: Int*): NodePosition = NodePosition.CryptoTreePrefix ++ path + + val table = Table(("proposition", "leafs"), + (TrueProp, Seq()), + (FalseProp, Seq()), + (dlog1, Seq(PositionedLeaf(NodePosition.CryptoTreePrefix, dlog1))), + (dht1, Seq(PositionedLeaf(NodePosition.CryptoTreePrefix, dht1))), + (and, Seq(PositionedLeaf(position(0), dlog1), PositionedLeaf(position(1), dlog2))), + (or, Seq(PositionedLeaf(position(0), dlog1), PositionedLeaf(position(1), dlog2))), + (th, Seq(PositionedLeaf(position(0), dlog1), PositionedLeaf(position(1), dht1))), + (th2, Seq( + PositionedLeaf(position(1, 0), dlog1), + PositionedLeaf(position(1, 1), dlog2), + PositionedLeaf(position(2, 0), dlog1), + PositionedLeaf(position(2, 1), dlog2), + PositionedLeaf(position(3, 0), dlog1), + PositionedLeaf(position(3, 1), dht1), + PositionedLeaf(position(4), dlog1), + PositionedLeaf(position(5), dht1))) + ) + forAll(table) { (prop: SigmaBoolean, leafs: Seq[PositionedLeaf]) => + prop.leaves shouldBe leafs + } + th2.distinctLeaves shouldBe Set(dlog1, dlog2, dht1) + } } diff --git a/interpreter/shared/src/test/scala/sigmastate/utxo/ProverSpecification.scala b/interpreter/shared/src/test/scala/sigmastate/utxo/ProverSpecification.scala index 360754db6f..c5b049dd63 100644 --- a/interpreter/shared/src/test/scala/sigmastate/utxo/ProverSpecification.scala +++ b/interpreter/shared/src/test/scala/sigmastate/utxo/ProverSpecification.scala @@ -118,8 +118,8 @@ class ProverSpecification extends TestingCommons { def checkUnrealRoot(sb: SigmaBoolean)(implicit prover: ProverInterpreter) = { assertExceptionThrown( checkProof(sb), - { case e: AssertionError => e.getMessage.contains("Tree root should be real but was") - case _ => false }) + exceptionLike[IllegalArgumentException]("Tree root should be real but was") + ) } property("proof/verify completeness") { diff --git a/sc/shared/src/test/scala/sigmastate/utxo/DistributedSigSpecification.scala b/sc/shared/src/test/scala/sigmastate/utxo/DistributedSigSpecification.scala index 9276413e1e..29863ee30d 100644 --- a/sc/shared/src/test/scala/sigmastate/utxo/DistributedSigSpecification.scala +++ b/sc/shared/src/test/scala/sigmastate/utxo/DistributedSigSpecification.scala @@ -77,29 +77,33 @@ class DistributedSigSpecification extends CompilerTestingCommons val prop = mkTestErgoTree(compile(env, """pubkeyA && pubkeyB && pubkeyC""").asSigmaProp) val bobHints = proverB.generateCommitments(prop, ctx) - val carolHints = proverC.generateCommitments(prop, ctx) - val dlBKnown: Hint = bobHints.realCommitments.head + + val carolHints = proverC.generateCommitments(prop, ctx) val dlCKnown: Hint = carolHints.realCommitments.head + val bagA = HintsBag(Seq(dlBKnown, dlCKnown)) + // Alice starts co-signing (needs all commitments in the hingsBag) val proofAlice = proverA.prove(prop, ctx, fakeMessage, bagA).get val bagC = proverB.bagForMultisig(ctx, prop, proofAlice.proof, Seq(pubkeyAlice)) .addHint(carolHints.ownCommitments.head) .addHint(dlBKnown) + // Carol continues co-signing val proofCarol = proverC.prove(prop, ctx, fakeMessage, bagC).get val bagB = proverB.bagForMultisig(ctx, prop, proofCarol.proof, Seq(pubkeyAlice, pubkeyCarol)) .addHint(bobHints.ownCommitments.head) + // Bob finishes co-signing val proofBob = proverB.prove(prop, ctx, fakeMessage, bagB).get // Proof generated by Alice without getting Bob's part is not correct verifier.verify(prop, ctx, proofAlice, fakeMessage).get._1 shouldBe false - // Proof generated by Alice without getting Bob's part is not correct + // Proof generated by Carol without getting Bob's part is not correct verifier.verify(prop, ctx, proofCarol, fakeMessage).get._1 shouldBe false // Compound proof from Bob is correct diff --git a/sc/shared/src/test/scala/sigmastate/utxo/examples/ColdWalletContractExampleSpecification.scala b/sc/shared/src/test/scala/sigmastate/utxo/examples/ColdWalletContractExampleSpecification.scala index ffd4b17018..1f07756b97 100644 --- a/sc/shared/src/test/scala/sigmastate/utxo/examples/ColdWalletContractExampleSpecification.scala +++ b/sc/shared/src/test/scala/sigmastate/utxo/examples/ColdWalletContractExampleSpecification.scala @@ -167,10 +167,10 @@ class ColdWalletContractExampleSpecification extends CompilerTestingCommons activatedVersionInTests ) - an [AssertionError] should be thrownBy ( + an [IllegalArgumentException] should be thrownBy ( alice.prove(spendEnv, scriptTree, withdrawContextInvalid, fakeMessage).get ) - an [AssertionError] should be thrownBy ( + an [IllegalArgumentException] should be thrownBy ( bob.prove(spendEnv, scriptTree, withdrawContextInvalid, fakeMessage).get ) diff --git a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/BlockchainParameters.scala b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/BlockchainParameters.scala index 4f4cc87010..eb421e171b 100644 --- a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/BlockchainParameters.scala +++ b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/BlockchainParameters.scala @@ -1,34 +1,30 @@ package org.ergoplatform.sdk.js -import org.ergoplatform.sdk.wallet.protocol.context.ErgoLikeParameters +import org.ergoplatform.sdk import scala.scalajs.js.UndefOr import scala.scalajs.js.annotation.JSExportTopLevel +import org.ergoplatform.sdk.Iso._ +/** JS exported version of the [[sdk.BlockchainParameters]] class with the same fields. + * @see sdk.BlockchainParameters + */ @JSExportTopLevel("BlockchainParameters") class BlockchainParameters( - val storageFeeFactor: Int, - val minValuePerByte: Int, - val maxBlockSize: Int, - val tokenAccessCost: Int, - val inputCost: Int, - val dataInputCost: Int, - val outputCost: Int, - val maxBlockCost: Int, - val _softForkStartingHeight: UndefOr[Int], - val _softForkVotesCollected: UndefOr[Int], - val blockVersion: Byte -) extends ErgoLikeParameters { - import org.ergoplatform.sdk.Iso._ - /** - * @return height when voting for a soft-fork had been started - */ - override def softForkStartingHeight: Option[Int] = - Isos.isoUndefOr[Int, Int](identityIso).to(_softForkStartingHeight) - - /** - * @return votes for soft-fork collected in previous epochs - */ - override def softForkVotesCollected: Option[Int] = - Isos.isoUndefOr[Int, Int](identityIso).to(_softForkVotesCollected) -} + storageFeeFactor: Int, + minValuePerByte: Int, + maxBlockSize: Int, + tokenAccessCost: Int, + inputCost: Int, + dataInputCost: Int, + outputCost: Int, + maxBlockCost: Int, + _softForkStartingHeight: UndefOr[Int], + _softForkVotesCollected: UndefOr[Int], + blockVersion: Byte +) extends sdk.BlockchainParameters( + storageFeeFactor, minValuePerByte, maxBlockSize, tokenAccessCost, inputCost, dataInputCost, + outputCost, maxBlockCost, + Isos.isoUndefOr[Int, Int](identityIso).to(_softForkStartingHeight), + Isos.isoUndefOr[Int, Int](identityIso).to(_softForkVotesCollected), blockVersion +) diff --git a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/BlockchainStateContext.scala b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/BlockchainStateContext.scala index 0b91cbae57..98244c5653 100644 --- a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/BlockchainStateContext.scala +++ b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/BlockchainStateContext.scala @@ -3,7 +3,7 @@ package org.ergoplatform.sdk.js import scala.scalajs.js import scala.scalajs.js.annotation.JSExportTopLevel -/** Equivalent of [[org.ergoplatform.sdk.wallet.protocol.context.ErgoLikeStateContext]] available from JS. */ +/** Equivalent of [[org.ergoplatform.sdk.wallet.protocol.context.BlockchainStateContext]] available from JS. */ @JSExportTopLevel("BlockchainStateContext") class BlockchainStateContext( val sigmaLastHeaders: js.Array[Header], diff --git a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/ErgoTree.scala b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/ErgoTree.scala index 810cc1ea81..fb80aeefcb 100644 --- a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/ErgoTree.scala +++ b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/ErgoTree.scala @@ -1,9 +1,6 @@ package org.ergoplatform.sdk.js -import scorex.util.encode.Base16 import sigmastate.Values -import sigmastate.serialization.ErgoTreeSerializer - import scala.scalajs.js import scala.scalajs.js.JSConverters.JSRichIterableOnce import scala.scalajs.js.annotation.JSExportTopLevel diff --git a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/Isos.scala b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/Isos.scala index e045294eab..9c1a57a919 100644 --- a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/Isos.scala +++ b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/Isos.scala @@ -2,7 +2,7 @@ package org.ergoplatform.sdk.js import org.ergoplatform.ErgoBox._ import org.ergoplatform.sdk.JavaHelpers.UniversalConverter -import org.ergoplatform.sdk.wallet.protocol.context.{CErgoLikeStateContext, ErgoLikeStateContext} +import org.ergoplatform.sdk.wallet.protocol.context import org.ergoplatform.sdk.{ExtendedInputBox, Iso} import org.ergoplatform._ import scalan.RType @@ -179,16 +179,16 @@ object Isos { } } - implicit val isoBlockchainStateContext: Iso[BlockchainStateContext, ErgoLikeStateContext] = new Iso[BlockchainStateContext, ErgoLikeStateContext] { - override def to(a: BlockchainStateContext): ErgoLikeStateContext = { - CErgoLikeStateContext( + implicit val isoBlockchainStateContext: Iso[BlockchainStateContext, context.BlockchainStateContext] = new Iso[BlockchainStateContext, context.BlockchainStateContext] { + override def to(a: BlockchainStateContext): context.BlockchainStateContext = { + context.BlockchainStateContext( sigmaLastHeaders = isoArrayToColl(isoHeader).to(a.sigmaLastHeaders), previousStateDigest = isoStringToColl.to(a.previousStateDigest), sigmaPreHeader = isoPreHeader.to(a.sigmaPreHeader) ) } - override def from(b: ErgoLikeStateContext): BlockchainStateContext = { + override def from(b: context.BlockchainStateContext): BlockchainStateContext = { new BlockchainStateContext( sigmaLastHeaders = isoArrayToColl(isoHeader).from(b.sigmaLastHeaders), previousStateDigest = isoStringToColl.from(b.previousStateDigest), diff --git a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/ProverBuilder.scala b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/ProverBuilder.scala index 4dfce27d44..4d35d033aa 100644 --- a/sdk/js/src/main/scala/org/ergoplatform/sdk/js/ProverBuilder.scala +++ b/sdk/js/src/main/scala/org/ergoplatform/sdk/js/ProverBuilder.scala @@ -1,9 +1,8 @@ package org.ergoplatform.sdk.js import org.ergoplatform.ErgoAddressEncoder.NetworkPrefix -import org.ergoplatform.sdk.wallet.protocol.context.ErgoLikeParameters import org.ergoplatform.sdk -import org.ergoplatform.sdk.SecretString +import org.ergoplatform.sdk.{BlockchainParameters, SecretString} import scala.scalajs.js import scala.scalajs.js.annotation.JSExportTopLevel @@ -12,7 +11,7 @@ import sigmastate.eval.SigmaDsl /** Equivalent of [[sdk.ProverBuilder]] available from JS. */ @JSExportTopLevel("ProverBuilder") -class ProverBuilder(parameters: ErgoLikeParameters, networkPrefix: NetworkPrefix) extends js.Object { +class ProverBuilder(parameters: BlockchainParameters, networkPrefix: NetworkPrefix) extends js.Object { val _builder = new sdk.ProverBuilder(parameters, networkPrefix) /** Configure this builder to use the given seed when building a new prover. diff --git a/sdk/js/src/test/scala/org/ergoplatform/sdk/js/IsosSpec.scala b/sdk/js/src/test/scala/org/ergoplatform/sdk/js/IsosSpec.scala index f8e7962055..e3a389be0d 100644 --- a/sdk/js/src/test/scala/org/ergoplatform/sdk/js/IsosSpec.scala +++ b/sdk/js/src/test/scala/org/ergoplatform/sdk/js/IsosSpec.scala @@ -1,14 +1,13 @@ package org.ergoplatform.sdk.js import org.ergoplatform.ErgoBox.{AdditionalRegisters, BoxId, TokenId} -import org.ergoplatform.sdk.{ExtendedInputBox, Iso} import org.ergoplatform._ -import org.ergoplatform.sdk.wallet.protocol.context.{CErgoLikeStateContext, ErgoLikeStateContext} +import org.ergoplatform.sdk.wallet.protocol.context.BlockchainStateContext +import org.ergoplatform.sdk.{ExtendedInputBox, Iso} import org.scalacheck.{Arbitrary, Gen} import org.scalatest.matchers.should.Matchers import org.scalatest.propspec.AnyPropSpec import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks -import scorex.crypto.authds.ADDigest import sigmastate.SType import sigmastate.Values.Constant import sigmastate.eval.Colls @@ -27,11 +26,11 @@ class IsosSpec extends AnyPropSpec with Matchers with ObjectGenerators with Sca extension <- contextExtensionGen } yield ExtendedInputBox(box, extension) - lazy val ergoLikeStateContextGen: Gen[ErgoLikeStateContext] = for { + lazy val blockchainStateContextGen: Gen[BlockchainStateContext] = for { stateRoot <- avlTreeGen headers <- headersGen(stateRoot) preHeader <- preHeaderGen(headers.headOption.map(_.id).getOrElse(modifierIdBytesGen.sample.get)) - } yield CErgoLikeStateContext( + } yield BlockchainStateContext( sigmaLastHeaders = Colls.fromItems(headers:_*), previousStateDigest = stateRoot.digest, sigmaPreHeader = preHeader @@ -93,7 +92,7 @@ class IsosSpec extends AnyPropSpec with Matchers with ObjectGenerators with Sca } property("Iso.isoBlockchainStateContext") { - forAll(ergoLikeStateContextGen) { (c: ErgoLikeStateContext) => + forAll(blockchainStateContextGen) { (c: BlockchainStateContext) => roundtrip(Isos.isoBlockchainStateContext)(c) } } diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/AppkitProvingInterpreter.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/AppkitProvingInterpreter.scala index 000b0a7347..a0a8c7f3b2 100644 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/AppkitProvingInterpreter.scala +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/AppkitProvingInterpreter.scala @@ -5,7 +5,7 @@ import org.ergoplatform._ import org.ergoplatform.sdk.Extensions.{CollOps, PairCollOps} import org.ergoplatform.sdk.JavaHelpers.{TokenColl, UniversalConverter} import org.ergoplatform.sdk.utils.ArithUtils -import org.ergoplatform.sdk.wallet.protocol.context.{ErgoLikeParameters, ErgoLikeStateContext, TransactionContext} +import org.ergoplatform.sdk.wallet.protocol.context.{BlockchainStateContext, TransactionContext} import org.ergoplatform.sdk.wallet.secrets.ExtendedSecretKey import org.ergoplatform.validation.ValidationRules import scalan.util.Extensions.LongOps @@ -35,7 +35,7 @@ class AppkitProvingInterpreter( val secretKeys: IndexedSeq[ExtendedSecretKey], val dLogInputs: IndexedSeq[DLogProverInput], val dhtInputs: IndexedSeq[DiffieHellmanTupleProverInput], - params: ErgoLikeParameters) + params: BlockchainParameters) extends ReducingInterpreter(params) with ProverInterpreter { override type CTX = ErgoLikeContext @@ -79,7 +79,7 @@ class AppkitProvingInterpreter( * The returned cost doesn't include `baseCost`. */ def sign(unreducedTx: UnreducedTransaction, - stateContext: ErgoLikeStateContext, + stateContext: BlockchainStateContext, baseCost: Int): Try[SignedTransaction] = Try { val maxCost = params.maxBlockCost var currentCost: Long = baseCost @@ -112,7 +112,7 @@ class AppkitProvingInterpreter( unsignedTx: UnsignedErgoLikeTransaction, boxesToSpend: IndexedSeq[ExtendedInputBox], dataBoxes: IndexedSeq[ErgoBox], - stateContext: ErgoLikeStateContext, + stateContext: BlockchainStateContext, baseCost: Int, tokensToBurn: IndexedSeq[ErgoToken]): ReducedErgoLikeTransaction = { if (unsignedTx.inputs.length != boxesToSpend.length) throw new Exception("Not enough boxes to spend") @@ -212,21 +212,25 @@ class AppkitProvingInterpreter( * Note, this method doesn't require context to generate proofs (aka signatures). * * @param reducedTx unsigend transaction augmented with reduced + * @param baseCost initial cost before signing + * @param inputBagsOpt optional sequence of hints bags for each input * @return a new signed transaction with all inputs signed and the cost of this transaction * The returned cost includes: * - the costs of obtaining reduced transaction * - the cost of verification of each signed input */ - def signReduced(reducedTx: ReducedTransaction, baseCost: Int): SignedTransaction = { + def signReduced(reducedTx: ReducedTransaction, baseCost: Int, inputBagsOpt: Option[IndexedSeq[HintsBag]] = None): SignedTransaction = { val provedInputs = mutable.ArrayBuilder.make[Input] val unsignedTx = reducedTx.ergoTx.unsignedTx + val inputBags = inputBagsOpt.getOrElse( + IndexedSeq.fill(unsignedTx.inputs.length)(HintsBag.empty)) val maxCost = params.maxBlockCost var currentCost: Long = baseCost - for ((reducedInput, boxIdx) <- reducedTx.ergoTx.reducedInputs.zipWithIndex ) { + for ((reducedInput, boxIdx) <- reducedTx.ergoTx.reducedInputs.zipWithIndex) { val unsignedInput = unsignedTx.inputs(boxIdx) - val proverResult = proveReduced(reducedInput, unsignedTx.messageToSign) + val proverResult = proveReduced(reducedInput, unsignedTx.messageToSign, inputBags(boxIdx)) val signedInput = Input(unsignedInput.boxId, proverResult) val verificationCost = estimateCryptoVerifyCost(reducedInput.reductionResult.value).toBlockCost diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/BlockchainContext.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/BlockchainContext.scala new file mode 100644 index 0000000000..d5b6edaf69 --- /dev/null +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/BlockchainContext.scala @@ -0,0 +1,22 @@ +package org.ergoplatform.sdk + +import org.ergoplatform.sdk.wallet.protocol.context.BlockchainStateContext +import special.collection.Coll +import special.sigma.Header + +/** Represents a specific context of blockchain for execution + * of transaction building scenario. + * It contains methods for accessing blockchain data, current blockchain state, + * node information etc. + * An instance of this class can also be used to create new builders + * for creating new transactions and provers (used for transaction signing). + */ +case class BlockchainContext( + networkType: NetworkType, + parameters: BlockchainParameters, + stateContext: BlockchainStateContext +) { + def headers: Coll[Header] = stateContext.sigmaLastHeaders + + def height: Int = headers(0).height +} diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/BlockchainParameters.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/BlockchainParameters.scala new file mode 100644 index 0000000000..4a1014b112 --- /dev/null +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/BlockchainParameters.scala @@ -0,0 +1,52 @@ +package org.ergoplatform.sdk + +/** Blockchain parameters re-adjustable via miners voting and voting-related data. + * All these fields are included into extension section of a first block of a voting epoch. + * + * @param storageFeeFactor cost of storing 1 byte in UTXO for four years, in nanoErgs + * @param minValuePerByte cost of a transaction output, in computation unit + * @param maxBlockSize max block size, in bytes + * @param tokenAccessCost cost of a token contained in a transaction, in computation unit + * @param inputCost cost of a transaction input, in computation unit + * @param dataInputCost cost of a transaction data input, in computation unit + * @param outputCost cost of a transaction output, in computation unit + * @param maxBlockCost computation units limit per block + * @param softForkStartingHeight height when voting for a soft-fork had been started + * @param softForkVotesCollected votes for soft-fork collected in previous epochs + * @param blockVersion Protocol version activated on the network + */ +case class BlockchainParameters( + storageFeeFactor: Int, + minValuePerByte: Int, + maxBlockSize: Int, + tokenAccessCost: Int, + inputCost: Int, + dataInputCost: Int, + outputCost: Int, + maxBlockCost: Int, + softForkStartingHeight: Option[Int], + softForkVotesCollected: Option[Int], + blockVersion: Byte +) + +/** Global parameters used by SDK */ +object BlockchainParameters { + /** A number of blocks a miner should wait before he/she can spend block reward. + * This is part of Ergo protocol and cannot be changed. + */ + val MinerRewardDelay_Mainnet = 720 + + val MinerRewardDelay_Testnet = 720 + + /** One Erg is 10^9 NanoErg */ + val OneErg: Long = 1000 * 1000 * 1000 + + /** Minimum transaction fee in NanoErgs as it is defined in Ergo protocol. */ + val MinFee: Long = 1000 * 1000 + + /** Minimum value for a change. It can be used to compute change output value. + * If computed change is less than this value, it is added to the fee + * and `change` output in not added to the transaction. + */ + val MinChangeValue: Long = 1000 * 1000 +} \ No newline at end of file diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/BoxSelectionResult.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/BoxSelectionResult.scala new file mode 100644 index 0000000000..d63ae82a44 --- /dev/null +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/BoxSelectionResult.scala @@ -0,0 +1,15 @@ +package org.ergoplatform.sdk + +import org.ergoplatform.ErgoBoxAssets + +/** + * Containter for box selector output + * + * @param inputBoxes - transaction inputs chosen by a selector + * @param changeBoxes - change outputs + * @param payToReemissionBox - pay-to-reemission output mde according to EIP-27, if needed + */ +class BoxSelectionResult[T <: ErgoBoxAssets]( + val inputBoxes: Seq[T], + val changeBoxes: Seq[ErgoBoxAssets], + val payToReemissionBox: Option[ErgoBoxAssets]) diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/ExtendedInputBox.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/ExtendedInputBox.scala index 311475c134..26a8c0379f 100644 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/ExtendedInputBox.scala +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/ExtendedInputBox.scala @@ -1,8 +1,11 @@ package org.ergoplatform.sdk -import org.ergoplatform.{ErgoBox, UnsignedInput} +import org.ergoplatform.{ErgoBox, ErgoBoxCandidate, UnsignedInput} +import scorex.util.ModifierId import sigmastate.interpreter.ContextExtension +// TODO rename to InputBox + /** Input ErgoBox paired with context variables (aka ContextExtensions). * * @param box an instance of ErgoBox which is used as an input of the transaction. @@ -16,4 +19,25 @@ case class ExtendedInputBox( extension: ContextExtension ) { def toUnsignedInput: UnsignedInput = new UnsignedInput(box.id, extension) + def value: Long = box.value +} + +case class OutBox(candidate: ErgoBoxCandidate) { + /** + * Converts this box candidate into a new instance of {@link ExtendedInputBox} by + * associating it with the given transaction and output position. + * This method can be used to create input boxed from scratch, without + * retrieving them from the UTXOs. Thus created boxes can be indistinguishable from those + * loaded from blockchain node, and as result can be used to create new transactions. + * This method can also be used to create chains of transactions in advance + * + * @param txId the id of the transaction of which created the box which will be returned + * @param outputIndex zero-based position (index) of the box in the outputs of the transaction. + * @return a new {@link ExtendedInputBox} representing UTXO box as an input of a next transaction. + */ + def convertToInputWith(txId: String, boxIndex: Short): ExtendedInputBox = { + val box = candidate.toBox(ModifierId @@ txId, boxIndex) + ExtendedInputBox(box, ContextExtension.empty) + } } + diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/Extensions.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/Extensions.scala index 840ec21576..11d0cd731a 100644 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/Extensions.scala +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/Extensions.scala @@ -2,11 +2,13 @@ package org.ergoplatform.sdk import debox.cfor import scalan.RType -import scalan.rtypeToClassTag // actually required +import scalan.rtypeToClassTag // actually used +import sigmastate.eval.CPreHeader import special.collection.{Coll, CollBuilder, PairColl} +import special.sigma.{Header, PreHeader} import scala.collection.compat.BuildFrom -import scala.collection.{GenIterable, immutable} +import scala.collection.{GenIterable, immutable, mutable} import scala.reflect.ClassTag object Extensions { @@ -196,4 +198,44 @@ object Extensions { builder.pairCollFromArrays(ks, vs) } } + + implicit class HeaderOps(val h: Header) extends AnyVal { + def toPreHeader: PreHeader = { + CPreHeader(h.version, h.parentId, h.timestamp, h.nBits, h.height, h.minerPk, h.votes) + } + } + + implicit class DoubleOps(val i: Double) extends AnyVal { + def erg: Long = (i * 1000000000L).toLong + } + + /** extension methods for IndexedSeq */ + implicit class IndexedSeqOps[T, C[X] <: IndexedSeq[X]](val v: C[T]) extends AnyVal { + + /** Modifies the seq by applying a function to the given element. + * @param i the index of the element to modify. + * @param f the function to apply to the element. + * @return the seq with the modified element. + */ + def modify(i: Int, f: T => T): C[T] = { + val newItem = f(v(i)) + v.updated(i, newItem).asInstanceOf[C[T]] + } + } + + /** extension methods for IndexedSeq */ + implicit class MutableMapOps[K, V](val m: mutable.Map[K, V]) extends AnyVal { + /** Modifies the Map by applying a function to the given element if it exists. + * + * @param k the key of the element to modify. + * @param f the function to apply to the element. + * @return the new value associated with the specified key + */ + def modifyIfExists(k: K)(f: V => V): Option[V] = { + m.updateWith(k) { + case Some(v) => Some(f(v)) + case None => None + } + } + } } diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/InputBoxesValidator.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/InputBoxesValidator.scala new file mode 100644 index 0000000000..7e3e24a8b7 --- /dev/null +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/InputBoxesValidator.scala @@ -0,0 +1,127 @@ +package org.ergoplatform.sdk + +import org.ergoplatform.SigmaConstants.MaxBoxSize +import org.ergoplatform.sdk.wallet.Constants.MaxAssetsPerBox +import org.ergoplatform.sdk.wallet.{AssetUtils, TokensMap} +import org.ergoplatform.{ErgoBoxAssets, ErgoBoxAssetsHolder} +import scorex.util.ModifierId + +import scala.collection.mutable + +object BoxSelection { + // from https://github.com/ergoplatform/ergo/blob/2ce78a0380977b8ca354518edca93a5269ac9f53/src/main/scala/org/ergoplatform/settings/Parameters.scala#L258-L258 + private val MinValuePerByteDefault = 30 * 12 + + val MinBoxValue: Long = (MaxBoxSize.value / 2L) * MinValuePerByteDefault + + trait Error { + def message: String + } + + final case class NotEnoughErgsError( + message: String, + balanceFound: Long) extends Error + + final case class NotEnoughTokensError( + message: String, + tokensFound: Map[ModifierId, Long]) extends Error + + final case class NotEnoughCoinsForChangeBoxesError(message: String) extends Error + + /** + * Pass through implementation of the box selector. Unlike DefaultBoxSelector from ergo-wallet, + * it does not select input boxes. We do this in SDK ourselves and do not need the selector + * to interfere with how we built our transaction. Instead, this selector performs validation + * and calculates the necessary change box + */ + class InputBoxesValidator { + + def select[T <: ErgoBoxAssets](inputBoxes: Iterator[T], + externalFilter: T => Boolean, + targetBalance: Long, + targetAssets: TokensMap): Either[Error, BoxSelectionResult[T]] = { + //mutable structures to collect results + val res = mutable.Buffer[T]() + var currentBalance = 0L + val currentAssets = mutable.Map[ModifierId, Long]() + + // select all input boxes - we only validate here + inputBoxes.foreach { box: T => + currentBalance = currentBalance + box.value + AssetUtils.mergeAssetsMut(currentAssets, box.tokens) + res += box + } + + if (currentBalance - targetBalance >= 0) { + //now check if we found all tokens + if (targetAssets.forall { + case (id, targetAmt) => currentAssets.getOrElse(id, 0L) >= targetAmt + }) { + formChangeBoxes(currentBalance, targetBalance, currentAssets, targetAssets) match { + case Right(changeBoxes) => Right(new BoxSelectionResult(res.toSeq, changeBoxes, None)) + case Left(error) => Left(error) + } + } else { + Left(NotEnoughTokensError( + s"Not enough tokens in input boxes to send $targetAssets (found only $currentAssets)", currentAssets.toMap) + ) + } + } else { + Left(NotEnoughErgsError( + s"not enough boxes to meet ERG needs $targetBalance (found only $currentBalance)", currentBalance) + ) + } + } + + /** + * Helper method to construct change outputs + * + * @param foundBalance - ERG balance of boxes collected + * (spendable only, so after possibly deducting re-emission tokens) + * @param targetBalance - ERG amount to be transferred to recipients + * @param foundBoxAssets - assets balances of boxes + * @param targetBoxAssets - assets amounts to be transferred to recipients + * @return + */ + def formChangeBoxes(foundBalance: Long, + targetBalance: Long, + foundBoxAssets: mutable.Map[ModifierId, Long], + targetBoxAssets: TokensMap): Either[Error, Seq[ErgoBoxAssets]] = { + AssetUtils.subtractAssetsMut(foundBoxAssets, targetBoxAssets) + val changeBoxesAssets: Seq[mutable.Map[ModifierId, Long]] = foundBoxAssets.grouped(MaxAssetsPerBox).toSeq + val changeBalance = foundBalance - targetBalance + //at least a minimum amount of ERG should be assigned per a created box + if (changeBoxesAssets.size * MinBoxValue > changeBalance) { + Left(NotEnoughCoinsForChangeBoxesError( + s"Not enough nanoERGs ($changeBalance nanoERG) to create ${changeBoxesAssets.size} change boxes, \nfor $changeBoxesAssets" + )) + } else { + val changeBoxes = if (changeBoxesAssets.nonEmpty) { + val baseChangeBalance = changeBalance / changeBoxesAssets.size + + val changeBoxesNoBalanceAdjusted = changeBoxesAssets.map { a => + ErgoBoxAssetsHolder(baseChangeBalance, a.toMap) + } + + val modifiedBoxOpt = changeBoxesNoBalanceAdjusted.headOption.map { firstBox => + ErgoBoxAssetsHolder( + changeBalance - baseChangeBalance * (changeBoxesAssets.size - 1), + firstBox.tokens + ) + } + + modifiedBoxOpt.toSeq ++ changeBoxesNoBalanceAdjusted.tail + } else if (changeBalance > 0) { + Seq(ErgoBoxAssetsHolder(changeBalance)) + } else { + Seq.empty + } + + Right(changeBoxes) + } + } + + } + +} + diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/JavaHelpers.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/JavaHelpers.scala index 4a49e23283..52c653e9db 100644 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/JavaHelpers.scala +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/JavaHelpers.scala @@ -109,21 +109,25 @@ object Iso extends LowPriorityIsos { override def from(t: Token): ErgoToken = new ErgoToken(t._1.toArray, t._2) } - implicit val isoJListErgoTokenToMapPair: Iso[JList[ErgoToken], mutable.LinkedHashMap[ModifierId, Long]] = - new Iso[JList[ErgoToken], mutable.LinkedHashMap[ModifierId, Long]] { - override def to(a: JList[ErgoToken]): mutable.LinkedHashMap[ModifierId, Long] = { - import JavaHelpers._ + implicit val isoErgoTokenSeqToLinkedMap: Iso[IndexedSeq[ErgoToken], mutable.LinkedHashMap[ModifierId, Long]] = + new Iso[IndexedSeq[ErgoToken], mutable.LinkedHashMap[ModifierId, Long]] { + override def to(a: IndexedSeq[ErgoToken]): mutable.LinkedHashMap[ModifierId, Long] = { val lhm = new mutable.LinkedHashMap[ModifierId, Long]() - a.convertTo[IndexedSeq[Token]] - .map(t => bytesToId(t._1.toArray) -> t._2) - .foldLeft(lhm)(_ += _) + a.foreach { et => + val t = isoErgoTokenToPair.to(et) + lhm += bytesToId(t._1.toArray) -> t._2 + } + lhm } - override def from(t: mutable.LinkedHashMap[ModifierId, Long]): JList[ErgoToken] = { - import JavaHelpers._ - val pairs: IndexedSeq[Token] = t.toIndexedSeq - .map(t => (Digest32Coll @@ Colls.fromArray(idToBytes(t._1))) -> t._2) - pairs.convertTo[JList[ErgoToken]] + override def from(t: mutable.LinkedHashMap[ModifierId, Long]): IndexedSeq[ErgoToken] = { + val pairs = t.toIndexedSeq + .map { t => + val id = Digest32Coll @@ Colls.fromArray(idToBytes(t._1)) + val value = t._2 + isoErgoTokenToPair.from((id, value)) + } + pairs } } @@ -294,9 +298,6 @@ object JavaHelpers { ErgoAlgos.encode(ErgoAlgos.hash(s)) } - def toPreHeader(h: Header): special.sigma.PreHeader = { - CPreHeader(h.version, h.parentId, h.timestamp, h.nBits, h.height, h.minerPk, h.votes) - } def toSigmaBoolean(ergoTree: ErgoTree): SigmaBoolean = { val prop = ergoTree.toProposition(ergoTree.isConstantSegregation) diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/NetworkType.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/NetworkType.scala new file mode 100644 index 0000000000..806ce6b014 --- /dev/null +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/NetworkType.scala @@ -0,0 +1,49 @@ +package org.ergoplatform.sdk + +import org.ergoplatform.ErgoAddressEncoder +import org.ergoplatform.ErgoAddressEncoder.NetworkPrefix + +/** + * Enumeration of network types as they are defined by Ergo specification of {@link ErgoAddress}. + */ +abstract class NetworkType { + + /** + * The network prefix code used in Ergo addresses + */ + val networkPrefix: NetworkPrefix + + /** + * verbose name for network type as reported by Node API + */ + val verboseName: String +} + +object NetworkType { + /** Mainnet network type. + * + * @see ErgoAddressEncoder#MainnetNetworkPrefix() + */ + case object Mainnet extends NetworkType { + override val networkPrefix: NetworkPrefix = ErgoAddressEncoder.MainnetNetworkPrefix + override val verboseName = "mainnet" + } + + /** Testnet network type. + * + * @see ErgoAddressEncoder#TestnetNetworkPrefix() + */ + case object Testnet extends NetworkType { + override val networkPrefix: NetworkPrefix = ErgoAddressEncoder.TestnetNetworkPrefix + override val verboseName = "testnet" + } + + /** @return network type for given verbose name */ + def fromName(name: String): Option[NetworkType] = name match { + case "mainnet" => Some(Mainnet) + case "testnet" => Some(Testnet) + case _ => None + } + +} + diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/OutBoxBuilder.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/OutBoxBuilder.scala new file mode 100644 index 0000000000..5c2f531588 --- /dev/null +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/OutBoxBuilder.scala @@ -0,0 +1,80 @@ +package org.ergoplatform.sdk + +import org.ergoplatform.ErgoBox.TokenId +import org.ergoplatform.sdk.JavaHelpers.collRType +import org.ergoplatform.{ErgoBox, ErgoBoxCandidate, SigmaConstants} +import scalan.RType +import sigmastate.SType +import sigmastate.Values.{Constant, ErgoTree, EvaluatedValue} +import sigmastate.eval.Colls + +import scala.collection.mutable.ArrayBuffer + +class OutBoxBuilder(val _txB: UnsignedTransactionBuilder) { + private val _ctx = _txB.ctx + private var _value: Long = 0 + private var _contract: ErgoTree = _ + private val _tokens = ArrayBuffer.empty[ErgoToken] + private val _registers = ArrayBuffer.empty[Constant[_]] + private var _creationHeightOpt: Option[Int] = None + + def value(value: Long): this.type = { + _value = value + this + } + + def contract(contract: ErgoTree): this.type = { + _contract = contract + this + } + + def tokens(tokens: ErgoToken*): this.type = { + require(tokens.nonEmpty, "At least one token should be specified") + val maxTokens = SigmaConstants.MaxTokens.value + require(tokens.size <= maxTokens, SigmaConstants.MaxTokens.description + s": $maxTokens") + _tokens ++= tokens + this + } + + def registers(registers: Constant[_]*): this.type = { + require(registers.nonEmpty, "At least one register should be specified") + _registers.clear() + _registers ++= registers + this + } + + def creationHeight(height: Int): OutBoxBuilder = { + _creationHeightOpt = Some(height) + this + } + + def build(): OutBox = { + require(_contract != null, "Contract is not defined") + val ergoBoxCandidate = OutBoxBuilder.createBoxCandidate( + _value, _contract, _tokens.toSeq, _registers.toSeq, + creationHeight = _creationHeightOpt.getOrElse(_txB.ctx.height)) + OutBox(ergoBoxCandidate) + } +} + +object OutBoxBuilder { + def apply(txB: UnsignedTransactionBuilder): OutBoxBuilder = new OutBoxBuilder(txB) + + private[sdk] def createBoxCandidate( + value: Long, tree: ErgoTree, + tokens: Seq[ErgoToken], + registers: Seq[Constant[_]], creationHeight: Int): ErgoBoxCandidate = { + import org.ergoplatform.ErgoBox.nonMandatoryRegisters + val nRegs = registers.length + require(nRegs <= nonMandatoryRegisters.length, + s"Too many additional registers $nRegs. Max allowed ${nonMandatoryRegisters.length}") + implicit val TokenIdRType: RType[TokenId] = collRType(RType.ByteType).asInstanceOf[RType[TokenId]] + val ts = Colls.fromItems(tokens.map(Iso.isoErgoTokenToPair.to(_)): _*) + val rs = registers.zipWithIndex.map { case (c, i) => + val id = ErgoBox.nonMandatoryRegisters(i) + id -> c.asInstanceOf[EvaluatedValue[_ <: SType]] + }.toMap + new ErgoBoxCandidate(value, tree, creationHeight, ts, rs) + } +} + diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/ProverBuilder.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/ProverBuilder.scala index 2bdfe48fb1..1e13962e18 100644 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/ProverBuilder.scala +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/ProverBuilder.scala @@ -1,7 +1,6 @@ package org.ergoplatform.sdk -import org.ergoplatform.ErgoAddressEncoder.NetworkPrefix -import org.ergoplatform.sdk.wallet.protocol.context.ErgoLikeParameters +import org.ergoplatform.ErgoAddressEncoder.{MainnetNetworkPrefix, NetworkPrefix} import org.ergoplatform.sdk.wallet.secrets.ExtendedSecretKey import sigmastate.basics.DLogProtocol.DLogProverInput import sigmastate.basics.{DLogProtocol, DiffieHellmanTupleProverInput} @@ -11,7 +10,7 @@ import java.math.BigInteger import scala.collection.mutable.ArrayBuffer /** A builder class for constructing a `Prover` with specified secrets. */ -class ProverBuilder(parameters: ErgoLikeParameters, networkPrefix: NetworkPrefix) { +class ProverBuilder(parameters: BlockchainParameters, networkPrefix: NetworkPrefix) { private var _masterKey: Option[ExtendedSecretKey] = None /** Generated EIP-3 secret keys paired with their derivation path index. */ @@ -27,7 +26,7 @@ class ProverBuilder(parameters: ErgoLikeParameters, networkPrefix: NetworkPrefix def withMnemonic( mnemonicPhrase: SecretString, mnemonicPass: SecretString, - usePre1627KeyDerivation: Boolean + usePre1627KeyDerivation: Boolean = false ): ProverBuilder = { _masterKey = Some(JavaHelpers.seedToMasterKey(mnemonicPhrase, mnemonicPass, usePre1627KeyDerivation)) this @@ -89,3 +88,8 @@ class ProverBuilder(parameters: ErgoLikeParameters, networkPrefix: NetworkPrefix } } +object ProverBuilder { + def forMainnet(parameters: BlockchainParameters): ProverBuilder = + new ProverBuilder(parameters, MainnetNetworkPrefix) +} + diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/ReducingInterpreter.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/ReducingInterpreter.scala index b7d8547e32..f06c37fd39 100644 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/ReducingInterpreter.scala +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/ReducingInterpreter.scala @@ -3,7 +3,7 @@ package org.ergoplatform.sdk import org.ergoplatform.sdk.Extensions.{CollOps, PairCollOps} import org.ergoplatform.sdk.JavaHelpers.UniversalConverter import org.ergoplatform.sdk.utils.ArithUtils -import org.ergoplatform.sdk.wallet.protocol.context.{ErgoLikeParameters, ErgoLikeStateContext, TransactionContext} +import org.ergoplatform.sdk.wallet.protocol.context.{BlockchainStateContext, TransactionContext} import org.ergoplatform.validation.ValidationRules import org.ergoplatform.{ErgoLikeContext, ErgoLikeInterpreter} import scalan.util.Extensions.LongOps @@ -20,7 +20,7 @@ import java.util.{Objects, List => JList} import scala.collection.mutable /** Interpreter that can reduce transactions with given chain parameters. */ -class ReducingInterpreter(params: ErgoLikeParameters) extends ErgoLikeInterpreter { +class ReducingInterpreter(params: BlockchainParameters) extends ErgoLikeInterpreter { override type CTX = ErgoLikeContext import org.ergoplatform.sdk.Iso._ @@ -56,7 +56,7 @@ class ReducingInterpreter(params: ErgoLikeParameters) extends ErgoLikeInterprete */ def reduceTransaction( unreducedTx: UnreducedTransaction, - stateContext: ErgoLikeStateContext, + stateContext: BlockchainStateContext, baseCost: Int ): ReducedTransaction = { val unsignedTx = unreducedTx.unsignedTx diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/SigmaProver.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/SigmaProver.scala index 255534e14b..c877c7bc4c 100644 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/SigmaProver.scala +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/SigmaProver.scala @@ -2,7 +2,9 @@ package org.ergoplatform.sdk import org.ergoplatform.ErgoAddressEncoder.NetworkPrefix import org.ergoplatform._ -import org.ergoplatform.sdk.wallet.protocol.context.ErgoLikeStateContext +import org.ergoplatform.sdk.wallet.protocol.context.BlockchainStateContext +import sigmastate.SigmaLeaf +import sigmastate.Values.SigmaBoolean import sigmastate.eval.{CostingSigmaDslBuilder, SigmaDsl} import sigmastate.interpreter.HintsBag import sigmastate.utils.Helpers.TryOps @@ -13,10 +15,18 @@ import special.sigma.{BigInt, SigmaProp} * @param _prover an instance of interpreter and a prover combined * @param networkPrefix the network prefix for Ergo addresses */ -class SigmaProver(_prover: AppkitProvingInterpreter, networkPrefix: NetworkPrefix) { +class SigmaProver(private[sdk] val _prover: AppkitProvingInterpreter, networkPrefix: NetworkPrefix) { + require(!hasSecrets || _prover.pubKeys.nonEmpty, + "Prover has secrets but no public keys.") + implicit val ergoAddressEncoder: ErgoAddressEncoder = ErgoAddressEncoder(networkPrefix) - /** Returns the Pay-to-Public-Key (P2PK) address associated with the prover's public key. */ + def hasSecrets: Boolean = _prover.secrets.nonEmpty + + /** Returns the Pay-to-Public-Key (P2PK) address associated with the prover's public key. + * The returned address corresponds to the master secret derived from the mnemonic + * phrase configured in the [[ProverBuilder]]. + */ def getP2PKAddress: P2PKAddress = { val pk = _prover.pubKeys(0) P2PKAddress(pk) @@ -37,16 +47,16 @@ class SigmaProver(_prover: AppkitProvingInterpreter, networkPrefix: NetworkPrefi addresses } - /** Signs a given `UnreducedTransaction` using the prover's secret keys and the provided `ErgoLikeStateContext`. + /** Signs a given `UnreducedTransaction` using the prover's secret keys and the provided [[BlockchainStateContext]]. * Uses baseCost == 0. */ - def sign(stateCtx: ErgoLikeStateContext, tx: UnreducedTransaction): SignedTransaction = + def sign(stateCtx: BlockchainStateContext, tx: UnreducedTransaction): SignedTransaction = sign(stateCtx, tx, baseCost = 0) - /** Signs a given `UnreducedTransaction` using the prover's secret keys and the provided `ErgoLikeStateContext`. + /** Signs a given `UnreducedTransaction` using the prover's secret keys and the provided [[BlockchainStateContext]]. * Uses the given baseCost. */ - def sign(stateCtx: ErgoLikeStateContext, tx: UnreducedTransaction, baseCost: Int): SignedTransaction = { + def sign(stateCtx: BlockchainStateContext, tx: UnreducedTransaction, baseCost: Int): SignedTransaction = { val signed = _prover .sign(tx, stateContext = stateCtx, baseCost = baseCost) .getOrThrow @@ -65,9 +75,9 @@ class SigmaProver(_prover: AppkitProvingInterpreter, networkPrefix: NetworkPrefi } /** Reduces a given `UnreducedTransaction` using the prover's secret keys and the - * provided `ErgoLikeStateContext` with a base cost. + * provided [[BlockchainStateContext]] with a base cost. */ - def reduce(stateCtx: ErgoLikeStateContext, tx: UnreducedTransaction, baseCost: Int): ReducedTransaction = { + def reduce(stateCtx: BlockchainStateContext, tx: UnreducedTransaction, baseCost: Int): ReducedTransaction = { val reduced = _prover.reduceTransaction( unreducedTx = tx, stateContext = stateCtx, baseCost = baseCost) reduced @@ -78,4 +88,47 @@ class SigmaProver(_prover: AppkitProvingInterpreter, networkPrefix: NetworkPrefi _prover.signReduced(tx, tx.ergoTx.cost) } + /** Signs a given ReducedTransaction using the prover's secret keys and hints. + * @param tx - transaction to sign + * @param inputHints - hints containing proofs for all inputs + */ + def signReduced(tx: ReducedTransaction, inputBags: IndexedSeq[HintsBag]): SignedTransaction = { + val nInputs = tx.ergoTx.reducedInputs.length + require(nInputs == inputBags.length, + s"Number of bags ${inputBags.length} must be equal to number of inputs $nInputs") + + _prover.signReduced(tx, tx.ergoTx.cost, Some(inputBags)) + } + + def generateCommitments(sigmaTree: SigmaBoolean): HintsBag = { + _prover.generateCommitments(sigmaTree) + } + + def extractHints( + proposition: SigmaBoolean, + proof: Array[Byte], + realSecretsToExtract: Seq[SigmaLeaf], + simulatedSecretsToExtract: Seq[SigmaLeaf]): HintsBag = { + _prover.bagForMultisig(proposition, proof, realSecretsToExtract, simulatedSecretsToExtract) + } + + def generateProof( + sb: SigmaBoolean, + messageToSign: Array[Byte], + hintsBag: HintsBag): Array[Byte] = { + _prover.generateProof(sb, messageToSign, hintsBag) + } + + override def equals(obj: Any): Boolean = obj match { + case that: SigmaProver => + if (!this.hasSecrets || !that.hasSecrets) this eq that + else { + // both have secrets, compare masterKeys + this._prover.pubKeys(0) == that._prover.pubKeys(0) + } + case _ => false + } + + override def hashCode(): Int = + if (hasSecrets) this._prover.pubKeys(0).hashCode() else super.hashCode() } diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/Transactions.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/Transactions.scala index 8073b77c9e..4aef0caa64 100644 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/Transactions.scala +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/Transactions.scala @@ -1,6 +1,7 @@ package org.ergoplatform.sdk import org.ergoplatform.{ErgoBox, ErgoLikeTransaction, UnsignedErgoLikeTransaction} +import sigmastate.Values.SigmaBoolean import java.util @@ -45,7 +46,17 @@ case class UnreducedTransaction( } /** Represents results for transaction reduction by [[ReducingInterpreter]]. */ -case class ReducedTransaction(ergoTx: ReducedErgoLikeTransaction) +case class ReducedTransaction(ergoTx: ReducedErgoLikeTransaction) { + /** A seq of sigma propositions for each input of the transaction. + * These propositions define a set of proofs (signatures) to be generated for this + * transaction. + * The proofs can be generated by a single prover or by a group of co-singing provers. + */ + lazy val inputPropositions: Seq[SigmaBoolean] = ergoTx.reducedInputs.map(_.reductionResult.value) + + /** @return transaction bytes to be signed by a prover (so called "message to sign"). */ + def bytesToSign: Array[Byte] = ergoTx.unsignedTx.messageToSign +} /** Represents results for transaction signing by a prover like [[SigmaProver]]. */ case class SignedTransaction(ergoTx: ErgoLikeTransaction, cost: Int) diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/UnsignedTransactionBuilder.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/UnsignedTransactionBuilder.scala new file mode 100644 index 0000000000..e48f26fd6f --- /dev/null +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/UnsignedTransactionBuilder.scala @@ -0,0 +1,240 @@ +package org.ergoplatform.sdk + +import org.ergoplatform.ErgoBox.TokenId +import org.ergoplatform._ +import org.ergoplatform.sdk.BlockchainParameters.MinChangeValue +import org.ergoplatform.sdk.BoxSelection.InputBoxesValidator +import org.ergoplatform.sdk.Extensions.HeaderOps +import org.ergoplatform.sdk.wallet.{AssetUtils, TokensMap} +import scorex.util.{ModifierId, bytesToId} +import sigmastate.eval.Extensions.ArrayOps +import sigmastate.utils.Extensions.ModifierIdOps +import special.collection.Coll +import special.collection.Extensions.CollBytesOps +import special.sigma.PreHeader + +import scala.collection.mutable.ArrayBuffer +import scala.util.Try + +class UnsignedTransactionBuilder(val ctx: BlockchainContext) { + private[sdk] val _inputs: ArrayBuffer[ExtendedInputBox] = ArrayBuffer.empty[ExtendedInputBox] + private[sdk] val _outputs: ArrayBuffer[OutBox] = ArrayBuffer.empty[OutBox] + private[sdk] val _dataInputs: ArrayBuffer[ErgoBox] = ArrayBuffer.empty[ErgoBox] + + private var _tokensToBurn: Option[ArrayBuffer[ErgoToken]] = None + private var _feeAmount: Option[Long] = None + private var _changeAddress: Option[ErgoAddress] = None + private var _ph: Option[PreHeader] = None + + def preHeader(ph: PreHeader): this.type = { + require(_ph.isEmpty, "PreHeader is already specified") + _ph = Some(ph) + this + } + + def addInputs(boxes: ExtendedInputBox*): this.type = { + _inputs ++= boxes + this + } + + def addDataInputs(boxes: ErgoBox*): this.type = { + _dataInputs ++= boxes + this + } + + def addOutputs(outBoxes: OutBox*): this.type = { + _outputs ++= outBoxes + this + } + + def fee(feeAmount: Long): this.type = { + require(_feeAmount.isEmpty, "Fee already defined") + _feeAmount = Some(feeAmount) + this + } + + def addTokensToBurn(tokens: ErgoToken*): this.type = { + if (_tokensToBurn.isEmpty) + _tokensToBurn = Some(ArrayBuffer.empty[ErgoToken]) + + _tokensToBurn.get ++= tokens + this + } + + def sendChangeTo(changeAddress: ErgoAddress): this.type = { + require(_changeAddress.isEmpty, "Change address is already specified") + _changeAddress = Some(changeAddress) + this + } + + private def getDefined[T](opt: Option[T], msg: => String): T = { + opt match { + case Some(x) => x + case _ => + throw new IllegalArgumentException("requirement failed: " + msg) + } + } + + def build(): UnreducedTransaction = { + val boxesToSpend = _inputs.toIndexedSeq + val outputCandidates = _outputs.map(c => c.candidate).toIndexedSeq + require(!outputCandidates.isEmpty, "Output boxes are not specified") + + val dataInputBoxes = _dataInputs.toIndexedSeq + val dataInputs = _dataInputs.map(box => DataInput(box.id)).toIndexedSeq + require(_feeAmount.isEmpty || _feeAmount.get >= BlockchainParameters.MinFee, + s"When fee amount is defined it should be >= ${BlockchainParameters.MinFee}, got ${_feeAmount.get}") + val changeAddress = getDefined(_changeAddress, "Change address is not defined") + val inputBoxesSeq = boxesToSpend.map(eb => eb.box) + val requestedToBurn = _tokensToBurn.fold(IndexedSeq.empty[ErgoToken])(_.toIndexedSeq) + val burnTokens = Iso.isoErgoTokenSeqToLinkedMap.to(requestedToBurn).toMap + val rewardDelay = ctx.networkType match { + case NetworkType.Mainnet => BlockchainParameters.MinerRewardDelay_Mainnet + case NetworkType.Testnet => BlockchainParameters.MinerRewardDelay_Testnet + } + val tx = UnsignedTransactionBuilder.buildUnsignedTx( + inputs = inputBoxesSeq, dataInputs = dataInputs, outputCandidates = outputCandidates, + currentHeight = ctx.height, createFeeOutput = _feeAmount, + changeAddress = changeAddress, minChangeValue = MinChangeValue, + minerRewardDelay = rewardDelay, + burnTokens = burnTokens).get + + // the method above don't accept ContextExtension along with inputs, thus, after the + // transaction has been built we need to zip with the extensions that have been + // attached to the inputBoxes + val txWithExtensions = new UnsignedErgoLikeTransaction( + inputs = boxesToSpend.map(_.toUnsignedInput), + tx.dataInputs, tx.outputCandidates + ) + UnreducedTransaction(txWithExtensions, boxesToSpend, dataInputBoxes, requestedToBurn) + } + + def preHeader: PreHeader = _ph.getOrElse(ctx.headers(0).toPreHeader) + + def outBoxBuilder: OutBoxBuilder = OutBoxBuilder(this) + + def networkType: NetworkType = ctx.networkType + + def inputBoxes: IndexedSeq[ExtendedInputBox] = _inputs.toIndexedSeq + + def outputBoxes: IndexedSeq[OutBox] = _outputs.toIndexedSeq +} + +object UnsignedTransactionBuilder { + def apply(ctx: BlockchainContext): UnsignedTransactionBuilder = new UnsignedTransactionBuilder(ctx) + + private def validateStatelessChecks( + inputs: IndexedSeq[ErgoBox], dataInputs: IndexedSeq[DataInput], + outputCandidates: Seq[ErgoBoxCandidate]): Unit = { + // checks from ErgoTransaction.validateStateless + require(inputs.nonEmpty, "inputs cannot be empty") + require(outputCandidates.nonEmpty, "outputCandidates cannot be empty") + require(inputs.size <= Short.MaxValue, s"too many inputs - ${inputs.size} (max ${Short.MaxValue})") + require(dataInputs.size <= Short.MaxValue, s"too many dataInputs - ${dataInputs.size} (max ${Short.MaxValue})") + require(outputCandidates.size <= Short.MaxValue, + s"too many outputCandidates - ${outputCandidates.size} (max ${Short.MaxValue})") + require(outputCandidates.forall(_.value >= 0), s"outputCandidate.value must be >= 0") + val outputSumTry = Try(outputCandidates.map(_.value).reduce(java7.compat.Math.addExact(_, _))) + require(outputSumTry.isSuccess, s"Sum of transaction output values should not exceed ${Long.MaxValue}") + require(inputs.distinct.size == inputs.size, s"There should be no duplicate inputs") + } + + def collectOutputTokens(outputCandidates: Seq[ErgoBoxCandidate]): TokensMap = { + AssetUtils.mergeAssets( + initialMap = Map.empty[ModifierId, Long], + maps = outputCandidates.map(b => collTokensToMap(b.additionalTokens)): _*) + } + + def collTokensToMap(tokens: Coll[(TokenId, Long)]): TokensMap = + tokens.toArray.map(t => t._1.toModifierId -> t._2).toMap + + def tokensMapToColl(tokens: TokensMap): Coll[(TokenId, Long)] = + tokens.toArray.map { t => t._1.toTokenId -> t._2 }.toColl + + /** Creates unsigned transaction from given inputs and outputs adding outputs with miner's fee and change + * Runs required checks ensuring that resulted transaction will be successfully validated by a node. + * + * @param inputs - input boxes + * @param dataInputs - data inputs + * @param outputCandidates - output candidate boxes + * @param currentHeight - current height (used in miner's fee box and change box) + * @param createFeeOutput - optional fee amount to put in a new miner's fee box, which will be + * created by this method. If None, then feeOut is not created. + * @param changeAddress - address where to send change from the input boxes + * @param minChangeValue - minimum change value to send, otherwise add to miner's fee + * @param minerRewardDelay - reward delay to encode in miner's fee box + * @return unsigned transaction + */ + def buildUnsignedTx( + inputs: IndexedSeq[ErgoBox], + dataInputs: IndexedSeq[DataInput], + outputCandidates: Seq[ErgoBoxCandidate], + currentHeight: Int, + createFeeOutput: Option[Long], + changeAddress: ErgoAddress, + minChangeValue: Long, + minerRewardDelay: Int, + burnTokens: TokensMap = Map.empty + ): Try[UnsignedErgoLikeTransaction] = Try { + validateStatelessChecks(inputs, dataInputs, outputCandidates) + + // TODO: implement all appropriate checks from ErgoTransaction.validateStatefull + val feeAmount = createFeeOutput.getOrElse(0L) + require(createFeeOutput.fold(true)(_ > 0), s"expected fee amount > 0, got $feeAmount") + val inputTotal = inputs.map(_.value).sum + val outputSum = outputCandidates.map(_.value).sum + val outputTotal = outputSum + feeAmount + val changeAmt = inputTotal - outputTotal + require(changeAmt >= 0, s"total inputs $inputTotal is less then total outputs $outputTotal") + val firstInputBoxId = bytesToId(inputs(0).id) + val tokensOut = collectOutputTokens(outputCandidates) + // remove minted tokens if any + val tokensOutNoMinted = tokensOut.filterKeys(_ != firstInputBoxId) + val mintedTokensNum = tokensOut.size - tokensOutNoMinted.size + require(mintedTokensNum <= 1, s"Only one token can be minted, but found $mintedTokensNum") + require(burnTokens.values.forall(_ > 0), + s"Incorrect burnTokens specification, positive values are expected: $burnTokens") + // add burnTokens to target assets so that they are excluded from the change outputs + // thus total outputs assets will be reduced which is interpreted as _token burning_ + val tokensOutWithBurned = AssetUtils.mergeAssets(tokensOutNoMinted.toMap, burnTokens) + val boxSelector = new InputBoxesValidator + val selection = boxSelector.select[ErgoBox](inputs.iterator, _ => true, outputTotal, tokensOutWithBurned) match { + case Left(err) => throw new IllegalArgumentException( + s"failed to calculate change for outputTotal: $outputTotal, \ntokens: $tokensOut, \nburnTokens: $burnTokens, \ninputs: $inputs, \nreason: $err") + case Right(v) => v + } + // although we're only interested in change boxes, make sure selection contains exact inputs + assert(selection.inputBoxes == inputs, s"unexpected selected boxes, expected: $inputs, got ${selection.inputBoxes}") + val changeBoxes = selection.changeBoxes + val changeBoxesHaveTokens = changeBoxes.exists(_.tokens.nonEmpty) + val changeGoesToFee = changeAmt < minChangeValue && !changeBoxesHaveTokens + require(!changeGoesToFee || (changeAmt == 0 || createFeeOutput.isDefined), + s"""When change=$changeAmt < minChangeValue=$minChangeValue it is added to miner's fee, + |in this case createFeeOutput should be defined + |""".stripMargin) + val feeOutOpt = createFeeOutput.map { fee => + // if computed changeAmt is too small give it to miner as tips + val actualFee = if (changeGoesToFee) fee + changeAmt else fee + new ErgoBoxCandidate( + actualFee, + ErgoTreePredef.feeProposition(minerRewardDelay), + currentHeight + ) + } + val addedChangeOut = if (!changeGoesToFee) { + val script = changeAddress.script + changeBoxes.map { cb => + new ErgoBoxCandidate(cb.value, script, currentHeight, tokensMapToColl(cb.tokens)) + } + } else { + Seq() + } + val finalOutputCandidates = outputCandidates ++ feeOutOpt ++ addedChangeOut + new UnsignedErgoLikeTransaction( + inputs.map(b => new UnsignedInput(b.id)), + dataInputs, + finalOutputCandidates.toIndexedSeq + ) + } +} + diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/multisig/Signer.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/multisig/Signer.scala new file mode 100644 index 0000000000..57f73fca10 --- /dev/null +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/multisig/Signer.scala @@ -0,0 +1,5 @@ +package org.ergoplatform.sdk.multisig + +import org.ergoplatform.P2PKAddress +import org.ergoplatform.sdk.{ReducedTransaction, SigmaProver} + diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/wallet/protocol/context/BlockchainStateContext.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/wallet/protocol/context/BlockchainStateContext.scala new file mode 100644 index 0000000000..5c8d97df4f --- /dev/null +++ b/sdk/shared/src/main/scala/org/ergoplatform/sdk/wallet/protocol/context/BlockchainStateContext.scala @@ -0,0 +1,15 @@ +package org.ergoplatform.sdk.wallet.protocol.context + +import special.collection.Coll + +/** Blockchain context used in tx signing. + * + * @param sigmaLastHeaders fixed number (10 in Ergo) of last block headers + * @param previousStateDigest UTXO set digest from a last header (of sigmaLastHeaders) + * @param sigmaPreHeader returns pre-header (header without certain fields) of the current block + */ +case class BlockchainStateContext( + sigmaLastHeaders: Coll[special.sigma.Header], + previousStateDigest: Coll[Byte], + sigmaPreHeader: special.sigma.PreHeader +) diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/wallet/protocol/context/ErgoLikeParameters.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/wallet/protocol/context/ErgoLikeParameters.scala deleted file mode 100644 index b84387e2b3..0000000000 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/wallet/protocol/context/ErgoLikeParameters.scala +++ /dev/null @@ -1,63 +0,0 @@ -package org.ergoplatform.sdk.wallet.protocol.context - -/** - * Blockchain parameters readjustable via miners voting and voting-related data. - * All these fields are included into extension section of a first block of a voting epoch. - */ -trait ErgoLikeParameters { - - /** - * @return cost of storing 1 byte in UTXO for four years, in nanoErgs - */ - def storageFeeFactor: Int - - /** - * @return cost of a transaction output, in computation unit - */ - def minValuePerByte: Int - - /** - * @return max block size, in bytes - */ - def maxBlockSize: Int - - /** - * @return cost of a token contained in a transaction, in computation unit - */ - def tokenAccessCost: Int - - /** - * @return cost of a transaction input, in computation unit - */ - def inputCost: Int - - /** - * @return cost of a transaction data input, in computation unit - */ - def dataInputCost: Int - - /** - * @return cost of a transaction output, in computation unit - */ - def outputCost: Int - - /** - * @return computation units limit per block - */ - def maxBlockCost: Int - - /** - * @return height when voting for a soft-fork had been started - */ - def softForkStartingHeight: Option[Int] - - /** - * @return votes for soft-fork collected in previous epochs - */ - def softForkVotesCollected: Option[Int] - - /** - * @return Protocol version - */ - def blockVersion: Byte -} diff --git a/sdk/shared/src/main/scala/org/ergoplatform/sdk/wallet/protocol/context/ErgoLikeStateContext.scala b/sdk/shared/src/main/scala/org/ergoplatform/sdk/wallet/protocol/context/ErgoLikeStateContext.scala deleted file mode 100644 index b150c83218..0000000000 --- a/sdk/shared/src/main/scala/org/ergoplatform/sdk/wallet/protocol/context/ErgoLikeStateContext.scala +++ /dev/null @@ -1,41 +0,0 @@ -package org.ergoplatform.sdk.wallet.protocol.context - -import scorex.crypto.authds.ADDigest -import special.collection.Coll - -import java.util - -/** - * Blockchain context used in transaction validation. - */ -trait ErgoLikeStateContext { - - /** - * @return fixed number (10 in Ergo) of last block headers - */ - def sigmaLastHeaders: Coll[special.sigma.Header] - - // todo remove from ErgoLikeContext and from ErgoStateContext - /** - * @return UTXO set digest from a last header (of sigmaLastHeaders) - */ - def previousStateDigest: Coll[Byte] - - /** - * @return returns pre-header (header without certain fields) of the current block - */ - def sigmaPreHeader: special.sigma.PreHeader -} - -/** Representis the Ergo-like state context for tx signing. - * - * @param sigmaLastHeaders the last headers of the Sigma blockchain - * @param previousStateDigest the bytes representing the previous state digest - * @param sigmaPreHeader the pre-header object - */ -case class CErgoLikeStateContext( - sigmaLastHeaders: Coll[special.sigma.Header], - previousStateDigest: Coll[Byte], - sigmaPreHeader: special.sigma.PreHeader -) extends ErgoLikeStateContext { -} diff --git a/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/AddressBook.scala b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/AddressBook.scala new file mode 100644 index 0000000000..f8449f5c3a --- /dev/null +++ b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/AddressBook.scala @@ -0,0 +1,35 @@ +package org.ergoplatform.sdk.multisig + +import org.ergoplatform.ErgoAddress +import scala.collection.mutable + +class AddressBook { + val signersByMasterAddress: mutable.HashMap[ErgoAddress, Signer] = mutable.HashMap.empty + + val signersByEip3Address: mutable.HashMap[ErgoAddress, Signer] = mutable.HashMap.empty + + def add(signer: Signer): this.type = { + if (!signersByMasterAddress.contains(signer.masterAddress)) { + signersByMasterAddress.put(signer.masterAddress, signer) + signer.eip3Addresses.foreach { eip3Address => + signersByEip3Address.put(eip3Address, signer) + } + } + this + } + + def ++=(signers: Signer*): this.type = { + signers.foreach(add); + this + } + + def get(address: ErgoAddress): Option[Signer] = { + signersByMasterAddress.get(address).orElse(signersByEip3Address.get(address)) + } +} + +object AddressBook { + def apply(signers: Signer*): AddressBook = { + new AddressBook ++= (signers: _*) + } +} \ No newline at end of file diff --git a/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/CosigningServer.scala b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/CosigningServer.scala new file mode 100644 index 0000000000..f1cf273fb2 --- /dev/null +++ b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/CosigningServer.scala @@ -0,0 +1,44 @@ +package org.ergoplatform.sdk.multisig + +import org.ergoplatform.sdk.Extensions.MutableMapOps +import sigmastate.SigmaLeaf + +import scala.collection.mutable + +class CosigningServer { + val sessions: mutable.Map[SessionId, SigningSession] = mutable.Map.empty + + def addSession(session: SigningSession): SessionId = { + require(!sessions.contains(session.id), s"Session for tx ${session.id} already exists") + sessions.put(session.id, session) + session.id + } + + def getSession(id: SessionId): Option[SigningSession] = { + sessions.get(id) + } + + def getSessionsFor(signerKeys: Seq[SigmaLeaf]): Seq[SigningSession] = { + val keyset = signerKeys.toSet + sessions.values.filter { s => + s.positionsToProve.exists { positionedLeafs => + positionedLeafs.exists(pl => keyset.contains(pl.leaf)) + } + }.toSeq + } + + def updateSession(session: SigningSession): Unit = { + sessions.put(session.id, session) + } + + def getReadySessions: Seq[SigningSession] = { + sessions.values.filter(_.isReady).toSeq + } + + def finishSession(sessionId: SessionId): Unit = { + sessions.modifyIfExists(sessionId)(_.finish()) + } +} + +object CosigningServer { +} \ No newline at end of file diff --git a/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/Signer.scala b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/Signer.scala new file mode 100644 index 0000000000..44a5af5934 --- /dev/null +++ b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/Signer.scala @@ -0,0 +1,106 @@ +package org.ergoplatform.sdk.multisig + +import org.ergoplatform.P2PKAddress +import org.ergoplatform.sdk.{ReducedTransaction, SigmaProver, SignedTransaction} +import scalan.reflection.memoize +import sigmastate.SigmaLeaf +import sigmastate.Values.SigmaBoolean +import sigmastate.interpreter.{HintsBag, RealCommitment} + +import scala.collection.mutable + +/** Stateful object, represents one participant in multisig session. + * Keeps secret random values in secure persistent storage. + * Can participate in many [[SigningSession]]. + */ +class Signer(val prover: SigmaProver) { + + /** Mapping from (session id, input proposition) stored hints. */ + private val sessions = mutable.HashMap.empty[(SessionId, SigmaBoolean), HintsBag] + + /** Mapping from (session id, input proposition) stored proof hints. */ + private val proofs = mutable.HashMap.empty[(SessionId, SigmaBoolean), HintsBag] + + def masterAddress: P2PKAddress = prover.getP2PKAddress + + def pubkey: SigmaLeaf = masterAddress.pubkey + + def eip3Addresses: Seq[P2PKAddress] = prover.getEip3Addresses + + def allAddresses: Seq[P2PKAddress] = masterAddress +: eip3Addresses + + def allKeys: Seq[SigmaLeaf] = allAddresses.map(_.pubkey) + + def canProve(leaf: SigmaLeaf): Boolean = allKeys.contains(leaf) + + def getHintsBag(sessionId: SessionId, leaf: SigmaBoolean): Option[HintsBag] = { + sessions.get((sessionId, leaf)) + } + + def startCosigning(reduced: ReducedTransaction): SigningSession = { + SigningSession(reduced) + } + + private def generateCommitments(sigmaTree: SigmaBoolean, sessionId: SessionId): Seq[RealCommitment] = { + val bag = memoize(sessions)((sessionId, sigmaTree), prover.generateCommitments(sigmaTree)) + bag.realCommitments + } + + private def generateProof( + sb: SigmaBoolean, sessionId: SessionId, + messageToSign: Array[Byte], + hintsBag: HintsBag): Array[Byte] = { + prover.generateProof(sb, messageToSign, hintsBag) + } + + def getActionsFrom(session: SigningSession): Seq[SigningAction] = { + val canProveInputs = session.positionsToProve.map { positions => + positions.filter { pl => canProve(pl.leaf) } + } + canProveInputs.zipWithIndex.flatMap { case (positions, inputIndex) => + positions.map { pl => + val action = if (session.collectedHints(inputIndex).hints.isEmpty) + CreateCommitment(this.pubkey, inputIndex, pl) + else + CreateSignature(this.pubkey, inputIndex, pl) + action + } + } + } + + def execute(action: SigningAction, session: SigningSession): SigningSession = { + val proposition = session.reduced.inputPropositions(action.inputIndex) + val newHints = action match { + case CreateCommitment(_, inputIndex, pl) => + val commitments = generateCommitments(proposition, session.id) + commitments.filter(c => c.position == pl.position && c.image == pl.leaf) + + case CreateSignature(_, inputIndex, pl) => + val ownCommitments = getHintsBag(session.id, proposition).get.ownCommitments + val otherCommitments = session.collectedHints(inputIndex) + .filter(_.image != pl.leaf) + val proof = generateProof(proposition, + session.id, + session.reduced.bytesToSign, + otherCommitments.addHints(ownCommitments: _*)) + val proofHints = prover.extractHints(proposition, proof, Seq(pl.leaf), Seq.empty) + proofHints.realProofs.filter(rp => rp.position == pl.position && rp.image == pl.leaf) + } + session.addHintsAt(action.inputIndex, newHints) + } + + def createSignedTransaction(session: SigningSession): SignedTransaction = { + prover.signReduced(session.reduced, session.collectedHints) + } + + override def equals(obj: Any): Boolean = (this eq obj.asInstanceOf[AnyRef]) || (obj match { + case s: Signer => s.prover == prover + case _ => false + }) + override def hashCode(): Int = prover.hashCode() + override def toString: String = s"Signer($masterAddress)" +} + +object Signer { + def apply(prover: SigmaProver): Signer = new Signer(prover) +} \ No newline at end of file diff --git a/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/SigningSession.scala b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/SigningSession.scala new file mode 100644 index 0000000000..09511653a0 --- /dev/null +++ b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/SigningSession.scala @@ -0,0 +1,65 @@ +package org.ergoplatform.sdk.multisig + +import org.ergoplatform.sdk.Extensions.IndexedSeqOps +import org.ergoplatform.sdk.ReducedTransaction +import sigmastate.interpreter.{Hint, HintsBag} +import sigmastate.{PositionedLeaf, SigmaLeaf} + +case class SessionId(value: String) extends AnyVal + +trait SigningAction { + def signerPk: SigmaLeaf + def inputIndex: Int + def leaf: PositionedLeaf +} + +case class CreateCommitment(signerPk: SigmaLeaf, inputIndex: Int, leaf: PositionedLeaf) extends SigningAction + +case class CreateSignature(signerPk: SigmaLeaf, inputIndex: Int, leaf: PositionedLeaf) extends SigningAction + + +case class SigningSession( + reduced: ReducedTransaction, + collectedHints: Vector[HintsBag], + isFinished: Boolean = false +) { + require(reduced.ergoTx.reducedInputs.length == collectedHints.length, + s"Collected hints should be provided for each input, but got ${collectedHints.length} hints for ${reduced.ergoTx.reducedInputs.length} inputs") + + def id: SessionId = SessionId(reduced.ergoTx.unsignedTx.id) + + /** Returns a seq of public keys (leaf sigma propositions) for each input. + * For each such leaf a proof should be generated to complete this session. + */ + lazy val positionsToProve: Seq[Seq[PositionedLeaf]] = { + val inputs = reduced.ergoTx.reducedInputs + inputs.map { reducedInput => + val sb = reducedInput.reductionResult.value + sb.leaves() + } + } + + def addHintsAt(inputIndex: Int, hints: Seq[Hint]): SigningSession = { + copy(collectedHints = collectedHints.modify(inputIndex, _.addHints(hints: _*))) + } + + def isReady: Boolean = { + positionsToProve.zipWithIndex.forall { case (positions, inputIndex) => + val inputHints = collectedHints(inputIndex) + positions.forall { pl => inputHints.hasProofFor(pl) } + } + } + + /** Mark this session as finished, no more actions can be performed. */ + def finish(): SigningSession = { + copy(isFinished = true) + } + +} + +object SigningSession { + def apply(reduced: ReducedTransaction): SigningSession = { + val collectedHints = Vector.fill(reduced.ergoTx.reducedInputs.length)(HintsBag.empty) + SigningSession(reduced, collectedHints) + } +} \ No newline at end of file diff --git a/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/SigningSpec.scala b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/SigningSpec.scala new file mode 100644 index 0000000000..e833142e56 --- /dev/null +++ b/sdk/shared/src/test/scala/org/ergoplatform/sdk/multisig/SigningSpec.scala @@ -0,0 +1,181 @@ +package org.ergoplatform.sdk.multisig + +import org.ergoplatform.sdk.Extensions.DoubleOps +import org.ergoplatform.sdk._ +import org.ergoplatform.sdk.wallet.protocol.context.BlockchainStateContext +import org.ergoplatform.{ErgoAddress, ErgoTreePredef} +import org.scalatest.matchers.should.Matchers +import org.scalatest.propspec.AnyPropSpec +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks +import scalan.util.CollectionUtil.AnyOps +import sigmastate.Values.{Constant, ErgoTree} +import sigmastate.interpreter.{HintsBag, OwnCommitment, RealCommitment} +import sigmastate.{PositionedLeaf, TestsBase} +import special.sigma.SigmaTestingData + +import scala.collection.immutable.Seq + +class SigningSpec extends AnyPropSpec with ScalaCheckPropertyChecks with Matchers + with TestsBase + with SigmaTestingData { + + val mainnetParameters = BlockchainParameters( + storageFeeFactor = 1250000, + minValuePerByte = 360, + maxBlockSize = 1271009, + tokenAccessCost = 100, + inputCost = 2407, + dataInputCost = 100, + outputCost = 184, + maxBlockCost = 8001091, + softForkStartingHeight = None, + softForkVotesCollected = None, + blockVersion = 3 + ) + + val mockTxId = "f9e5ce5aa0d95f5d54a7bc89c46730d9662397067250aa18a0039631c0f5b809" + + val ctx = BlockchainContext(NetworkType.Mainnet, mainnetParameters, + BlockchainStateContext( + sigmaLastHeaders = headers, + previousStateDigest = headers(0).stateRoot.digest, + sigmaPreHeader = preHeader + ) + ) + + + def createSigner(secret: String): Signer = Signer( + ProverBuilder.forMainnet(mainnetParameters) + .withMnemonic(SecretString.create(secret), SecretString.empty()) + .build() + ) + + /** Special signer without secretes */ + val publicSigner = Signer(ProverBuilder.forMainnet(mainnetParameters).build()) + + val alice = createSigner("Alice secret") + val bob = createSigner("Bob secret") + val carol = createSigner("Carol secret") + val david = createSigner("David secret") + + def createReducedTx( + ctx: BlockchainContext, inputs: Seq[ExtendedInputBox], + recepient: ErgoAddress, changeAddress: ErgoAddress): ReducedTransaction = { + val txB = UnsignedTransactionBuilder(ctx) + val output = txB.outBoxBuilder + .value(inputs.map(_.value).sum - BlockchainParameters.MinFee) + .contract(recepient.script).build() + val feeOut = txB.outBoxBuilder + .value(BlockchainParameters.MinFee) + .contract(ErgoTreePredef.feeProposition()) + .build() + val unsigned = txB + .addInputs(inputs: _*) + .addOutputs(output, feeOut) + .sendChangeTo(changeAddress) + .build() + // create a prover without secrets as they are not needed for reduction + val prover = ProverBuilder.forMainnet(ctx.parameters).build() + val reduced = prover.reduce(ctx.stateContext, unsigned, 0) + reduced + } + + def createTestOut( + ctx: BlockchainContext, + amount: Long, + contract: ErgoTree, + registers: Constant[_]* + ): OutBox = { + val out = UnsignedTransactionBuilder(ctx).outBoxBuilder + .value(amount) + .contract(contract) + .update(b => if (registers.isEmpty) b else b.registers(registers: _*)) + .build() + out + } + + def createInput(ctx: BlockchainContext, owner: Signer): ExtendedInputBox = { + val out = createTestOut(ctx, 1.erg, owner.masterAddress.script) + out.convertToInputWith(mockTxId, 0) + } + + property("Signing workflow") { + val cosigners = Seq(alice, bob, carol) + val inputs = cosigners.map(createInput(ctx, _)) + val reduced = createReducedTx(ctx, inputs, alice.masterAddress, david.masterAddress) + + reduced shouldNot be(null) + + // none of cosigners can sign the transaction + cosigners.foreach(signer => + assertExceptionThrown( + signer.prover.signReduced(reduced), + exceptionLike[IllegalArgumentException]("Tree root should be real but was UnprovenSchnorr") + ) + ) + + val addressBook = AddressBook(cosigners :+ david: _*) + cosigners.foreach(s => + addressBook.get(s.masterAddress) shouldBe Some(s) + ) + + val server = new CosigningServer + + // anyone can start a session (e.g. Alice) + val sessionId = server.addSession(alice.startCosigning(reduced)) + + // each cosigner generates a commitment and stores it in the session + cosigners.zipWithIndex.foreach { case (signer, i) => + val signerPk = signer.pubkey + + // participants can retrieve related sessions + val session = server.getSessionsFor(signer.allKeys).head + session.reduced shouldBe reduced + + // obtain next actions for the current session state + val actions = signer.getActionsFrom(session) + val expectedAction = CreateCommitment(signerPk, i, PositionedLeaf.at()(signerPk)) + actions shouldBe Seq(expectedAction) + + // then execute actions to obtain a new session state + val newSession = signer.execute(actions.head, session) + + val HintsBag(Seq(rc @ RealCommitment(image, _, position))) = newSession.collectedHints(actions.head.inputIndex) + image shouldBe signerPk + position shouldBe expectedAction.leaf.position + + // signer should store commitments (including secret random values) + val proposition = session.reduced.inputPropositions(i) + val Seq(OwnCommitment(`signerPk`, _, _, `position`), `rc`) = signer.getHintsBag(sessionId, proposition).get.hints + + server.updateSession(newSession) + } + + // each signer added a commitment to the session + server.getSession(sessionId).get.collectedHints.size shouldBe cosigners.size + + + // each cosigner generates a proof and stores it in the session + cosigners.zipWithIndex.foreach { case (signer, i) => + val signerPk = signer.pubkey + val session = server.getSessionsFor(signer.allKeys).head + val actions = signer.getActionsFrom(session) + + val expectedAction = CreateSignature(signerPk, i, PositionedLeaf.at()(signerPk)) + actions shouldBe Seq(expectedAction) + val newSession = signer.execute(actions.head, session) + + server.updateSession(newSession) + } + + // complete signing of the ready session + val session = server.getReadySessions.head + val signedTx = publicSigner.createSignedTransaction(session) + + // send the signed transaction to the network and wait for confirmation + // then finish the session + server.finishSession(session.id) + } +} + + diff --git a/sdk/shared/src/test/scala/org/ergoplatform/sdk/wallet/utils/Generators.scala b/sdk/shared/src/test/scala/org/ergoplatform/sdk/wallet/utils/Generators.scala index e0672d4a3d..e4cc09c4a2 100644 --- a/sdk/shared/src/test/scala/org/ergoplatform/sdk/wallet/utils/Generators.scala +++ b/sdk/shared/src/test/scala/org/ergoplatform/sdk/wallet/utils/Generators.scala @@ -17,7 +17,6 @@ import sigmastate.eval.Extensions._ import scorex.util.{ModifierId, bytesToId} import sigmastate.eval._ import sigmastate.helpers.TestingHelpers._ -import scorex.crypto.hash.Digest32 import sigmastate.crypto.CryptoFacade trait Generators {