diff --git a/appkit/src/main/scala/org/ergoplatform/appkit/multisig/Signing.scala b/appkit/src/main/scala/org/ergoplatform/appkit/multisig/Signing.scala new file mode 100644 index 00000000..dcb3e822 --- /dev/null +++ b/appkit/src/main/scala/org/ergoplatform/appkit/multisig/Signing.scala @@ -0,0 +1,93 @@ +package org.ergoplatform.appkit.multisig + +import org.ergoplatform.P2PKAddress +import org.ergoplatform.appkit.{SigmaProp, ReducedErgoLikeTransaction, AppkitProvingInterpreter, ErgoProver, ReducedTransaction} +import scorex.util.ModifierId +import sigmastate.interpreter.Hint + +import scala.collection.mutable + +/** Participant of a multisig workflow. */ +case class Participant(address: P2PKAddress) { + +} + +case class Signature(hints: Seq[Hint]) { +} + +/** + * The SigningSession class represents a signing session for a specific transaction. + * It keeps track of collected signatures and provides methods for adding signatures and + * checking the completeness of the session. + * + * @param reducedTx the transaction being signed. + */ +class SigningSession(reducedTx: ReducedErgoLikeTransaction) { + /** Participants who can co-sign the transaction to provide enough signatures for all + * the reducedInputs. + */ + def participants: Seq[Participant] = ??? + + private var signatures: List[Signature] = List.empty + + def addSignature(signature: Signature): Unit = { + signatures = signature :: signatures + } + + def getSignatures: List[Signature] = signatures + + def isComplete: Boolean = ??? +} + +/** The SigningServer class represents a server that manages multiple signing sessions. + * It provides methods for creating and retrieving signing sessions. + */ +class SigningServer { + private val sessions = mutable.HashMap.empty[ModifierId, SigningSession] + + /** Creates a new signing session for the given transaction. + * + * @param reducedTx transaction to sign + */ + def createSession( + reducedTx: ReducedErgoLikeTransaction): SigningSession = { + val session = new SigningSession(reducedTx) + sessions.put(reducedTx.unsignedTx.id, session) + session + } + + /** Retrieves a signing session for the given transaction. + * + * @param modifierId id of the transaction to sign + */ + def getSession(modifierId: ModifierId): Option[SigningSession] = sessions.get(modifierId) +} + +/** Represents a participant in a multisig signing process. + * It provides methods for interacting with the SigningServer, such as initializing multisig sessions, + * adding partial signatures, listing collected signatures, and checking session completeness. + * + * @param server The SigningServer instance that the client interacts with. + */ +class Client(server: SigningServer, I: AppkitProvingInterpreter) { + /** Adds secrets from the given prover */ + def addSecrets(prover: ErgoProver): Unit = ??? + + def initMultisigSession(tx: ReducedErgoLikeTransaction): SigningSession = { + server.createSession(tx) + } + + def addPartialSignature(tx: ReducedErgoLikeTransaction): Unit = { + val signature: Signature = Signature(Seq.empty) + server.getSession(tx.unsignedTx.id).foreach(_.addSignature(signature)) + } + + def listSignatures(modifierId: ModifierId): List[Signature] = { + server.getSession(modifierId).map(_.getSignatures).getOrElse(List.empty) + } + + def isComplete(modifierId: ModifierId): Boolean = { + server.getSession(modifierId).exists(_.isComplete) + } +} + diff --git a/appkit/src/test/scala/org/ergoplatform/appkit/multisig/SigningSpec.scala b/appkit/src/test/scala/org/ergoplatform/appkit/multisig/SigningSpec.scala new file mode 100644 index 00000000..45aeeef2 --- /dev/null +++ b/appkit/src/test/scala/org/ergoplatform/appkit/multisig/SigningSpec.scala @@ -0,0 +1,14 @@ +package org.ergoplatform.appkit.multisig + +import org.scalatest.propspec.AnyPropSpec +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks +import org.scalatest.matchers.should.Matchers +import org.ergoplatform.appkit.testing.AppkitTesting + +class SigningSpec extends AnyPropSpec with Matchers with ScalaCheckDrivenPropertyChecks + with AppkitTesting { + + property("Signing workflow") { + val server = new SigningServer + } +} diff --git a/common/src/main/java/org/ergoplatform/appkit/scalaapi/Utils.scala b/common/src/main/java/org/ergoplatform/appkit/scalaapi/Utils.scala index 164eafab..15358f50 100644 --- a/common/src/main/java/org/ergoplatform/appkit/scalaapi/Utils.scala +++ b/common/src/main/java/org/ergoplatform/appkit/scalaapi/Utils.scala @@ -1,5 +1,6 @@ package org.ergoplatform.appkit.scalaapi +import debox.cfor import scalan.ExactIntegral import scala.collection.mutable @@ -121,4 +122,16 @@ object Utils { def parseString(str: String): Option[A] = throw new NotImplementedError("operation is not supported") } + + implicit val byteArrayOrdering: Ordering[Array[Byte]] = new Ordering[Array[Byte]] { + override def compare(x: Array[Byte], y: Array[Byte]): Int = { + val len = math.min(x.length, y.length) + cfor(0)(_ < len, _ + 1) { i => + val cmp = (x(i) & 0xFF) - (y(i) & 0xFF) + if (cmp != 0) + return cmp + } + x.length - y.length + } + } } diff --git a/common/src/main/java/org/ergoplatform/appkit/scalaapi/package.scala b/common/src/main/java/org/ergoplatform/appkit/scalaapi/package.scala index 32ca22a2..3245e574 100644 --- a/common/src/main/java/org/ergoplatform/appkit/scalaapi/package.scala +++ b/common/src/main/java/org/ergoplatform/appkit/scalaapi/package.scala @@ -1,6 +1,8 @@ package org.ergoplatform.appkit import scalan.RType +import sigmastate.Values.{SigmaBoolean, ErgoTree, SigmaPropConstant} +import sigmastate.eval.SigmaDsl import special.sigma import special.sigma.{Header, Box, GroupElement, AvlTree, PreHeader} @@ -36,4 +38,20 @@ package object scalaapi { implicit val headerType: ErgoType[Header] = ErgoType.headerType() implicit val preHeaderType: ErgoType[sigma.PreHeader] = ErgoType.preHeaderType() + /** Extractors of SigmaBoolean value to be use in Scala pattern matching. */ + object SigmaProp { + def unapply(prop: special.sigma.SigmaProp): Option[SigmaBoolean] = Some(SigmaDsl.toSigmaBoolean(prop)) + } + + /** Extractor of SigmaBoolean from Pay-to-SigmaProp tree, which is the spedial case of the tree. */ + object Pay2SigmaProp { + def unapply(ergoTree: ErgoTree): Option[SigmaBoolean] = { + // get ErgoTree expression respecting constant segregation flag + val prop = ergoTree.toProposition(ergoTree.isConstantSegregation) + prop match { + case SigmaPropConstant(SigmaProp(sb)) => Some(sb) + case _ => None + } + } + } } diff --git a/common/src/test/scala/org/ergoplatform/appkit/scalaapi/ByteArrayOrderingSpec.scala b/common/src/test/scala/org/ergoplatform/appkit/scalaapi/ByteArrayOrderingSpec.scala new file mode 100644 index 00000000..17a70117 --- /dev/null +++ b/common/src/test/scala/org/ergoplatform/appkit/scalaapi/ByteArrayOrderingSpec.scala @@ -0,0 +1,48 @@ +package org.ergoplatform.appkit.scalaapi + +import org.scalacheck.{Gen, Arbitrary} +import org.ergoplatform.appkit.TestingBase +import org.scalacheck.Prop.{propBoolean} + + +class ByteArrayOrderingSpec extends TestingBase { + + implicit val arbitraryByteArray: Arbitrary[Array[Byte]] = Arbitrary(Gen.containerOf[Array, Byte](Arbitrary.arbByte.arbitrary)) + val ord = Utils.byteArrayOrdering + + property("byteArrayOrdering should satisfy required properties") { + forAll { (a: Array[Byte], b: Array[Byte], c: Array[Byte]) => + + + // reflexivity + propBoolean(ord.compare(a, a) == 0) + // antisymmetry + .&&((ord.compare(a, b) <= 0 && ord.compare(b, a) <= 0) ==> (a sameElements b)) + .&&((ord.compare(a, b) >= 0 && ord.compare(b, a) >= 0) ==> (a sameElements b)) + + // transitivity + .&&((ord.compare(a, b) <= 0 && ord.compare(b, c) <= 0) ==> (ord.compare(a, c) <= 0)) + .&&((ord.compare(a, b) >= 0 && ord.compare(b, c) >= 0) ==> (ord.compare(a, c) >= 0)) + } + } + + property("it should compare arrays with different lengths correctly") { + forAll { (a: Array[Byte], b: Array[Byte]) => + (a.length != b.length) ==> (ord.compare(a, b) != 0) + } + } + + property("it should compare arrays with equal elements correctly") { + forAll { (a: Array[Byte], b: Array[Byte]) => + val commonPrefix = a.zip(b).takeWhile { case (x, y) => x == y }.map(_._1) + val longer = a.length > b.length + val shorter = a.length < b.length + + (a startsWith commonPrefix) && (b startsWith commonPrefix) ==> ( + (longer && ord.compare(a, b) > 0) || + (shorter && ord.compare(a, b) < 0) || + (ord.compare(a, b) == 0) + ) + } + } +} diff --git a/lib-api/src/main/java/org/ergoplatform/appkit/MultisigAddress.java b/lib-api/src/main/java/org/ergoplatform/appkit/MultisigAddress.java new file mode 100644 index 00000000..4beef160 --- /dev/null +++ b/lib-api/src/main/java/org/ergoplatform/appkit/MultisigAddress.java @@ -0,0 +1,51 @@ +package org.ergoplatform.appkit; + +import org.ergoplatform.appkit.impl.MultisigAddressImpl; + +import java.util.List; + +/** + * An EIP-42 compliant multisig address + */ +public abstract class MultisigAddress { + + /** + * @return address for this multisig address + */ + public abstract Address getAddress(); + + /** + * @return list of participating p2pk addresses + */ + public abstract List
getParticipants(); + + /** + * @return number of signers required to sign a transaction for this address + */ + public abstract int getSignersRequiredCount(); + + /** Default value for tree header flags (0 means no flags). */ + public static byte DEFAULT_TREE_HEADER_FLAGS = (byte)0; + + /** + * constructs a k-out-of-n (threshold signature) address from the list of participants + * and the number of required signers + * + * @param signersRequired number k, signers required to sign a transaction for this address + * @param particpants list of p2pk addresses of possible signers + * @return MultisigAddress interface + */ + public static MultisigAddress buildFromParticipants(int signersRequired, List
particpants, NetworkType networkType) { + return MultisigAddressImpl.fromParticipants(signersRequired, particpants, networkType, DEFAULT_TREE_HEADER_FLAGS); + } + + /** + * + * @param address multisig address to construct class for + * @return MultisigAddress if the given address is an EIP-42 compliant multisig address + * @throws IllegalArgumentException if given address is NOT an EIP-42 compliant multisig address + */ + public static MultisigAddress buildFromAddress(Address address) { + return MultisigAddressImpl.fromAddress(address); + } +} diff --git a/lib-api/src/main/java/org/ergoplatform/appkit/MultisigTransaction.java b/lib-api/src/main/java/org/ergoplatform/appkit/MultisigTransaction.java new file mode 100644 index 00000000..87bb0f40 --- /dev/null +++ b/lib-api/src/main/java/org/ergoplatform/appkit/MultisigTransaction.java @@ -0,0 +1,66 @@ +package org.ergoplatform.appkit; + +import java.util.List; + +/** + * EIP-41/EIP-11 compliant multi sig transaction + */ +public abstract class MultisigTransaction { + + /** + * @return transaction that is going to be signed + */ + abstract public ReducedTransaction getTransaction(); + + /** + * adds a new hint to this multisig transaction + * @param prover to add commitment for + */ + abstract public void addHint(ErgoProver prover); + + /** + * adds the hints not present on this instance from another multisig transaction instance + * for the same transaction. + */ + abstract public void mergeHints(MultisigTransaction other); + + /** + * adds the hints not present on this instance from the EIP-11 json + */ + abstract public void mergeHints(String json); + + /** + * @return list of participants that added a hint for the transaction + */ + abstract public List
getCommitingParticipants(); + /** + * @return true if SignedTransaction can be built + */ + abstract public boolean isHintBagComplete(); + + /** + * @return the signed transaction if enough commitments are available + * @throws IllegalStateException if {@link #isHintBagComplete()} is false + */ + abstract public SignedTransaction toSignedTransaction(); + + /** + * @return EIP-11 compliant json string to transfer the partially signed transaction to the + * next participant + */ + abstract public String hintsToJson(); + + /** + * constructs a multi sig transaction from a reduced transaction + */ + public static MultisigTransaction fromTransaction(ReducedTransaction transaction) { + throw new UnsupportedOperationException(); + } + + /** + * constructs a multi sig transaction from EIP-11 json string + */ + public static MultisigTransaction fromJson(String json) { + throw new UnsupportedOperationException(); + } +} diff --git a/lib-api/src/main/java/org/ergoplatform/appkit/ReducedTransaction.java b/lib-api/src/main/java/org/ergoplatform/appkit/ReducedTransaction.java index 0f0fb2ad..6350784f 100644 --- a/lib-api/src/main/java/org/ergoplatform/appkit/ReducedTransaction.java +++ b/lib-api/src/main/java/org/ergoplatform/appkit/ReducedTransaction.java @@ -21,4 +21,9 @@ public interface ReducedTransaction extends Transaction { * Returns the serialized bytes of this transaction. */ byte[] toBytes(); + + /** + * @return tree of participants required or able to sign for the transaction + */ + SigningParticipants getSignersRequired(); } diff --git a/lib-api/src/main/java/org/ergoplatform/appkit/SigningParticipants.java b/lib-api/src/main/java/org/ergoplatform/appkit/SigningParticipants.java new file mode 100644 index 00000000..2f229b82 --- /dev/null +++ b/lib-api/src/main/java/org/ergoplatform/appkit/SigningParticipants.java @@ -0,0 +1,45 @@ +package org.ergoplatform.appkit; + +import java.util.List; + +/** + * Participants able to sign for a {@link ReducedTransaction} + */ +abstract public class SigningParticipants { + + /** + * @return true if multiple signers are needed + */ + abstract public boolean isMultisig(); + + /** + * Multisig requirement, k-out-of-n + */ + public static class MultisigRequirement extends SigningParticipants { + public final int hintsNeeded; + public final List hintbag; + + public MultisigRequirement(int hintsNeeded, List hintbag) { + this.hintsNeeded = hintsNeeded; + this.hintbag = hintbag; + } + + @Override + public boolean isMultisig() { + return hintsNeeded > 1; + } + } + + public static class SingleSigner extends SigningParticipants { + public final Address address; + + public SingleSigner(Address address) { + this.address = address; + } + + @Override + public boolean isMultisig() { + return false; + } + } +} diff --git a/lib-api/src/main/java/org/ergoplatform/appkit/impl/MultisigAddressImpl.scala b/lib-api/src/main/java/org/ergoplatform/appkit/impl/MultisigAddressImpl.scala new file mode 100644 index 00000000..a51ccfc4 --- /dev/null +++ b/lib-api/src/main/java/org/ergoplatform/appkit/impl/MultisigAddressImpl.scala @@ -0,0 +1,81 @@ +package org.ergoplatform.appkit.impl + +import org.ergoplatform.appkit.scalaapi.Pay2SigmaProp +import org.ergoplatform.{Pay2SAddress, P2PKAddress} +import org.ergoplatform.appkit.{Address, NetworkType, MultisigAddress} +import sigmastate.CTHRESHOLD +import sigmastate.Values.ErgoTree +import sigmastate.utils.Helpers.TryOps + +import java.util +import scala.collection.JavaConverters._ +import scala.util.Try + +case class MultisigAddressImpl( + numRequiredSigners: Int, participants: Seq[P2PKAddress], + networkType: NetworkType, treeHeaderFlags: Byte) extends MultisigAddress { + val threshold = CTHRESHOLD(numRequiredSigners, participants.map(_.pubkey)) + + override def getAddress: Address = { + // create ErgoTree without segregated constants + val tree = ErgoTree.fromSigmaBoolean(treeHeaderFlags, threshold) + Address.fromErgoTree(tree, networkType) + } + + override def getParticipants: util.List[Address] = participants.map(new Address(_)).asJava + + override def getSignersRequiredCount: Int = numRequiredSigners +} + +object MultisigAddressImpl { + /** Creates a multisignature address from a list of participant addresses. + * @param numRequiredSigners the minimum number of signers required to spend funds from the address + * @param participants the list of participant addresses + * @param networkType the network type on which the address is used + * @param treeHeaderFlags optional flags to put in the header of the underlying ErgoTree (no flags by default) + * @return a new multisignature address + */ + def fromParticipants( + numRequiredSigners: Int, participants: java.util.List[Address], + networkType: NetworkType, treeHeaderFlags: Byte = MultisigAddress.DEFAULT_TREE_HEADER_FLAGS): MultisigAddress = { + import org.ergoplatform.appkit.scalaapi.Utils.byteArrayOrdering + val p2pkAddresses = participants.asScala + .map { a => val pk = a.asP2PK(); (pk, pk.pubkey.pkBytes) } + .sortBy(_._2) + .map(_._1).toSeq + new MultisigAddressImpl(numRequiredSigners, p2pkAddresses, networkType, treeHeaderFlags) + } + + /** Attempts to create a multi-signature address from an existing address. + * + * @param address the address to convert to a multisig address + * + * @return a [[Try]] that wraps a MultisigAddress if successful, or an exception if the address is not a valid multisig address + */ + def fromAddressTry(address: Address): Try[MultisigAddress] = Try { + address.getErgoAddress match { + case p2s: Pay2SAddress => + p2s.script match { + case Pay2SigmaProp(CTHRESHOLD(k, participants)) => + fromParticipants( + numRequiredSigners = k, + participants = participants.map(Address.fromSigmaBoolean(_, address.getNetworkType)).asJava, + networkType = address.getNetworkType) + + case _ => + throw new IllegalArgumentException( + s"P2S address $address is not a valid multisig address") + } + case _ => + throw new IllegalArgumentException( + s"Address $address is not a P2S address and cannot be multisig address") + } + } + + /** Creates a multi-signature address from an existing address. + * @param address the address to convert to a multisig address + * @return a MultisigAddress object + * @throws IllegalArgumentException if the address is not a valid multisig address + */ + def fromAddress(address: Address): MultisigAddress = fromAddressTry(address).getOrThrow +} diff --git a/lib-impl/src/main/java/org/ergoplatform/appkit/impl/ReducedTransactionImpl.java b/lib-impl/src/main/java/org/ergoplatform/appkit/impl/ReducedTransactionImpl.java index a1d7adba..a723e233 100644 --- a/lib-impl/src/main/java/org/ergoplatform/appkit/impl/ReducedTransactionImpl.java +++ b/lib-impl/src/main/java/org/ergoplatform/appkit/impl/ReducedTransactionImpl.java @@ -68,6 +68,11 @@ public byte[] toBytes() { return w.toBytes(); } + @Override + public SigningParticipants getSignersRequired() { + throw new UnsupportedOperationException(); + } + @Override public int hashCode() { return _tx.hashCode(); diff --git a/lib-impl/src/test/java/org/ergoplatform/appkit/impl/MultisigAddressTests.scala b/lib-impl/src/test/java/org/ergoplatform/appkit/impl/MultisigAddressTests.scala new file mode 100644 index 00000000..eaddc790 --- /dev/null +++ b/lib-impl/src/test/java/org/ergoplatform/appkit/impl/MultisigAddressTests.scala @@ -0,0 +1,80 @@ +package org.ergoplatform.appkit.impl + +import org.ergoplatform.appkit.{Address, NetworkType, MultisigAddress} +import org.scalacheck.Gen +import org.scalatest.matchers.should.Matchers +import org.scalatest.propspec.AnyPropSpec +import sigmastate.serialization.generators.ObjectGenerators +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks +import sigmastate.CTHRESHOLD +import sigmastate.basics.DLogProtocol.ProveDlog +import sigmastate.helpers.NegativeTesting + +import scala.collection.JavaConverters._ + +class MultisigAddressTests extends AnyPropSpec with Matchers with ScalaCheckDrivenPropertyChecks + with ObjectGenerators with NegativeTesting { + import org.ergoplatform.appkit.scalaapi.Utils.byteArrayOrdering + + /** Generate CTHRESHOLD with sorted children. */ + lazy val thresholdGen = for { + n <- Gen.choose(1, 20) + k <- Gen.choose(1, n) + children <- Gen.listOfN(n, proveDlogGen) + } yield CTHRESHOLD(k, children.sortBy(_.pkBytes)) + + property("getAddress/fromAddress roundtrip") { + // do create a multisig address, convert to Address and back + forAll(thresholdGen, MinSuccessful(50)) { threshold: CTHRESHOLD => + val multisigAddress = MultisigAddress.buildFromParticipants( + threshold.k, + threshold.children.map(Address.fromSigmaBoolean(_, NetworkType.MAINNET)).asJava, + NetworkType.MAINNET) + + multisigAddress.getSignersRequiredCount shouldBe threshold.k + multisigAddress.getParticipants.asScala.map(_.asP2PK().pubkey) shouldBe threshold.children.map(_.asInstanceOf[ProveDlog]) + + // check roundtrip + val address = multisigAddress.getAddress + val multisigAddress2 = MultisigAddress.buildFromAddress(address) + multisigAddress2 shouldBe multisigAddress + } + } + + property("address order stability") { + // do create a multisig address, convert to Address and back + forAll(thresholdGen, MinSuccessful(50)) { threshold: CTHRESHOLD => + val addresses = threshold.children.map(Address.fromSigmaBoolean(_, NetworkType.MAINNET)) + val multisigAddress = MultisigAddress.buildFromParticipants( + threshold.k, + addresses.asJava, + NetworkType.MAINNET) + + val multisigAddress2 = MultisigAddress.buildFromParticipants( + threshold.k, + addresses.reverse.asJava, + NetworkType.MAINNET) + + multisigAddress.getAddress shouldBe multisigAddress2.getAddress + } + } + + property("fromAddress negative") { + // check that fromAddress fails on non-P2S addresses + forAll(proveDlogGen) { dlog: ProveDlog => + val address = Address.fromSigmaBoolean(dlog, NetworkType.MAINNET) + assertExceptionThrown( + MultisigAddress.buildFromAddress(address), + exceptionLike[IllegalArgumentException]("is not a P2S address and cannot be multisig address") + ) + } + + { // check that fromAddress fails on non-Pay2SigmaProp addresses + val address = Address.fromErgoTree(TrueTree, NetworkType.MAINNET) + assertExceptionThrown( + MultisigAddress.buildFromAddress(address), + exceptionLike[IllegalArgumentException]("is not a valid multisig address") + ) + } + } +}