diff --git a/build.gradle.kts b/build.gradle.kts index 434893c..3fb14c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,7 +22,7 @@ allprojects { } group = "org.onflow.flow" - val defaultVersion = "0.0.11" + val defaultVersion = "0.0.12" version = System.getenv("GITHUB_REF")?.split('/')?.last() ?: defaultVersion } diff --git a/flow/src/androidMain/kotlin/org/onflow/flow/crypto/Crypto.kt b/flow/src/androidMain/kotlin/org/onflow/flow/crypto/Crypto.kt index ac1f43c..9c065e4 100644 --- a/flow/src/androidMain/kotlin/org/onflow/flow/crypto/Crypto.kt +++ b/flow/src/androidMain/kotlin/org/onflow/flow/crypto/Crypto.kt @@ -87,7 +87,7 @@ actual object Crypto { var publicKey = PublicKey( publicKey = pk, algo = algo, - hex = jsecPrivateKeyToHexString(sk, curveFieldSize) + hex = jsecPublicKeyToHexString(pk, curveFieldSize) ) return PrivateKey( @@ -136,7 +136,7 @@ actual object Crypto { // x and y must be guaranteed to be less than curveFieldSize each at this point xBytes.copyInto(paddedPKBytes, max(curveFieldSize - xBytes.size, 0), max(xBytes.size - curveFieldSize, 0)) yBytes.copyInto(paddedPKBytes, curveFieldSize + max(curveFieldSize - yBytes.size, 0), max(yBytes.size - curveFieldSize, 0)) - (xBytes + yBytes).bytesToHex() + paddedPKBytes.bytesToHex() } else { throw IllegalArgumentException("PublicKey must be an ECPublicKey") } @@ -188,8 +188,9 @@ actual object Crypto { @JvmStatic fun formatSignature(r: BigInteger, s: BigInteger, curveOrderSize: Int): ByteArray { val paddedSignature = ByteArray(2 * curveOrderSize) - val rBytes = r.toByteArray() - val sBytes = s.toByteArray() + // Handle BigInteger.ZERO case specifically to match RLP encoding behavior + val rBytes = if (r == BigInteger.ZERO) byteArrayOf() else r.toByteArray() + val sBytes = if (s == BigInteger.ZERO) byteArrayOf() else s.toByteArray() // occasionally R/S bytes representation has leading zeroes, so make sure to copy them appropriately rBytes.copyInto( diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/models/Transaction.kt b/flow/src/commonMain/kotlin/org/onflow/flow/models/Transaction.kt index da4132a..75b07b1 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/models/Transaction.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/models/Transaction.kt @@ -2,6 +2,7 @@ package org.onflow.flow.models import org.onflow.flow.infrastructure.BigIntegerCadenceSerializer import org.onflow.flow.infrastructure.Cadence +import org.onflow.flow.infrastructure.removeHexPrefix import org.onflow.flow.rlp.* import com.ionspin.kotlin.bignum.integer.BigInteger import com.ionspin.kotlin.bignum.integer.toBigInteger @@ -82,133 +83,360 @@ data class Transaction( @SerialName(value = "_links") val links: Links? = null ) { - val signers: Map - get() = listOf(listOf(proposalKey.address, payer), authorizers) - .flatten() - .toSet() - .mapIndexed { index, item -> - item to index - } - .toMap() - private fun findSigners(address: String, signers: List): List { + // Normalize addresses by removing hex prefix for comparison + val normalizedAddress = address.removeHexPrefix() return signers.filter { signer -> - signer.address == address + signer.address.removeHexPrefix() == normalizedAddress } } - suspend fun signPayload(signers: List): Transaction { - val payloadMessage = payloadMessage() + private suspend fun signPayload(signers: List): Transaction { val payloadSignatures = mutableListOf() - - // Sign with the proposal key first. - // If proposer is same as payer, we skip this step - if (proposalKey.address != payer) { + val signedAddresses = mutableSetOf() + + // Flow signature ordering rule: proposer first, then authorizers in order + // Step 1: Proposer signs first (if proposer is not already an authorizer) + val proposerAddress = proposalKey.address.removeHexPrefix() + val authorizerAddresses = authorizers.map { it.removeHexPrefix() } + + // If proposer is NOT in authorizers, they sign first + if (!authorizerAddresses.contains(proposerAddress)) { val signerList = findSigners(proposalKey.address, signers) for (signUser in signerList) { - val signature = signUser.sign(payloadMessage) + // Use payloadMessage() which already includes domain tags + val messageToSign = payloadMessage() + val signature = signUser.sign(messageToSign) val txSignature = TransactionSignature( address = signUser.address, keyIndex = signUser.keyIndex, - signature = signature.toHexString(), - signerIndex = this.signers[signUser.address] ?: -1 + signature = signature.toHexString() ) payloadSignatures.add(txSignature) + signedAddresses.add(signUser.address.removeHexPrefix()) } } - // Sign the transaction with each authorizer + // Step 2: Authorizers sign in the order they appear in the authorizers list for (authorizer in authorizers) { - if (proposalKey.address == authorizer || payer == authorizer) { - continue - } - - val signerList = findSigners(authorizer, signers) - for (signUser in signerList) { - val signature = signUser.sign(payloadMessage) - val txSignature = TransactionSignature( - address = signUser.address, - keyIndex = signUser.keyIndex, - signature = signature.toHexString(), - signerIndex = this.signers[signUser.address] ?: -1 - ) - payloadSignatures.add(txSignature) + val authorizerAddress = authorizer.removeHexPrefix() + // Skip if already signed (deduplication for proposer who is also authorizer) + if (!signedAddresses.contains(authorizerAddress)) { + val signerList = findSigners(authorizer, signers) + for (signUser in signerList) { + // Use payloadMessage() which already includes domain tags + val messageToSign = payloadMessage() + val signature = signUser.sign(messageToSign) + val txSignature = TransactionSignature( + address = signUser.address, + keyIndex = signUser.keyIndex, + signature = signature.toHexString() + ) + payloadSignatures.add(txSignature) + signedAddresses.add(signUser.address.removeHexPrefix()) + } } } - payloadSignatures.sortWith(CompareTransactionSignature) return copy(payloadSignatures = payloadSignatures) } - suspend fun signEnvelope(signers: List): Transaction { - val envelopeMessage = envelopeMessage() + private suspend fun signEnvelope(signers: List): Transaction { val envelopeSignatures = mutableListOf() - // Sign the transaction with payer + // Sign the transaction with payer(s) - order should match payer list val signerList = findSigners(payer, signers) for (signUser in signerList) { - val signature = signUser.sign(envelopeMessage) + // Use envelopeMessage() which already includes domain tags + val messageToSign = envelopeMessage() + val signature = signUser.sign(messageToSign) val txSignature = TransactionSignature( address = signUser.address, keyIndex = signUser.keyIndex, - signature = signature.toHexString(), - //signerIndex = this.signers[signUser.address] ?: -1 + signature = signature.toHexString() ) envelopeSignatures.add(txSignature) } - envelopeSignatures.sortWith(CompareTransactionSignature) return copy(envelopeSignatures = envelopeSignatures) } suspend fun sign(signers: List): Transaction { - return signPayload(signers).signEnvelope(signers) + // Check if this is a single-signer transaction + val allSignerAddresses = signers.map { it.address.removeHexPrefix() }.toSet() + val proposerAddress = proposalKey.address.removeHexPrefix() + val payerAddress = payer.removeHexPrefix() + val authorizerAddresses = authorizers.map { it.removeHexPrefix() }.toSet() + + val allRoleAddresses = setOf(proposerAddress, payerAddress) + authorizerAddresses + + // If all roles are performed by the same single account, only sign envelope + if (allRoleAddresses.size == 1 && allSignerAddresses.size == 1 && allRoleAddresses == allSignerAddresses) { + println("Single-signer transaction detected - only signing envelope") + return signEnvelope(signers) + } else { + println("Multi-signer transaction detected - signing both payload and envelope") + return signPayload(signers).signEnvelope(signers) + } + } + + /** + * Add a payload signature to the transaction + */ + suspend fun addPayloadSignature(address: String, keyIndex: Int, signer: Signer): Transaction { + // Use payloadMessage() which already includes domain tags + val messageToSign = payloadMessage() + val signature = signer.sign(messageToSign) + val txSignature = TransactionSignature( + address = address, + keyIndex = keyIndex, + signature = signature.toHexString() + ) + // DO NOT SORT - preserve the order signatures are added + val newPayloadSignatures = payloadSignatures + txSignature + return copy(payloadSignatures = newPayloadSignatures) + } + + /** + * Add an envelope signature to the transaction + */ + suspend fun addEnvelopeSignature(address: String, keyIndex: Int, signer: Signer): Transaction { + // Use envelopeMessage() which already includes domain tags + val messageToSign = envelopeMessage() + val signature = signer.sign(messageToSign) + val txSignature = TransactionSignature( + address = address, + keyIndex = keyIndex, + signature = signature.toHexString() + ) + // Preserve the order signatures are added + val newEnvelopeSignatures = envelopeSignatures + txSignature + return copy(envelopeSignatures = newEnvelopeSignatures) } } +/** + * Create payload that exactly matches Flow JVM SDK structure + * + * JVM SDK Payload structure: + * @RLP(0) script: ByteArray + * @RLP(1) arguments: List + * @RLP(2) referenceBlockId: ByteArray + * @RLP(3) gasLimit: Long + * @RLP(4) proposalKeyAddress: ByteArray + * @RLP(5) proposalKeyIndex: Long + * @RLP(6) proposalKeySequenceNumber: Long + * @RLP(7) payer: ByteArray + * @RLP(8) authorizers: List + */ fun Transaction.payload(): List = listOf( - script.toRLP(), - RLPList(arguments.map { it.encode().toByteArray().toRLP() }), - hex(referenceBlockId).toRLP(), - gasLimit.toRLP(), - hex(proposalKey.address).paddingZeroLeft().toRLP(), - proposalKey.keyIndex.toRLP(), - proposalKey.sequenceNumber.toRLP(), - hex(payer).paddingZeroLeft().toRLP(), - RLPList(authorizers.map { hex(it).paddingZeroLeft().toRLP() }) + script.toByteArray().toRLP(), // ByteArray + RLPList(arguments.map { it.encode().toByteArray().toRLP() }), // List + hex(referenceBlockId).paddingZeroLeft(32).toRLP(), // Pad to 32 bytes + gasLimit.intValue().toRLP(), // Convert BigInteger to Int + hex(proposalKey.address).paddingZeroLeft().toRLP(), // ByteArray (8 bytes) + proposalKey.keyIndex.toRLP(), // Int (compatible with Long) + proposalKey.sequenceNumber.intValue().toRLP(), // Convert BigInteger to Int + hex(payer).paddingZeroLeft().toRLP(), // ByteArray (8 bytes) + RLPList(authorizers.map { hex(it).paddingZeroLeft().toRLP() }) // List ) -fun Transaction.toRLP(): RLPElement = payload().toRLP() - +/** + * Create the payload message for signing + */ fun Transaction.payloadMessage(): ByteArray = - DomainTag.Transaction.bytes + - (RLPList( - listOf( - RLPList(payload()), - RLPList( - payloadSignatures.map { - listOf((signers[it.address] ?: -1).toRLP(), it.keyIndex.toRLP(), hex(it.signature).toRLP()).toRLP() - } - ) - ) - )).encode() + DomainTag.Transaction.bytes + RLPList(payload()).encode() +/** + * Create the envelope message for signing + */ fun Transaction.envelopeMessage(): ByteArray = - DomainTag.Transaction.bytes + - RLPList( - listOf( - RLPList(payload()), - RLPList( - payloadSignatures.map { - listOf( - (signers[it.address] ?: -1).toRLP(), - it.keyIndex.toRLP(), - hex(it.signature).toRLP() - ).toRLP() - } - ) - ) - ).encode() + DomainTag.Transaction.bytes + createEnvelopeRLPFlowJSStyle() + +/** + * Create the complete transaction envelope that includes both payload and envelope signatures + * This is what gets broadcast to the network, different from envelopeMessage() which is for signing + */ +fun Transaction.completeEnvelopeMessage(): ByteArray = + DomainTag.Transaction.bytes + createCompleteEnvelopeRLP() + +/** + * Helper function to create RLP-encoded envelope data for Flow JS SDK compatibility + * Structure: [payload, payloadSignatures] where signatures use signer indices + */ +internal fun Transaction.createEnvelopeRLPFlowJSStyle(): ByteArray { + val signerList = mutableListOf() + val seen = mutableSetOf() + + fun addSigner(address: String) { + val normalized = address.removeHexPrefix() + if (normalized !in seen) { + signerList.add(normalized) + seen.add(normalized) + } + } + + addSigner(proposalKey.address) + addSigner(payer) + authorizers.forEach { addSigner(it) } + + // Create signer index map + val signerIndexMap = signerList.withIndex().associate { it.value to it.index } + + // Convert payload signatures to indexed format and sort by signer index, then key index + val indexedPayloadSignatures = payloadSignatures.map { sig -> + val signerIndex = signerIndexMap[sig.address.removeHexPrefix()] ?: 0 + Triple(signerIndex, sig.keyIndex, sig.signature) + }.sortedWith(compareBy> { it.first }.thenBy { it.second }) + + val payloadSignaturesList = indexedPayloadSignatures.map { (signerIndex, keyIndex, signature) -> + // Create each signature as an RLP list: [signerIndex, keyIndex, signature] + RLPList(listOf( + signerIndex.toRLP(), + keyIndex.toRLP(), + hex(signature).toRLP() + )) + } + + return RLPList( + listOf( + RLPList(payload()), + RLPList(payloadSignaturesList) + ) + ).encode() +} + +/** + * Helper function to create RLP-encoded transaction data for signing + * @param includePayloadSignatures Whether to include existing payload signatures in the structure + */ +internal fun Transaction.createSigningRLP(includePayloadSignatures: Boolean = false): ByteArray { + val signaturesList = if (includePayloadSignatures) { + payloadSignatures.map { + listOf( + hex(it.address).paddingZeroLeft().toRLP(), + it.keyIndex.toRLP(), + hex(it.signature).toRLP() + ).toRLP() + } + } else { + emptyList() + } + + return RLPList( + listOf( + RLPList(payload()), + RLPList(signaturesList) + ) + ).encode() +} + +/** + * Alternative RLP encoding that matches Flow JVM SDK format exactly + * Uses signer indices instead of addresses in signature encoding + */ +internal fun Transaction.createSigningRLPJVMStyle(includePayloadSignatures: Boolean = false): ByteArray { + // Build signer list like JVM SDK + val signerList = mutableListOf() + val seen = mutableSetOf() + + // Add signers in order: proposer, payer, then authorizers + fun addSigner(address: String) { + val normalized = address.removeHexPrefix() + if (normalized !in seen) { + signerList.add(normalized) + seen.add(normalized) + } + } + + addSigner(proposalKey.address) + addSigner(payer) + authorizers.forEach { addSigner(it) } + + // Create signer index map + val signerIndexMap = signerList.withIndex().associate { it.value to it.index } + + val signaturesList = if (includePayloadSignatures) { + payloadSignatures.map { sig -> + val signerIndex = signerIndexMap[sig.address.removeHexPrefix()] ?: 0 + listOf( + signerIndex.toRLP(), + sig.keyIndex.toRLP(), + hex(sig.signature).toRLP() + ).toRLP() + } + } else { + emptyList() + } + + return RLPList( + listOf( + RLPList(payload()), + RLPList(signaturesList) + ) + ).encode() +} + +/** + * Helper function to create complete envelope RLP with both payload and envelope signatures + */ +internal fun Transaction.createCompleteEnvelopeRLP(): ByteArray { + // Build signer list + val signerList = mutableListOf() + val seen = mutableSetOf() + + // Add signers in order: proposer, payer, then authorizers + fun addSigner(address: String) { + val normalized = address.removeHexPrefix() + if (normalized !in seen) { + signerList.add(normalized) + seen.add(normalized) + } + } + + addSigner(proposalKey.address) + addSigner(payer) + authorizers.forEach { addSigner(it) } + + // Create signer index map + val signerIndexMap = signerList.withIndex().associate { it.value to it.index } + + // Convert payload signatures to indexed format and sort by signer index, then key index + val indexedPayloadSignatures = payloadSignatures.map { sig -> + val signerIndex = signerIndexMap[sig.address.removeHexPrefix()] ?: 0 + Triple(signerIndex, sig.keyIndex, sig.signature) + }.sortedWith(compareBy> { it.first }.thenBy { it.second }) + + val payloadSignaturesList = indexedPayloadSignatures.map { (signerIndex, keyIndex, signature) -> + RLPList(listOf( + signerIndex.toRLP(), + keyIndex.toRLP(), + hex(signature).toRLP() + )) + } + + // Convert envelope signatures to indexed format and sort by signer index, then key index + val indexedEnvelopeSignatures = envelopeSignatures.map { sig -> + val signerIndex = signerIndexMap[sig.address.removeHexPrefix()] ?: 0 + Triple(signerIndex, sig.keyIndex, sig.signature) + }.sortedWith(compareBy> { it.first }.thenBy { it.second }) + + val envelopeSignaturesList = indexedEnvelopeSignatures.map { (signerIndex, keyIndex, signature) -> + RLPList(listOf( + signerIndex.toRLP(), + keyIndex.toRLP(), + hex(signature).toRLP() + )) + } + + return RLPList( + listOf( + RLPList(payload()), + RLPList(payloadSignaturesList), + RLPList(envelopeSignaturesList) + ) + ).encode() +} /** * Builder class to simplify transaction creation and signing diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/models/TransactionSignature.kt b/flow/src/commonMain/kotlin/org/onflow/flow/models/TransactionSignature.kt index 940466f..1f5bd16 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/models/TransactionSignature.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/models/TransactionSignature.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.* * @param signature A variable length signature. */ @Serializable -data class TransactionSignature @OptIn(ExperimentalSerializationApi::class) constructor( +data class TransactionSignature( /* The 8-byte address of an account. */ @SerialName(value = "address") @Required val address: String, @@ -20,17 +20,5 @@ data class TransactionSignature @OptIn(ExperimentalSerializationApi::class) cons /* A variable length signature. */ @Serializable(Base64HexSerializer::class) - @SerialName(value = "signature") @Required val signature: String, - - @EncodeDefault(EncodeDefault.Mode.NEVER) - var signerIndex: Int = -1 + @SerialName(value = "signature") @Required val signature: String ) - -class CompareTransactionSignature { - companion object : Comparator { - override fun compare(a: TransactionSignature, b: TransactionSignature): Int = when { - a.keyIndex == b.keyIndex -> a.signerIndex - b.signerIndex - else -> a.keyIndex - b.keyIndex - } - } -} \ No newline at end of file diff --git a/flow/src/commonMain/kotlin/org/onflow/flow/rlp/RLPTypeConverter.kt b/flow/src/commonMain/kotlin/org/onflow/flow/rlp/RLPTypeConverter.kt index ff4a9d1..57a3b82 100644 --- a/flow/src/commonMain/kotlin/org/onflow/flow/rlp/RLPTypeConverter.kt +++ b/flow/src/commonMain/kotlin/org/onflow/flow/rlp/RLPTypeConverter.kt @@ -12,7 +12,9 @@ import io.ktor.utils.io.core.* fun String.toRLP() = RLPElement(toByteArray()) fun Int.toRLP() = RLPElement(toMinimalByteArray()) -fun BigInteger.toRLP() = RLPElement(toByteArray().removeLeadingZero()) +fun BigInteger.toRLP() = RLPElement( + if (this == BigInteger.ZERO) byteArrayOf() else toByteArray().removeLeadingZero() +) fun ByteArray.toRLP() = RLPElement(this) fun Byte.toRLP() = RLPElement(ByteArray(1) { this }) fun List.toRLP() = RLPElement(RLPList(this.map { it }).encode()) diff --git a/flow/src/commonTest/kotlin/org/onflow/flow/FlowTransactionTests.kt b/flow/src/commonTest/kotlin/org/onflow/flow/FlowTransactionTests.kt index 1d8b99d..2587580 100644 --- a/flow/src/commonTest/kotlin/org/onflow/flow/FlowTransactionTests.kt +++ b/flow/src/commonTest/kotlin/org/onflow/flow/FlowTransactionTests.kt @@ -4,340 +4,650 @@ import com.ionspin.kotlin.bignum.integer.toBigInteger import kotlinx.coroutines.runBlocking import org.onflow.flow.crypto.Crypto import org.onflow.flow.infrastructure.Cadence -import org.onflow.flow.models.HashingAlgorithm -import org.onflow.flow.models.ProposalKey -import org.onflow.flow.models.SigningAlgorithm -import org.onflow.flow.models.Transaction import org.onflow.flow.models.TransactionBuilder import org.onflow.flow.models.TransactionStatus +import org.onflow.flow.models.TransactionSignature +import org.onflow.flow.models.createSigningRLP +import org.onflow.flow.models.createSigningRLPJVMStyle +import org.onflow.flow.models.envelopeMessage import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import org.onflow.flow.models.payloadMessage +import kotlin.test.assertNotNull class FlowTransactionTests { - private val api = FlowApi(ChainId.Testnet) + /** + * Test multi-signer transaction where proposer ≠ payer (gas sponsored scenario) + * + * Flow: Proposer signs payload, Payer signs envelope + */ @Test - fun testSignTestnet() { - runBlocking { - // Test account A details - val testAccountAddress = "0xc6de0d94160377cd" - val cleanAccountAddress = testAccountAddress.removePrefix("0x") - val privateKeyHex = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" - - // Get the account to verify it exists and get the key index - val account = api.getAccount(cleanAccountAddress) - println(account) - val key = account.keys!!.first() - - // Get the latest block for reference block ID - val latestBlock = api.getBlock() - - // Create a signer using the provided private key - val privateKey = Crypto.decodePrivateKey(privateKeyHex, SigningAlgorithm.ECDSA_P256) - val signer = Crypto.getSigner(privateKey, HashingAlgorithm.SHA3_256).apply { - address = cleanAccountAddress - keyIndex = 0 - } + fun testGasSponsoredTransaction() = runBlocking { + // ===== SETUP ===== + val proposerAddress = "c6de0d94160377cd" + val proposerPrivateKey = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" + val payerAddress = "10711015c370a95c" + val payerPrivateKey = "38ebd09b83e221e406b176044a65350333b3a5280ed3f67227bd80d55ac91a0f" + + // Get on-chain account information + val proposerAccount = api.getAccount(proposerAddress) + val payerAccount = api.getAccount(payerAddress) + val proposerKey = proposerAccount.keys!!.first { it.index.toInt() == 0 } + val payerKey = payerAccount.keys!!.first { it.index.toInt() == 0 } + + // Create signers with on-chain algorithms + val proposerSigner = Crypto.getSigner( + Crypto.decodePrivateKey(proposerPrivateKey, proposerKey.signingAlgorithm), + proposerKey.hashingAlgorithm + ).apply { + address = proposerAddress + keyIndex = 0 + } + + val payerSigner = Crypto.getSigner( + Crypto.decodePrivateKey(payerPrivateKey, payerKey.signingAlgorithm), + payerKey.hashingAlgorithm + ).apply { + address = payerAddress + keyIndex = 0 + } + + val latestBlock = api.getBlockHeader() - // Create the transaction with all required fields - val tx = Transaction( - id = null, - script = """ - transaction { - prepare(signer: auth(Storage) &Account) { - log(signer.address) - } + // ===== TRANSACTION BUILDING ===== + val transaction = TransactionBuilder( + script = """ + transaction(msg: String) { + prepare(proposer: auth(Storage) &Account) { + log("Proposer: ".concat(proposer.address.toString())) + log("Message: ".concat(msg)) } - """.trimIndent(), - arguments = emptyList(), - referenceBlockId = latestBlock.header.id, - gasLimit = 1000.toBigInteger(), - payer = cleanAccountAddress, - proposalKey = ProposalKey( - address = cleanAccountAddress, - keyIndex = account.keys!!.first().index.toInt(), - sequenceNumber = key.sequenceNumber.toBigInteger() - ), - authorizers = listOf(cleanAccountAddress), - payloadSignatures = emptyList(), - envelopeSignatures = emptyList(), - expandable = null, // This is optional - result = null, // This is optional - links = null // This is optional - ) + } + """.trimIndent(), + arguments = listOf(Cadence.string("Hello, Testnet!")) + ) + .withProposalKey(proposerAddress, 0, proposerKey.sequenceNumber.toBigInteger()) + .withPayer(payerAddress) + .withAuthorizers(listOf(proposerAddress)) + .withReferenceBlockId(latestBlock.id) + .build() + + // ===== SIGNING ===== + val signedTransaction = transaction.sign(listOf(proposerSigner, payerSigner)) + + // ===== SUBMISSION AND VERIFICATION ===== + val result = api.sendTransaction(signedTransaction) + assertNotNull(result.id, "Transaction ID should not be null") + + api.waitForSeal(result.id!!) + val finalResult = api.getTransactionResult(result.id!!) + + assertEquals(TransactionStatus.SEALED, finalResult.status) + println("✅ Gas sponsored transaction sealed successfully") + } - // Sign and send the transaction - val signedTx = tx.sign(listOf(signer)) - val result = api.sendTransaction(signedTx) + /** + * Test single-signer transaction where proposer = payer = authorizer + */ + @Test + fun testSingleSignerTransaction() = runBlocking { + // ===== SETUP ===== + val accountAddress = "c6de0d94160377cd" + val privateKey = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" + + // Get on-chain account information + val account = api.getAccount(accountAddress) + assertNotNull(account, "Account should exist") + val accountKey = account.keys!!.first { it.index.toInt() == 0 } + + // Create signer with on-chain algorithms + val signer = Crypto.getSigner( + Crypto.decodePrivateKey(privateKey, accountKey.signingAlgorithm), + accountKey.hashingAlgorithm + ).apply { + address = accountAddress + keyIndex = 0 + } - val seal = result.id?.let { api.waitForSeal(it) } + val latestBlock = api.getBlockHeader() - // Verify the transaction was sent successfully - assertEquals(TransactionStatus.SEALED, seal?.status, "Transaction should be sealed") - result.id?.isNotEmpty()?.let { assertTrue(it, "Transaction ID should not be empty") } - println("Transaction sent successfully with ID: ${result.id}") - } + // ===== TRANSACTION BUILDING ===== + val transaction = TransactionBuilder( + script = """ + transaction { + prepare(signer: auth(Storage) &Account) { + log("Hello, World!") + } + } + """.trimIndent() + ) + .withProposalKey(accountAddress, 0, accountKey.sequenceNumber.toBigInteger()) + .withPayer(accountAddress) + .withAuthorizers(listOf(accountAddress)) + .withReferenceBlockId(latestBlock.id) + .build() + + // ===== SIGNING ===== + val signedTransaction = transaction.sign(listOf(signer)) + + // ===== SUBMISSION AND VERIFICATION ===== + val result = api.sendTransaction(signedTransaction) + assertNotNull(result.id, "Transaction ID should not be null") + + api.waitForSeal(result.id!!) + val finalResult = api.getTransactionResult(result.id!!) + + assertEquals(TransactionStatus.SEALED, finalResult.status) + println("✅ Single signer transaction sealed successfully") } + /** + * Test that proposer account can sign transactions independently + */ @Test - fun testMultiSignTransaction() { - runBlocking { - // Test account details - val testAccountAddress = "0xc6de0d94160377cd" - val cleanAccountAddress = testAccountAddress.removePrefix("0x") - val privateKeyHex = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" - - // Get the account to verify it exists and get the key index - val account = api.getAccount(cleanAccountAddress) - val key = account.keys!!.first() - - // Get the latest block for reference block ID - val latestBlock = api.getBlock() - - // Create multiple signers with different key indices - val privateKey = Crypto.decodePrivateKey(privateKeyHex, SigningAlgorithm.ECDSA_P256) - val signers = listOf( - Crypto.getSigner(privateKey, HashingAlgorithm.SHA3_256).apply { - address = cleanAccountAddress - keyIndex = 0 - }, - Crypto.getSigner(privateKey, HashingAlgorithm.SHA3_256).apply { - address = cleanAccountAddress - keyIndex = 1 - } - ) + fun testProposerSoloTransaction() = runBlocking { + // ===== SETUP ===== + val proposerAddress = "c6de0d94160377cd" + val proposerPrivateKey = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" + + // Get on-chain account information + val account = api.getAccount(proposerAddress) + val accountKey = account.keys!!.first { it.index.toInt() == 0 } + + // Create signer with on-chain algorithms + val signer = Crypto.getSigner( + Crypto.decodePrivateKey(proposerPrivateKey, accountKey.signingAlgorithm), + accountKey.hashingAlgorithm + ).apply { + address = proposerAddress + keyIndex = 0 + } + + val latestBlock = api.getBlockHeader() - // Create the transaction with all required fields - val tx = Transaction( - id = null, - script = """ - transaction { - prepare(signer1: auth(Storage) &Account, signer2: auth(Storage) &Account) { - log(signer1.address) - log(signer2.address) - } + // ===== TRANSACTION BUILDING ===== + val transaction = TransactionBuilder( + script = """ + transaction { + prepare(signer: auth(Storage) &Account) { + log("Proposer solo test") } - """.trimIndent(), - arguments = emptyList(), - referenceBlockId = latestBlock.header.id, - gasLimit = 1000.toBigInteger(), - payer = cleanAccountAddress, - proposalKey = ProposalKey( - address = cleanAccountAddress, - keyIndex = account.keys!!.first().index.toInt(), - sequenceNumber = key.sequenceNumber.toBigInteger() - ), - authorizers = listOf(cleanAccountAddress, cleanAccountAddress), - payloadSignatures = emptyList(), - envelopeSignatures = emptyList(), - expandable = null, - result = null, - links = null - ) + } + """.trimIndent() + ) + .withProposalKey(proposerAddress, 0, accountKey.sequenceNumber.toBigInteger()) + .withPayer(proposerAddress) + .withAuthorizers(listOf(proposerAddress)) + .withReferenceBlockId(latestBlock.id) + .build() + + // ===== SIGNING ===== + val signedTransaction = transaction.sign(listOf(signer)) + + // ===== SUBMISSION AND VERIFICATION ===== + val result = api.sendTransaction(signedTransaction) + assertNotNull(result.id, "Transaction ID should not be null") + + api.waitForSeal(result.id!!) + val finalResult = api.getTransactionResult(result.id!!) + + assertEquals(TransactionStatus.SEALED, finalResult.status) + println("✅ Proposer solo transaction sealed successfully") + } - // Sign and send the transaction - val signedTx = tx.sign(signers) - val result = api.sendTransaction(signedTx) + /** + * Test that payer account can sign transactions independently + */ + @Test + fun testPayerSoloTransaction() = runBlocking { + // ===== SETUP ===== + val payerAddress = "10711015c370a95c" + val payerPrivateKey = "38ebd09b83e221e406b176044a65350333b3a5280ed3f67227bd80d55ac91a0f" + + // Get on-chain account information + val account = api.getAccount(payerAddress) + val accountKey = account.keys!!.first { it.index.toInt() == 0 } + + // Create signer with on-chain algorithms + val signer = Crypto.getSigner( + Crypto.decodePrivateKey(payerPrivateKey, accountKey.signingAlgorithm), + accountKey.hashingAlgorithm + ).apply { + address = payerAddress + keyIndex = 0 + } - val seal = result.id?.let { api.waitForSeal(it) } + val latestBlock = api.getBlockHeader() - // Verify the transaction was sent successfully - assertEquals(TransactionStatus.SEALED, seal?.status, "Transaction should be sealed") - result.id?.isNotEmpty()?.let { assertTrue(it, "Transaction ID should not be empty") } - println("Multi-sign transaction sent successfully with ID: ${result.id}") - } + // ===== TRANSACTION BUILDING ===== + val transaction = TransactionBuilder( + script = """ + transaction { + prepare(signer: auth(Storage) &Account) { + log("Payer solo test") + } + } + """.trimIndent() + ) + .withProposalKey(payerAddress, 0, accountKey.sequenceNumber.toBigInteger()) + .withPayer(payerAddress) + .withAuthorizers(listOf(payerAddress)) + .withReferenceBlockId(latestBlock.id) + .build() + + // ===== SIGNING ===== + val signedTransaction = transaction.sign(listOf(signer)) + + // ===== SUBMISSION AND VERIFICATION ===== + val result = api.sendTransaction(signedTransaction) + assertNotNull(result.id, "Transaction ID should not be null") + + api.waitForSeal(result.id!!) + val finalResult = api.getTransactionResult(result.id!!) + + assertEquals(TransactionStatus.SEALED, finalResult.status) + println("✅ Payer solo transaction sealed successfully") } + /** + * Test manual step-by-step signature addition (JVM SDK style) + * + * This test demonstrates the step-by-step approach used by the Flow JVM SDK + */ @Test - fun testAddKeyTransactionTest() { - runBlocking { - // ---- CONFIGURATION ---- - // Using existing Testnet account details from other tests in this file - val proposerAuthorizerAddress = "0xc6de0d94160377cd" - val proposerPrivateKeyHex = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" - val proposerKeyIndex = 0 // Assuming key index 0 for this test account - - // Simplification: Proposer is also Payer for this test, as the error is in payload() construction - val payerAddress = proposerAuthorizerAddress - - // New public key to add (64-byte hex string, no "0x" or "04" prefix) - // This is the value that was '53effd...' in your Android app logs. - val newPublicKeyForCadence = "53effd044517cffd785f4719f63769c9f18b24df546c0cd1f52ff7f2e63789f3c80d6fe0d0a48db47cefd5b0c95714f041233a419409f44f47ce35cd55eb2a7b" - val newKeySigAlgoCadenceIndex = 1 // ECDSA_P256.cadenceIndex - val newKeyHashAlgoCadenceIndex = 3 // SHA3_256.cadenceIndex (aligns with app log: UInt8Value(value=3)) - val newKeyWeightString = "1000.0" - - val gasLimitForTx = 9999L - // ---- END CONFIGURATION ---- - - val cleanProposerAuthorizerAddress = proposerAuthorizerAddress.removePrefix("0x") - val cleanPayerAddress = payerAddress.removePrefix("0x") - - // 1. Get proposer account details - val proposerAccount = api.getAccount(cleanProposerAuthorizerAddress) - val proposerAccountKey = proposerAccount.keys?.find { it.index.toInt() == proposerKeyIndex } - ?: throw RuntimeException("Proposer key with index $proposerKeyIndex not found on account $proposerAuthorizerAddress") - val currentSequenceNumber = proposerAccountKey.sequenceNumber.toBigInteger() - - // 2. Get the latest block for reference block ID - val latestBlock = api.getBlockHeader() - - // 3. Define the script (same as your app) - val addKeyScript = """ - import Crypto - - transaction(publicKey: String, signatureAlgorithm: UInt8, hashAlgorithm: UInt8, weight: UFix64) { - prepare(signer: auth(Keys) &Account) { - let key = PublicKey( - publicKey: publicKey.decodeHex(), - signatureAlgorithm: SignatureAlgorithm(rawValue: signatureAlgorithm)! - ) - - signer.keys.add( - publicKey: key, - hashAlgorithm: HashAlgorithm(rawValue: hashAlgorithm)!, - weight: weight - ) + fun testManualStepByStepSigning() = runBlocking { + // ===== SETUP ===== + val proposerAddress = "c6de0d94160377cd" + val proposerPrivateKey = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" + val payerAddress = "10711015c370a95c" + val payerPrivateKey = "38ebd09b83e221e406b176044a65350333b3a5280ed3f67227bd80d55ac91a0f" + + // Get on-chain account information + val proposerAccount = api.getAccount(proposerAddress) + val payerAccount = api.getAccount(payerAddress) + val proposerKey = proposerAccount.keys!!.first { it.index.toInt() == 0 } + val payerKey = payerAccount.keys!!.first { it.index.toInt() == 0 } + + // Create signers with on-chain algorithms + val proposerSigner = Crypto.getSigner( + Crypto.decodePrivateKey(proposerPrivateKey, proposerKey.signingAlgorithm), + proposerKey.hashingAlgorithm + ).apply { + address = proposerAddress + keyIndex = 0 + } + + val payerSigner = Crypto.getSigner( + Crypto.decodePrivateKey(payerPrivateKey, payerKey.signingAlgorithm), + payerKey.hashingAlgorithm + ).apply { + address = payerAddress + keyIndex = 0 + } + + val latestBlock = api.getBlockHeader() + + // ===== TRANSACTION BUILDING ===== + val unsignedTransaction = TransactionBuilder( + script = """ + transaction(msg: String) { + prepare(proposer: auth(Storage) &Account) { + log("Manual signing: ".concat(msg)) } } - """.trimIndent() + """.trimIndent(), + arguments = listOf(Cadence.string("Hello!")) + ) + .withProposalKey(proposerAddress, 0, proposerKey.sequenceNumber.toBigInteger()) + .withPayer(payerAddress) + .withAuthorizers(listOf(proposerAddress)) + .withReferenceBlockId(latestBlock.id) + .build() + + // ===== STEP-BY-STEP SIGNING ===== + // Step 1: Add payload signature (proposer signs payload) + val transactionWithPayloadSig = unsignedTransaction.addPayloadSignature( + proposerAddress, 0, proposerSigner + ) + + // Step 2: Add envelope signature (payer signs envelope) + val fullySignedTransaction = transactionWithPayloadSig.addEnvelopeSignature( + payerAddress, 0, payerSigner + ) + + // ===== SUBMISSION AND VERIFICATION ===== + val result = api.sendTransaction(fullySignedTransaction) + assertNotNull(result.id, "Transaction ID should not be null") + + api.waitForSeal(result.id!!) + val finalResult = api.getTransactionResult(result.id!!) + + assertEquals(TransactionStatus.SEALED, finalResult.status) + println("✅ Manual step-by-step transaction sealed successfully") + } - // 4. Prepare arguments (matching your app logs) - val arguments = listOf( - Cadence.string(newPublicKeyForCadence), - Cadence.uint8(newKeySigAlgoCadenceIndex.toUByte()), - Cadence.uint8(newKeyHashAlgoCadenceIndex.toUByte()), - Cadence.ufix64(newKeyWeightString) - ) - println("[KMM Test] Arguments: $arguments") - - // 5. Create the proposer's signer - // Ensure SigningAlgorithm and HashingAlgorithm match the key's actual algorithms - val keySigningAlgorithm = SigningAlgorithm.values().firstOrNull { it.cadenceIndex == proposerAccountKey.signingAlgorithm.cadenceIndex } - ?: SigningAlgorithm.ECDSA_P256 // Default if not found, adjust as needed - val keyHashingAlgorithm = HashingAlgorithm.values().firstOrNull { it.cadenceIndex == proposerAccountKey.hashingAlgorithm.cadenceIndex } - ?: HashingAlgorithm.SHA3_256 // Default if not found, adjust as needed - - val proposerPrivateKey = Crypto.decodePrivateKey(proposerPrivateKeyHex, keySigningAlgorithm) - val proposerSigner = Crypto.getSigner(proposerPrivateKey, keyHashingAlgorithm).apply { - address = cleanProposerAuthorizerAddress - keyIndex = proposerKeyIndex - } - println("[KMM Test] Proposer Signer: address=${proposerSigner.address}, keyIndex=${proposerSigner.keyIndex}, sigAlgo=${keySigningAlgorithm}, hashAlgo=${keyHashingAlgorithm}") - - // 6. Construct the Transaction object directly - println("[KMM Test] Constructing Transaction object:") - println(" Script (length ${addKeyScript.length}): $addKeyScript") - println(" ReferenceBlockId: ${latestBlock.id}") - println(" GasLimit: ${gasLimitForTx.toBigInteger()}") - println(" Payer: $cleanPayerAddress") - println(" ProposalKey: address=$cleanProposerAuthorizerAddress, keyIndex=$proposerKeyIndex, sequenceNumber=$currentSequenceNumber") - println(" Authorizers: ${listOf(cleanProposerAuthorizerAddress)}") - - println("[KMM Test] Transaction object created. Attempting to sign (which calls payload())...") - - // 7. Attempt to sign using TransactionBuilder instead of direct Transaction construction - try { - val signedTx = TransactionBuilder( - script = addKeyScript, - arguments = arguments, - gasLimit = gasLimitForTx.toBigInteger() - ).apply { - withReferenceBlockId(latestBlock.id) - withPayer(cleanPayerAddress) - withProposalKey( - address = cleanProposerAuthorizerAddress, - keyIndex = proposerKeyIndex, - sequenceNumber = currentSequenceNumber - ) - withAuthorizers(listOf(cleanProposerAuthorizerAddress)) - withSigners(listOf(proposerSigner)) - }.buildAndSign() - - println("[KMM Test] Transaction signed successfully: $signedTx") - - - } catch (e: Exception) { - e.printStackTrace() // Print full stack trace for detailed analysis - - // Check if the exception message is "Array is empty." - val isCorrectError = e.message?.contains("Array is empty", ignoreCase = true) == true - if (isCorrectError) { - println("[KMM Test] SUCCESSFULLY REPLICATED an error containing 'Array is empty'. Test PASSES.") - // Optionally, assert specific details from the exception if needed - // For example, to ensure it's from the expected place: - // assertTrue(e.stackTraceToString().contains("TransactionKt.payload"), "Error should originate from payload()") - } else { - println("[KMM Test] An exception occurred, but it was not the expected 'Array is empty' error. Test FAILS.") + /** + * Test payload message debugging and comparison + * + * This test compares payload messages between single-signer and multi-signer scenarios + */ + @Test + fun testPayloadMessageComparison() = runBlocking { + // ===== SETUP ===== + val proposerAddress = "c6de0d94160377cd" + val proposerPrivateKey = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" + val payerAddress = "10711015c370a95c" + + // Get on-chain account information + val proposerAccount = api.getAccount(proposerAddress) + val proposerKey = proposerAccount.keys!!.first { it.index.toInt() == 0 } + + // Create signer with on-chain algorithms + val proposerSigner = Crypto.getSigner( + Crypto.decodePrivateKey(proposerPrivateKey, proposerKey.signingAlgorithm), + proposerKey.hashingAlgorithm + ).apply { + address = proposerAddress + keyIndex = 0 + } + + val latestBlock = api.getBlockHeader() + + // ===== TRANSACTION BUILDING ===== + val commonScript = """ + transaction { + prepare(signer: auth(Storage) &Account) { + log("Payload comparison test") } } - } + """.trimIndent() + + // Single-signer transaction (proposer = payer) + val singleSignerTransaction = TransactionBuilder(commonScript) + .withProposalKey(proposerAddress, 0, proposerKey.sequenceNumber.toBigInteger()) + .withPayer(proposerAddress) + .withAuthorizers(listOf(proposerAddress)) + .withReferenceBlockId(latestBlock.id) + .build() + + // Multi-signer transaction (proposer ≠ payer) + val multiSignerTransaction = TransactionBuilder(commonScript) + .withProposalKey(proposerAddress, 0, proposerKey.sequenceNumber.toBigInteger()) + .withPayer(payerAddress) + .withAuthorizers(listOf(proposerAddress)) + .withReferenceBlockId(latestBlock.id) + .build() + + // ===== PAYLOAD MESSAGE COMPARISON ===== + val singleSignerPayload = singleSignerTransaction.payloadMessage() + val multiSignerPayload = multiSignerTransaction.payloadMessage() + + // Verify payloads are different (due to different payer) + assertTrue( + !singleSignerPayload.contentEquals(multiSignerPayload), + "Payload messages should differ when payer is different" + ) + + // ===== SIGNATURE VERIFICATION ===== + val singleSignerSignature = proposerSigner.sign(singleSignerPayload) + val multiSignerSignature = proposerSigner.sign(multiSignerPayload) + + // Verify signatures are different (due to different payloads) + assertTrue( + !singleSignerSignature.contentEquals(multiSignerSignature), + "Signatures should differ when payload is different" + ) + + println("✅ Payload message comparison test passed") } + /** + * Debug test to understand signature verification issues + */ + @OptIn(ExperimentalStdlibApi::class) @Test - fun testTransactionWithZeroSequenceNumber() { - runBlocking { - // Test account details (can reuse from other tests) - val testAccountAddress = "0xc6de0d94160377cd" - val cleanAccountAddress = testAccountAddress.removePrefix("0x") - val privateKeyHex = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" - - // Get the account to verify it exists and get the key index - // We will ignore the fetched sequence number and use 0 instead. - val account = api.getAccount(cleanAccountAddress) - val key = account.keys!!.first() // For keyIndex and signing algorithm - - // Get the latest block for reference block ID - val latestBlock = api.getBlockHeader() - - // Create a signer using the provided private key - val privateKey = Crypto.decodePrivateKey(privateKeyHex, key.signingAlgorithm) - val signer = Crypto.getSigner(privateKey, key.hashingAlgorithm).apply { - address = cleanAccountAddress - keyIndex = key.index.toInt() - } + fun testSignatureVerificationDebug() = runBlocking { + // ===== SETUP ===== + val proposerAddress = "c6de0d94160377cd" + val proposerPrivateKey = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" + + // Get on-chain account information + val proposerAccount = api.getAccount(proposerAddress) + val proposerKey = proposerAccount.keys!!.first { it.index.toInt() == 0 } + + println("SIGNATURE DEBUG:") + println("On-chain key details:") + println("Public key: ${proposerKey.publicKey}") + println("Signing algorithm: ${proposerKey.signingAlgorithm}") + println("Hashing algorithm: ${proposerKey.hashingAlgorithm}") + println("Weight: ${proposerKey.weight}") + println("Revoked: ${proposerKey.revoked}") + + // Create signer and verify key derivation + val privateKeyDecoded = Crypto.decodePrivateKey(proposerPrivateKey, proposerKey.signingAlgorithm) + val derivedPublicKey = privateKeyDecoded.publicKey + + println("Derived public key: ${derivedPublicKey.key}") + // Fix: Compare hex strings properly by removing 0x prefix + val onChainHex = proposerKey.publicKey.removePrefix("0x") + val derivedHex = derivedPublicKey.hex + println("Keys match: ${onChainHex.equals(derivedHex, ignoreCase = true)}") + + val proposerSigner = Crypto.getSigner(privateKeyDecoded, proposerKey.hashingAlgorithm).apply { + address = proposerAddress + keyIndex = 0 + } + + val latestBlock = api.getBlockHeader() - val simpleScript = """ + // ===== TRANSACTION BUILDING ===== + val transaction = TransactionBuilder( + script = """ transaction { prepare(signer: auth(Storage) &Account) { - log("Sequence number 0 test") - log(signer.address) + log("Signature debug test") } } """.trimIndent() + ) + .withProposalKey(proposerAddress, 0, proposerKey.sequenceNumber.toBigInteger()) + .withPayer(proposerAddress) + .withAuthorizers(listOf(proposerAddress)) + .withReferenceBlockId(latestBlock.id) + .build() + + println("Transaction details:") + println("Script: ${transaction.script}") + println("Reference block: ${transaction.referenceBlockId}") + println("Gas limit: ${transaction.gasLimit}") + println("Proposer: ${transaction.proposalKey.address}") + println("Payer: ${transaction.payer}") + println("Authorizers: ${transaction.authorizers}") + + // ===== PAYLOAD MESSAGE ANALYSIS ===== + val payloadMessage = transaction.payloadMessage() + println("Payload message (${payloadMessage.size} bytes): ${payloadMessage.toHexString()}") + + // Test different RLP creation methods + val currentRLP = transaction.createSigningRLP(includePayloadSignatures = false) + val jvmStyleRLP = transaction.createSigningRLPJVMStyle(includePayloadSignatures = false) + + println("Current RLP: ${currentRLP.toHexString()}") + println("JVM Style RLP: ${jvmStyleRLP.toHexString()}") + println("RLP methods match: ${currentRLP.contentEquals(jvmStyleRLP)}") + + // Test what addPayloadSignature actually signs vs what we're manually signing + val rawRLPData = transaction.createSigningRLPJVMStyle(includePayloadSignatures = false) + val signAsTransactionResult = proposerSigner.signAsTransaction(rawRLPData) + val manualSignResult = proposerSigner.sign(payloadMessage) + + println("Raw RLP data: ${rawRLPData.toHexString()}") + println("SignAsTransaction result: ${signAsTransactionResult.toHexString()}") + println("Manual sign result: ${manualSignResult.toHexString()}") + println("Signing methods match: ${signAsTransactionResult.contentEquals(manualSignResult)}") + + // Test if this matches what our addPayloadSignature method would create + val addedSigTx = transaction.addPayloadSignature(proposerAddress, 0, proposerSigner) + println("AddPayloadSignature result: ${addedSigTx.payloadSignatures.first().signature}") + println("Manual vs AddPayloadSignature match: ${manualSignResult.toHexString() == addedSigTx.payloadSignatures.first().signature}") + + println("✅ Signature debug test completed") + } - println("[KMM Test - Zero SeqNum] Attempting to build and sign transaction with sequenceNumber = 0") - - var signedTx: Transaction? = null - var exceptionOccurred: Exception? = null - - try { - signedTx = TransactionBuilder( - script = simpleScript, - arguments = emptyList(), - gasLimit = 100L.toBigInteger() // Minimal gas for a simple log - ).apply { - withReferenceBlockId(latestBlock.id) - withPayer(cleanAccountAddress) - withProposalKey( - address = cleanAccountAddress, - keyIndex = key.index.toInt(), - sequenceNumber = 0.toBigInteger() // Explicitly use Zero BigInteger - ) - withAuthorizers(listOf(cleanAccountAddress)) - withSigners(listOf(signer)) - }.buildAndSign() - - println("[KMM Test - Zero SeqNum] Transaction signed successfully with sequenceNumber = 0: $signedTx") - } catch (e: Exception) { - exceptionOccurred = e - e.printStackTrace() - } + /** + * Debug test to verify multi-signer signing process + */ + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testMultiSignerDebug() = runBlocking { + // ===== SETUP ===== + val proposerAddress = "c6de0d94160377cd" + val proposerPrivateKey = "c9c0f04adddf7674d265c395de300a65a777d3ec412bba5bfdfd12cffbbb78d9" + val payerAddress = "10711015c370a95c" + val payerPrivateKey = "38ebd09b83e221e406b176044a65350333b3a5280ed3f67227bd80d55ac91a0f" + + // Get on-chain account information + val proposerAccount = api.getAccount(proposerAddress) + val payerAccount = api.getAccount(payerAddress) + val proposerKey = proposerAccount.keys!!.first { it.index.toInt() == 0 } + val payerKey = payerAccount.keys!!.first { it.index.toInt() == 0 } + + // Create signers with on-chain algorithms + val proposerSigner = Crypto.getSigner( + Crypto.decodePrivateKey(proposerPrivateKey, proposerKey.signingAlgorithm), + proposerKey.hashingAlgorithm + ).apply { + address = proposerAddress + keyIndex = 0 + } + + val payerSigner = Crypto.getSigner( + Crypto.decodePrivateKey(payerPrivateKey, payerKey.signingAlgorithm), + payerKey.hashingAlgorithm + ).apply { + address = payerAddress + keyIndex = 0 + } - assertTrue(exceptionOccurred == null, "[KMM Test - Zero SeqNum] No exception should be thrown during signing. Got: ${exceptionOccurred?.message}") - assertTrue(signedTx != null, "[KMM Test - Zero SeqNum] Signed transaction should not be null.") + val latestBlock = api.getBlockHeader() + + // ===== TRANSACTION BUILDING ===== + val transaction = TransactionBuilder( + script = """ + transaction { + prepare(signer: auth(Storage) &Account) { + log("Multi-signer debug test") + } + } + """.trimIndent() + ) + .withProposalKey(proposerAddress, 0, proposerKey.sequenceNumber.toBigInteger()) + .withPayer(payerAddress) + .withAuthorizers(listOf(proposerAddress)) + .withReferenceBlockId(latestBlock.id) + .build() + + println("MULTI-SIGNER DEBUG:") + println("Initial transaction payload signatures: ${transaction.payloadSignatures.size}") + println("Initial transaction envelope signatures: ${transaction.envelopeSignatures.size}") + + // Step 1: Test payload signing manually (corrected approach) + val payloadMessageToSign = transaction.payloadMessage() + val proposerPayloadSig = proposerSigner.sign(payloadMessageToSign) // Fix: Use corrected approach + + println("Payload message to sign: ${payloadMessageToSign.toHexString()}") + println("Proposer payload signature: ${proposerPayloadSig.toHexString()}") + + // Add the payload signature + val txWithPayloadSig = transaction.copy( + payloadSignatures = listOf( + TransactionSignature( + address = proposerAddress, + keyIndex = 0, + signature = proposerPayloadSig.toHexString() + ) + ) + ) + + println("Transaction after adding payload sig - payloadSignatures: ${txWithPayloadSig.payloadSignatures.size}") + + // Step 2: Test envelope signing manually (corrected approach) + val envelopeMessageToSign = txWithPayloadSig.envelopeMessage() + val payerEnvelopeSig = payerSigner.sign(envelopeMessageToSign) // Fix: Use corrected approach + + println("Envelope message to sign: ${envelopeMessageToSign.toHexString()}") + println("Payer envelope signature: ${payerEnvelopeSig.toHexString()}") + + // Test automatic signing vs manual signing + val autoSignedTransaction = transaction.sign(listOf(proposerSigner, payerSigner)) + + println("Auto-signed transaction - payloadSignatures: ${autoSignedTransaction.payloadSignatures.size}") + println("Auto-signed transaction - envelopeSignatures: ${autoSignedTransaction.envelopeSignatures.size}") + + if (autoSignedTransaction.payloadSignatures.isNotEmpty()) { + println("Auto payload signature: ${autoSignedTransaction.payloadSignatures[0].signature}") + } + if (autoSignedTransaction.envelopeSignatures.isNotEmpty()) { + println("Auto envelope signature: ${autoSignedTransaction.envelopeSignatures[0].signature}") + } + + // Compare signatures + val manualPayloadSig = proposerPayloadSig.toHexString() + val autoPayloadSig = if (autoSignedTransaction.payloadSignatures.isNotEmpty()) + autoSignedTransaction.payloadSignatures[0].signature else "NONE" + + val manualEnvelopeSig = payerEnvelopeSig.toHexString() + val autoEnvelopeSig = if (autoSignedTransaction.envelopeSignatures.isNotEmpty()) + autoSignedTransaction.envelopeSignatures[0].signature else "NONE" + + println("Manual vs Auto payload signature match: ${manualPayloadSig == autoPayloadSig}") + println("Manual vs Auto envelope signature match: ${manualEnvelopeSig == autoEnvelopeSig}") + + // The key test: Do both payload messages for signing match? + val manualPayloadMessage = transaction.payloadMessage() + val autoPayloadMessage = transaction.payloadMessage() // These should be identical + + println("Manual vs Auto payload message match: ${manualPayloadMessage.contentEquals(autoPayloadMessage)}") + + // CRITICAL TEST: Try submitting the manual transaction + println("Testing manual transaction submission...") + val manualTransaction = txWithPayloadSig.copy( + envelopeSignatures = listOf( + TransactionSignature( + address = payerAddress, + keyIndex = 0, + signature = payerEnvelopeSig.toHexString() + ) + ) + ) + + try { + val manualResult = api.sendTransaction(manualTransaction) + println("Manual transaction submitted successfully: ${manualResult.id}") + + api.waitForSeal(manualResult.id!!) + println("Manual transaction sealed successfully!") + + } catch (e: Exception) { + println("Manual transaction failed: ${e.message}") + } + + // TEST: Try submitting the automatic transaction + println("Testing automatic transaction submission...") + try { + val autoResult = api.sendTransaction(autoSignedTransaction) + println("Auto transaction submitted successfully: ${autoResult.id}") + + api.waitForSeal(autoResult.id!!) + println("Auto transaction sealed successfully!") + + } catch (e: Exception) { + println("Auto transaction failed: ${e.message}") } + + println("✅ Multi-signer debug test completed") } } \ No newline at end of file diff --git a/flow/src/commonTest/kotlin/org/onflow/flow/rlp/RLPTests.kt b/flow/src/commonTest/kotlin/org/onflow/flow/rlp/RLPTests.kt index 6f66c18..7073bdd 100644 --- a/flow/src/commonTest/kotlin/org/onflow/flow/rlp/RLPTests.kt +++ b/flow/src/commonTest/kotlin/org/onflow/flow/rlp/RLPTests.kt @@ -2,11 +2,18 @@ package org.onflow.flow.rlp import org.onflow.flow.models.ProposalKey import org.onflow.flow.models.Transaction +import org.onflow.flow.models.TransactionSignature import org.onflow.flow.models.payloadMessage +import org.onflow.flow.models.envelopeMessage +import org.onflow.flow.infrastructure.Cadence import com.ionspin.kotlin.bignum.integer.BigInteger import io.ktor.util.* +import org.onflow.flow.models.completeEnvelopeMessage +import org.onflow.flow.models.payload import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class RLPTests { @@ -18,8 +25,328 @@ class RLPTests { assertEquals(9, size) } + @OptIn(ExperimentalStdlibApi::class) @Test - fun testSimpleFlowTxEncode() { + fun testEnvelopeStructureWithPayloadSignatures() { + // Test envelope structure when payload signatures are present + val baseTx = Transaction( + script = "transaction { log(\"test\") }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(1000), + proposalKey = ProposalKey(address = "01", keyIndex = 0, sequenceNumber = BigInteger(10)), + payer = "02", + authorizers = listOf("01") + ) + + // Add a payload signature + val txWithPayloadSig = baseTx.copy( + payloadSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 0, + signature = "abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234567890ab" + ) + ) + ) + + val envelopeMsg = txWithPayloadSig.envelopeMessage() + val envelopeMsgHex = envelopeMsg.toHexString() + + // Should NOT end with 'c0' because it includes the payload signature + assertTrue(!envelopeMsgHex.endsWith("c0"), "Envelope message should NOT end with 'c0' when payload signatures exist") + + // Should include the signature in the encoding + assertTrue(envelopeMsgHex.contains("abcd1234567890"), "Should contain part of the signature") + + println("Envelope message with payload signature: $envelopeMsgHex") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRLPPayloadComponentsStructure() { + // Test the individual components of the payload RLP structure + val tx = Transaction( + script = "transaction { log(\"Hello!\") }", + arguments = listOf(Cadence.string("test")), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(9999), + proposalKey = ProposalKey(address = "c6de0d94160377cd", keyIndex = 2, sequenceNumber = BigInteger(42)), + payer = "10711015c370a95c", + authorizers = listOf("c6de0d94160377cd", "1234567890abcdef") + ) + + val payload = tx.payload() + assertEquals(9, payload.size, "Payload should have exactly 9 components") + + // Verify each component type by encoding and checking structure + val payloadRLP = RLPList(payload) + val payloadEncoded = payloadRLP.encode() + + // Should be a valid RLP list + assertTrue(payloadEncoded.isNotEmpty()) + + // Decode it back and verify structure + val decoded = payloadEncoded.decodeRLP() as RLPList + assertEquals(9, decoded.element.size) + + println("Payload components verified: ${payloadEncoded.toHexString()}") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testDomainTagPresence() { + // Test that domain tag is properly included in both payload and envelope messages + val tx = Transaction( + script = "transaction { log(\"domain test\") }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(1000), + proposalKey = ProposalKey(address = "01", keyIndex = 0, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01") + ) + + val expectedDomainTag = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000" + + val payloadMsg = tx.payloadMessage() + val envelopeMsg = tx.envelopeMessage() + + // Both should start with domain tag + assertTrue(payloadMsg.toHexString().startsWith(expectedDomainTag), "Payload message should start with domain tag") + assertTrue(envelopeMsg.toHexString().startsWith(expectedDomainTag), "Envelope message should start with domain tag") + + // Domain tag should be exactly 32 bytes + assertEquals(32, expectedDomainTag.length / 2) + + println("Domain tag verified in both payload and envelope messages") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testEmptyArgumentsEncoding() { + // Test that empty arguments list is properly encoded + val txWithEmpty = Transaction( + script = "transaction { log(\"empty args\") }", + arguments = listOf(), // Empty + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(1000), + proposalKey = ProposalKey(address = "01", keyIndex = 0, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01") + ) + + val txWithArgs = Transaction( + script = "transaction { log(\"with args\") }", + arguments = listOf(Cadence.string("test")), // Non-empty + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(1000), + proposalKey = ProposalKey(address = "01", keyIndex = 0, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01") + ) + + val emptyArgsMsg = txWithEmpty.payloadMessage() + val withArgsMsg = txWithArgs.payloadMessage() + + // Should be different + assertNotEquals(emptyArgsMsg.toHexString(), withArgsMsg.toHexString()) + + // Both should be valid + assertTrue(emptyArgsMsg.size > 32) + assertTrue(withArgsMsg.size > 32) + + println("Empty arguments encoding verified") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRLPBasicEncodingDecoding() { + // Test basic RLP encoding/decoding with known values + + // Test empty string + val emptyString = "".toRLP() + val emptyEncoded = emptyString.encode() + assertEquals("80", emptyEncoded.toHexString()) + val emptyDecoded = emptyEncoded.decodeRLP() as RLPElement + assertEquals("", emptyDecoded.toStringFromRLP()) + + // Test single byte values + val singleByte = 15.toByte().toRLP() + val singleEncoded = singleByte.encode() + assertEquals("0f", singleEncoded.toHexString()) + val singleDecoded = singleEncoded.decodeRLP() as RLPElement + assertEquals(15.toByte(), singleDecoded.toByteFromRLP()) + + // Test short string + val shortString = "dog".toRLP() + val shortEncoded = shortString.encode() + assertEquals("83646f67", shortEncoded.toHexString()) + val shortDecoded = shortEncoded.decodeRLP() as RLPElement + assertEquals("dog", shortDecoded.toStringFromRLP()) + + // Test empty list + val emptyList = RLPList(emptyList()) + val emptyListEncoded = emptyList.encode() + assertEquals("c0", emptyListEncoded.toHexString()) + val emptyListDecoded = emptyListEncoded.decodeRLP() as RLPList + assertEquals(0, emptyListDecoded.element.size) + + // Test list with strings + val stringList = RLPList(listOf("cat".toRLP(), "dog".toRLP())) + val stringListEncoded = stringList.encode() + assertEquals("c88363617483646f67", stringListEncoded.toHexString()) + val stringListDecoded = stringListEncoded.decodeRLP() as RLPList + assertEquals(2, stringListDecoded.element.size) + assertEquals("cat", (stringListDecoded.element[0] as RLPElement).toStringFromRLP()) + assertEquals("dog", (stringListDecoded.element[1] as RLPElement).toStringFromRLP()) + + println("Basic RLP encoding/decoding tests passed") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRLPIntegerEncoding() { + // Test integer encoding edge cases + + // Test zero + val zero = 0.toRLP() + val zeroEncoded = zero.encode() + assertEquals("80", zeroEncoded.toHexString()) // Zero should encode as empty string + val zeroDecoded = zeroEncoded.decodeRLP() as RLPElement + assertEquals(0, zeroDecoded.toIntFromRLP()) + + // Test small integers + val small = 15.toRLP() + val smallEncoded = small.encode() + assertEquals("0f", smallEncoded.toHexString()) + val smallDecoded = smallEncoded.decodeRLP() as RLPElement + assertEquals(15, smallDecoded.toIntFromRLP()) + + // Test larger integers + val large = 1024.toRLP() + val largeEncoded = large.encode() + assertEquals("820400", largeEncoded.toHexString()) + val largeDecoded = largeEncoded.decodeRLP() as RLPElement + assertEquals(1024, largeDecoded.toIntFromRLP()) + + // Test BigInteger + val bigInt = BigInteger(1000000).toRLP() + val bigIntEncoded = bigInt.encode() + val bigIntDecoded = bigIntEncoded.decodeRLP() as RLPElement + assertEquals(BigInteger(1000000), bigIntDecoded.toUnsignedBigIntegerFromRLP()) + + // Test BigInteger zero - should encode as empty like Int zero + val bigIntZero = BigInteger.ZERO.toRLP() + val bigIntZeroEncoded = bigIntZero.encode() + assertEquals("80", bigIntZeroEncoded.toHexString()) // Should encode as empty string like Int zero + val bigIntZeroDecoded = bigIntZeroEncoded.decodeRLP() as RLPElement + assertEquals(BigInteger.ZERO, bigIntZeroDecoded.toUnsignedBigIntegerFromRLP()) + + println("Integer RLP encoding tests passed") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRLPByteArrayEncoding() { + // Test byte array encoding + + // Test with hex data (common in blockchain) + val hexData = hex("deadbeef") + val hexRLP = hexData.toRLP() + val hexEncoded = hexRLP.encode() + assertEquals("84deadbeef", hexEncoded.toHexString()) + val hexDecoded = hexEncoded.decodeRLP() as RLPElement + assertTrue(hexData.contentEquals(hexDecoded.bytes)) + + // Test with address-like data (8 bytes padded) + val addressData = hex("01").paddingZeroLeft(8) + val addressRLP = addressData.toRLP() + val addressEncoded = addressRLP.encode() + val addressDecoded = addressEncoded.decodeRLP() as RLPElement + assertTrue(addressData.contentEquals(addressDecoded.bytes)) + + println("Byte array RLP encoding tests passed") + } + + @Test + fun testRLPNestedStructures() { + // Test complex nested structures like we use in transactions + + // Create a structure similar to what we use in Flow transactions + val nestedList = RLPList(listOf( + "script".toRLP(), // Script + RLPList(emptyList()), // Empty arguments + hex("deadbeef").toRLP(), // Reference block ID (4 bytes for test) + BigInteger(1000).toRLP(), // Gas limit + hex("01").paddingZeroLeft(8).toRLP(), // Proposer address + 0.toRLP(), // Key index + BigInteger(10).toRLP(), // Sequence number + hex("02").paddingZeroLeft(8).toRLP(), // Payer address + RLPList(listOf(hex("01").paddingZeroLeft(8).toRLP())) // Authorizers + )) + + val encoded = nestedList.encode() + val decoded = encoded.decodeRLP() as RLPList + + assertEquals(9, decoded.element.size, "Should have 9 elements like Flow transaction payload") + + // Verify each element + assertEquals("script", (decoded.element[0] as RLPElement).toStringFromRLP()) + assertEquals(0, (decoded.element[1] as RLPList).element.size) // Empty arguments + assertTrue(hex("deadbeef").contentEquals((decoded.element[2] as RLPElement).bytes)) + assertEquals(BigInteger(1000), (decoded.element[3] as RLPElement).toUnsignedBigIntegerFromRLP()) + assertTrue(hex("01").paddingZeroLeft(8).contentEquals((decoded.element[4] as RLPElement).bytes)) + assertEquals(0, (decoded.element[5] as RLPElement).toIntFromRLP()) + assertEquals(BigInteger(10), (decoded.element[6] as RLPElement).toUnsignedBigIntegerFromRLP()) + assertTrue(hex("02").paddingZeroLeft(8).contentEquals((decoded.element[7] as RLPElement).bytes)) + + val authorizersList = decoded.element[8] as RLPList + assertEquals(1, authorizersList.element.size) + assertTrue(hex("01").paddingZeroLeft(8).contentEquals((authorizersList.element[0] as RLPElement).bytes)) + + println("Nested RLP structure tests passed") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testRLPRoundtripConsistency() { + // Test that encode -> decode -> encode produces identical results + + val originalData = listOf( + "test".toRLP(), + 123.toRLP(), + hex("deadbeef").toRLP(), + RLPList(listOf("a".toRLP(), "b".toRLP())), + BigInteger(999999).toRLP() + ) + + val originalList = RLPList(originalData) + val firstEncoding = originalList.encode() + val decoded = firstEncoding.decodeRLP() as RLPList + val secondEncoding = decoded.encode() + + // Should be identical + assertEquals(firstEncoding.toHexString(), secondEncoding.toHexString(), "Encode -> Decode -> Encode should be consistent") + + assertEquals(0, RLPElement(byteArrayOf()).toIntFromRLP()) + assertEquals(255, 255.toRLP().toIntFromRLP()) + + val bi = BigInteger.fromInt(255) + assertEquals(bi, bi.toRLP().toUnsignedBigIntegerFromRLP()) + + val padded = byteArrayOf(0x1, 0x2).paddingZeroLeft() + assertEquals(8, padded.size) + assertTrue(padded.take(6).all { it == 0.toByte() }) + + println("RLP roundtrip consistency test passed") + + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompatibilityBasic() { + // Test case: "complete tx" from Flow JS SDK val tx = Transaction( script = "transaction { execute { log(\"Hello, World!\") } }", arguments = listOf(), @@ -29,9 +356,556 @@ class RLPTests { payer = "01", authorizers = listOf("01") ) - val hexString = hex(tx.payloadMessage()) - println(hexString) - val rlpString = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f875f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001c0" - assertEquals(rlpString, hexString) + + val expectedPayload = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001" + + val actualPayload = tx.payloadMessage().toHexString() + assertEquals(expectedPayload, actualPayload, "Payload should match Flow JS SDK encoding") + + // Test with payload signature + val txWithSig = tx.copy( + payloadSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 4, + signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + ) + ) + ) + + val expectedEnvelope = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f899f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + val actualEnvelope = txWithSig.envelopeMessage().toHexString() + assertEquals(expectedEnvelope, actualEnvelope, "Envelope should match Flow JS SDK encoding") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompatibilityEdgeCases() { + // Test case: "empty cadence" + val emptyScriptTx = Transaction( + script = "", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01") + ) + + val expectedEmptyScript = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f84280c0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001" + assertEquals(expectedEmptyScript, emptyScriptTx.payloadMessage().toHexString()) + + // Test case: "zero computeLimit" + val zeroGasTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(0), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01") + ) + + val expectedZeroGas = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b80880000000000000001040a880000000000000001c9880000000000000001" + assertEquals(expectedZeroGas, zeroGasTx.payloadMessage().toHexString()) + + // Test case: "zero proposalKey.keyId" + val zeroKeyTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 0, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01") + ) + + val expectedZeroKey = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001800a880000000000000001c9880000000000000001" + assertEquals(expectedZeroKey, zeroKeyTx.payloadMessage().toHexString()) + + // Test case: "zero proposalKey.sequenceNum" + val zeroSeqTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(0)), + payer = "01", + authorizers = listOf("01") + ) + + val expectedZeroSeq = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a8800000000000000010480880000000000000001c9880000000000000001" + assertEquals(expectedZeroSeq, zeroSeqTx.payloadMessage().toHexString()) + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompatibilityAuthorizers() { + // Test case: "empty authorizers" + val emptyAuthTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf() + ) + + val expectedEmptyAuth = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f869b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c0" + assertEquals(expectedEmptyAuth, emptyAuthTx.payloadMessage().toHexString()) + + // Test case: "multiple authorizers" + val multiAuthTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01", "02") + ) + + val expectedMultiAuth = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f87bb07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001d2880000000000000001880000000000000002" + assertEquals(expectedMultiAuth, multiAuthTx.payloadMessage().toHexString()) + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompatibilitySignatures() { + val baseTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01"), + payloadSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 4, + signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + ) + ) + ) + + // Test case: "empty payloadSigs" + val emptyPayloadSigs = baseTx.copy(payloadSignatures = listOf()) + val expectedEmptyPayloadSigs = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f875f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001c0" + assertEquals(expectedEmptyPayloadSigs, emptyPayloadSigs.envelopeMessage().toHexString()) + + // Test case: "zero payloadSigs.0.keyId" + val zeroKeyIdSig = baseTx.copy( + payloadSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 0, + signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + ) + ) + ) + val expectedZeroKeyIdSig = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f899f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001e4e38080a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + assertEquals(expectedZeroKeyIdSig, zeroKeyIdSig.envelopeMessage().toHexString()) + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompatibilityComplexSignatures() { + // Test case: "out-of-order payloadSigs -- by signer" + val outOfOrderBySignerTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01", "02", "03"), + payloadSignatures = listOf( + TransactionSignature(address = "03", keyIndex = 0, signature = "c"), + TransactionSignature(address = "01", keyIndex = 0, signature = "a"), + TransactionSignature(address = "02", keyIndex = 0, signature = "b") + ) + ) + + val expectedOutOfOrderBySigner = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f893f884b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001db880000000000000001880000000000000002880000000000000003ccc3808080c3018080c3028080" + assertEquals(expectedOutOfOrderBySigner, outOfOrderBySignerTx.envelopeMessage().toHexString()) + + // Test case: "out-of-order payloadSigs -- by key ID" + val outOfOrderByKeyTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01"), + payloadSignatures = listOf( + TransactionSignature(address = "01", keyIndex = 2, signature = "c"), + TransactionSignature(address = "01", keyIndex = 0, signature = "a"), + TransactionSignature(address = "01", keyIndex = 1, signature = "b") + ) + ) + + val expectedOutOfOrderByKey = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f881f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001ccc3808080c3800180c3800280" + assertEquals(expectedOutOfOrderByKey, outOfOrderByKeyTx.envelopeMessage().toHexString(), "Out-of-order key signatures should be sorted") + + println("✅ All Flow JS SDK signature ordering tests passed") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompatibilityNullRefBlock() { + // Test case: "null refBlock" - Flow JS SDK allows null reference block + val nullRefBlockTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "0000000000000000000000000000000000000000000000000000000000000000", // Null/zero block ID + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01") + ) + + val expectedNullRefBlock = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a000000000000000000000000000000000000000000000000000000000000000002a880000000000000001040a880000000000000001c9880000000000000001" + assertEquals(expectedNullRefBlock, nullRefBlockTx.payloadMessage().toHexString()) + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompatibilityMissingCases() { + // Test case: Transaction with actual arguments (not just empty) + val txWithArguments = Transaction( + script = "transaction(msg: String) { execute { log(msg) } }", + arguments = listOf(Cadence.string("Hello")), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01") + ) + + // Should be able to encode with arguments + val payloadWithArgs = txWithArguments.payloadMessage() + assertTrue(payloadWithArgs.isNotEmpty(), "Should handle transaction with arguments") + + // Test case: Different payer than proposer + val multiRoleTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "02", // Different from proposer + authorizers = listOf("01") + ) + + val multiRolePayload = multiRoleTx.payloadMessage() + assertNotEquals(txWithArguments.payloadMessage().toHexString(), multiRolePayload.toHexString(), + "Different payer should produce different encoding") + + println("✅ Additional compatibility test cases passed") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompatibilityEnvelopeSignatures() { + // Test envelope signatures (not just payload signatures) + val baseTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01"), + payloadSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 4, + signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + ) + ) + ) + + // Test with envelope signatures added + val txWithEnvelopeSig = baseTx.copy( + envelopeSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 4, + signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + ) + ) + ) + + // Complete envelope message should include both payload and envelope signatures + val completeEnvelopeMsg = txWithEnvelopeSig.completeEnvelopeMessage() + assertTrue(completeEnvelopeMsg.isNotEmpty(), "Should handle envelope signatures") + + // Should be different from just payload signatures + val payloadOnlyEnvelope = baseTx.completeEnvelopeMessage() + assertNotEquals(payloadOnlyEnvelope.toHexString(), completeEnvelopeMsg.toHexString(), + "Complete envelope with envelope signatures should differ from payload-only") + + // Regular envelope message (for signing) should be the same regardless of envelope signatures + val envelopeForSigning1 = baseTx.envelopeMessage() + val envelopeForSigning2 = txWithEnvelopeSig.envelopeMessage() + assertEquals(envelopeForSigning1.toHexString(), envelopeForSigning2.toHexString(), + "Envelope message for signing should not include envelope signatures") + + println("✅ Envelope signature compatibility tests passed") + } + + @Test + fun testFlowJSSDKTransactionIdEncoding() { + + val completeTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01"), + payloadSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 4, + signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + ) + ) + ) + + val envelope = completeTx.envelopeMessage() + assertTrue(envelope.isNotEmpty(), "Transaction should produce valid envelope for ID calculation") + + println("✅ Transaction ID encoding structure verified (full implementation needed)") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompatibilityComplexAuthorizers() { + // Test complex authorizer scenarios from JS SDK + + // Test case: Multiple authorizers with different addresses + val multiAuthTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01", "02", "03") + ) + + val multiAuthPayload = multiAuthTx.payloadMessage().toHexString() + + // Should match the "multiple authorizers" expected encoding pattern + assertTrue(multiAuthPayload.contains("880000000000000001"), "Should contain proposer address") + assertTrue(multiAuthPayload.contains("880000000000000002"), "Should contain second authorizer") + assertTrue(multiAuthPayload.contains("880000000000000003"), "Should contain third authorizer") + + // Test case: Authorizer same as proposer (should be deduplicated correctly) + val overlappingAuthTx = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "02", + authorizers = listOf("01") // Same as proposer + ) + + val overlappingPayload = overlappingAuthTx.payloadMessage() + assertTrue(overlappingPayload.isNotEmpty(), "Should handle overlapping proposer/authorizer correctly") + + println("✅ Complex authorizer compatibility tests passed") + } + + /** + * Comprehensive test cases matching ALL Flow JS SDK test scenarios + * These ensure exact compatibility with Flow JavaScript SDK encoding + */ + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKCompletePayloadCompatibility() { + // Base transaction matching JS SDK test + fun createBaseTx() = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01"), + payloadSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 4, + signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + ) + ) + ) + + // Test case: "complete tx" - matches JS SDK baseTx + val completeTx = createBaseTx() + val expectedCompletePayload = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001" + val expectedCompleteEnvelope = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f899f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + assertEquals(expectedCompletePayload, completeTx.payloadMessage().toHexString(), "Complete tx payload should match JS SDK") + assertEquals(expectedCompleteEnvelope, completeTx.envelopeMessage().toHexString(), "Complete tx envelope should match JS SDK") + + // Test case: "empty cadence" + val emptyCadenceTx = completeTx.copy(script = "") + val expectedEmptyPayload = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f84280c0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001" + val expectedEmptyEnvelope = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f869f84280c0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + assertEquals(expectedEmptyPayload, emptyCadenceTx.payloadMessage().toHexString(), "Empty cadence payload should match JS SDK") + assertEquals(expectedEmptyEnvelope, emptyCadenceTx.envelopeMessage().toHexString(), "Empty cadence envelope should match JS SDK") + + // Test case: "null refBlock" + val nullRefBlockTx = completeTx.copy(referenceBlockId = "0000000000000000000000000000000000000000000000000000000000000000") + val expectedNullRefPayload = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a000000000000000000000000000000000000000000000000000000000000000002a880000000000000001040a880000000000000001c9880000000000000001" + val expectedNullRefEnvelope = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f899f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a000000000000000000000000000000000000000000000000000000000000000002a880000000000000001040a880000000000000001c9880000000000000001e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + assertEquals(expectedNullRefPayload, nullRefBlockTx.payloadMessage().toHexString(), "Null refBlock payload should match JS SDK") + assertEquals(expectedNullRefEnvelope, nullRefBlockTx.envelopeMessage().toHexString(), "Null refBlock envelope should match JS SDK") + + // Test case: "zero computeLimit" + val zeroGasTx = completeTx.copy(gasLimit = BigInteger.ZERO) + val expectedZeroGasPayload = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b80880000000000000001040a880000000000000001c9880000000000000001" + val expectedZeroGasEnvelope = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f899f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b80880000000000000001040a880000000000000001c9880000000000000001e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + assertEquals(expectedZeroGasPayload, zeroGasTx.payloadMessage().toHexString(), "Zero gas payload should match JS SDK") + assertEquals(expectedZeroGasEnvelope, zeroGasTx.envelopeMessage().toHexString(), "Zero gas envelope should match JS SDK") + + // Test case: "zero proposalKey.keyId" + val zeroKeyTx = completeTx.copy( + proposalKey = completeTx.proposalKey.copy(keyIndex = 0), + payloadSignatures = listOf( + TransactionSignature(address = "01", keyIndex = 4, signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162") + ) + ) + val expectedZeroKeyPayload = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001800a880000000000000001c9880000000000000001" + val expectedZeroKeyEnvelope = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f899f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001800a880000000000000001c9880000000000000001e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + assertEquals(expectedZeroKeyPayload, zeroKeyTx.payloadMessage().toHexString(), "Zero key payload should match JS SDK") + assertEquals(expectedZeroKeyEnvelope, zeroKeyTx.envelopeMessage().toHexString(), "Zero key envelope should match JS SDK") + + // Test case: "zero proposalKey.sequenceNum" + val zeroSeqTx = completeTx.copy(proposalKey = completeTx.proposalKey.copy(sequenceNumber = BigInteger.ZERO)) + val expectedZeroSeqPayload = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a8800000000000000010480880000000000000001c9880000000000000001" + val expectedZeroSeqEnvelope = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f899f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a8800000000000000010480880000000000000001c9880000000000000001e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + assertEquals(expectedZeroSeqPayload, zeroSeqTx.payloadMessage().toHexString(), "Zero sequence payload should match JS SDK") + assertEquals(expectedZeroSeqEnvelope, zeroSeqTx.envelopeMessage().toHexString(), "Zero sequence envelope should match JS SDK") + + // Test case: "empty authorizers" + val emptyAuthTx = completeTx.copy(authorizers = listOf()) + val expectedEmptyAuthPayload = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f869b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c0" + val expectedEmptyAuthEnvelope = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f890f869b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c0e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + assertEquals(expectedEmptyAuthPayload, emptyAuthTx.payloadMessage().toHexString(), "Empty authorizers payload should match JS SDK") + assertEquals(expectedEmptyAuthEnvelope, emptyAuthTx.envelopeMessage().toHexString(), "Empty authorizers envelope should match JS SDK") + + // Test case: "multiple authorizers" + val multiAuthTx = completeTx.copy(authorizers = listOf("01", "02")) + val expectedMultiAuthPayload = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f87bb07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001d2880000000000000001880000000000000002" + val expectedMultiAuthEnvelope = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f8a2f87bb07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001d2880000000000000001880000000000000002e4e38004a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + assertEquals(expectedMultiAuthPayload, multiAuthTx.payloadMessage().toHexString(), "Multiple authorizers payload should match JS SDK") + assertEquals(expectedMultiAuthEnvelope, multiAuthTx.envelopeMessage().toHexString(), "Multiple authorizers envelope should match JS SDK") + + println("✅ All Flow JS SDK payload compatibility tests passed") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKEnvelopeOnlyTestCases() { + // Base transaction for envelope-specific tests + fun createBaseTx() = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01"), + payloadSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 4, + signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + ) + ) + ) + + // Test case: "empty payloadSigs" + val emptyPayloadSigsTx = createBaseTx().copy(payloadSignatures = listOf()) + val expectedEmptyPayloadSigs = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f875f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001c0" + + assertEquals(expectedEmptyPayloadSigs, emptyPayloadSigsTx.envelopeMessage().toHexString(), "Empty payloadSigs should match JS SDK") + + // Test case: "zero payloadSigs.0.keyId" + val zeroPayloadKeyTx = createBaseTx().copy( + payloadSignatures = listOf( + TransactionSignature( + address = "01", + keyIndex = 0, + signature = "f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + ) + ) + ) + val expectedZeroPayloadKey = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f899f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001e4e38080a0f7225388c1d69d57e6251c9fda50cbbf9e05131e5adb81e5aa0422402f048162" + + assertEquals(expectedZeroPayloadKey, zeroPayloadKeyTx.envelopeMessage().toHexString(), "Zero payload key should match JS SDK") + + println("✅ All Flow JS SDK envelope-only test cases passed") + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun testFlowJSSDKSignatureOrderingCases() { + // These test the critical signature ordering that was causing our issues + fun createBaseTx() = Transaction( + script = "transaction { execute { log(\"Hello, World!\") } }", + arguments = listOf(), + referenceBlockId = "f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b", + gasLimit = BigInteger(42), + proposalKey = ProposalKey(address = "01", keyIndex = 4, sequenceNumber = BigInteger(10)), + payer = "01", + authorizers = listOf("01", "02", "03") + ) + + // Test case: "out-of-order payloadSigs -- by signer" + // This tests that signatures get sorted by signer index, not by the order they were added + val outOfOrderSignerTx = createBaseTx().copy( + payloadSignatures = listOf( + TransactionSignature(address = "03", keyIndex = 0, signature = "c"), // signer index 2 + TransactionSignature(address = "01", keyIndex = 0, signature = "a"), // signer index 0 + TransactionSignature(address = "02", keyIndex = 0, signature = "b") // signer index 1 + ) + ) + val expectedOutOfOrderSigner = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f893f884b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001db880000000000000001880000000000000002880000000000000003ccc3808080c3018080c3028080" + + assertEquals(expectedOutOfOrderSigner, outOfOrderSignerTx.envelopeMessage().toHexString(), "Out-of-order signer signatures should be sorted") + + // Test case: "out-of-order payloadSigs -- by key ID" + // This tests that signatures with same signer get sorted by key index + val outOfOrderKeyTx = createBaseTx().copy( + authorizers = listOf("01"), + payloadSignatures = listOf( + TransactionSignature(address = "01", keyIndex = 2, signature = "c"), + TransactionSignature(address = "01", keyIndex = 0, signature = "a"), + TransactionSignature(address = "01", keyIndex = 1, signature = "b") + ) + ) + val expectedOutOfOrderKey = "464c4f572d56302e302d7472616e73616374696f6e0000000000000000000000f881f872b07472616e73616374696f6e207b2065786563757465207b206c6f67282248656c6c6f2c20576f726c64212229207d207dc0a0f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b2a880000000000000001040a880000000000000001c9880000000000000001ccc3808080c3800180c3800280" + + assertEquals(expectedOutOfOrderKey, outOfOrderKeyTx.envelopeMessage().toHexString(), "Out-of-order key signatures should be sorted") + + println("✅ All Flow JS SDK signature ordering tests passed") } } \ No newline at end of file