Repository: sol4k/sol4k
Pull request #139: feat: allow signing versioned transaction from pre-computed signature
Author: login: JMaio
Created: 2025-04-11T12:56:24Z | Merged: 2025-04-11T19:14:02Z

this PR adds a function `sign(ByteArray)` that allows adding a pre-computed signature to a `VersionedTransaction`. 

it will now be possible to sign a `VersionedTransaction` without needing explicit access to the signer's Keypair, enabling offline signing.


Diff:
diff --git a/src/main/kotlin/org/sol4k/Transaction.kt b/src/main/kotlin/org/sol4k/Transaction.kt
index c9e07bd..386a656 100644
--- a/src/main/kotlin/org/sol4k/Transaction.kt
+++ b/src/main/kotlin/org/sol4k/Transaction.kt
@@ -26,7 +26,7 @@ class Transaction(
         signatures.add(Base58.encode(signature))
     }
 
-    private fun addSignature(signature: String) {
+    fun addSignature(signature: String) {
         signatures.add(signature)
     }
 
diff --git a/src/main/kotlin/org/sol4k/VersionedTransaction.kt b/src/main/kotlin/org/sol4k/VersionedTransaction.kt
index 70de482..a0e35a1 100644
--- a/src/main/kotlin/org/sol4k/VersionedTransaction.kt
+++ b/src/main/kotlin/org/sol4k/VersionedTransaction.kt
@@ -20,6 +20,17 @@ class VersionedTransaction private constructor(
     fun sign(keypair: Keypair) {
         val data = message.serialize()
         val signature = keypair.sign(data)
+        addSignature(signature, data)
+    }
+
+    fun addSignature(signature: String) {
+        val signatureBytes = Base58.decode(signature)
+        addSignature(signatureBytes)
+    }
+
+    private fun addSignature(signature: ByteArray, serializedMessage: ByteArray? = null) {
+        val data = serializedMessage ?: message.serialize()
+
         for (i in 0 until message.header.numRequireSignatures) {
             val a = message.accounts[i]
             if (a.verify(signature, data)) {
diff --git a/src/test/kotlin/org/sol4k/VersionedTransactionTest.kt b/src/test/kotlin/org/sol4k/VersionedTransactionTest.kt
index 48907ee..97f42c9 100644
--- a/src/test/kotlin/org/sol4k/VersionedTransactionTest.kt
+++ b/src/test/kotlin/org/sol4k/VersionedTransactionTest.kt
@@ -82,4 +82,44 @@ class VersionedTransactionTest {
         val fee = tx.calculateFee(5000)
         assertEquals(BigDecimal("0.000005000"), fee)
     }
+
+    @Test
+    fun shouldSignTransactionFromKeypairOrSignature() {
+        val signer1 = Keypair.generate()
+        val signer2 = Keypair.generate()
+        val blockhash = "GYQReb5N3KWsM7x7aboAGTb6kQSxDGRZ1S42N6RTNkgS"
+        val accounts = listOf(
+            AccountMeta.signerAndWritable(signer1.publicKey),
+            AccountMeta.signerAndWritable(signer2.publicKey),
+            AccountMeta.writable(Keypair.generate().publicKey),
+        )
+        val instruction = BaseInstruction(
+            ByteBuffer.allocate(8).array(),
+            accounts,
+            Keypair.generate().publicKey,
+        )
+        val message = TransactionMessage.newMessage(signer1.publicKey, blockhash, instruction)
+        val transaction1 = VersionedTransaction(message)
+        val transaction2 = VersionedTransaction(message)
+
+        // assume no private key is exposed, only the final signature is provided
+        val data = message.serialize()
+        val sig1 = Base58.encode(signer1.sign(data))
+        val sig2 = Base58.encode(signer2.sign(data))
+
+        transaction1.addSignature(sig1)
+        transaction1.addSignature(sig2)
+
+        val signatures = transaction1.signatures
+        assertEquals(2, signatures.size)
+        assertEquals(2, signatures.filterNotNull().size)
+        assertTrue(signer1.publicKey.verify(Base58.decode(signatures[0]!!), message.serialize()))
+        assertTrue(signer2.publicKey.verify(Base58.decode(signatures[1]!!), message.serialize()))
+
+        // regular keypair-based signing
+        transaction2.sign(signer1)
+        transaction2.sign(signer2)
+
+        assertTrue(transaction1.serialize().contentEquals(transaction2.serialize()))
+    }
 }


Reviews:
user: login: Shpota
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-11T16:06:02Z
user: login: JMaio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T16:27:13Z
user: login: Shpota
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T16:29:49Z
user: login: JMaio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T16:45:36Z
user: login: Shpota
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T17:36:44Z
user: login: JMaio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T18:14:50Z

Review comments:
diff_hunk: @@ -20,6 +20,12 @@ class VersionedTransaction private constructor(
     fun sign(keypair: Keypair) {
         val data = message.serialize()
         val signature = keypair.sign(data)
+        sign(signature, data)
+    }
+
+    fun sign(signature: ByteArray, serializedMessage: ByteArray? = null) {
path: src/main/kotlin/org/sol4k/VersionedTransaction.kt
user: login: Shpota
body: 1. Please rename it to `addSignature` to be explicit what's happening.
2. What is the purpose of `serializedMessage`? There is already message available that can be serialized. Let's remove it.
3. Would you like to add the corresponding method to `Transaction` as well?
created_at: 2025-04-11T15:32:40Z
author_association: MEMBER
diff_hunk: @@ -20,6 +20,12 @@ class VersionedTransaction private constructor(
     fun sign(keypair: Keypair) {
         val data = message.serialize()
         val signature = keypair.sign(data)
+        sign(signature, data)
+    }
+
+    fun sign(signature: ByteArray, serializedMessage: ByteArray? = null) {
path: src/main/kotlin/org/sol4k/VersionedTransaction.kt
user: login: JMaio
body: thanks for the comments! 

1. ✅ renamed
2. `serializedMessage` was supposed to be passed in from the existing `sign` call so as to not serialize the message twice when using a `Keypair`. would you prefer that `sign` uses `addSignature`, or duplicate the code?
3. it looks like the `addSignature` method already exists in `Transaction`, maybe it could be made public instead of private? https://github.com/sol4k/sol4k/blob/476ec408d7890f467d6ada2e5d8dc2f16829914e/src/main/kotlin/org/sol4k/Transaction.kt#L29-L31
created_at: 2025-04-11T16:27:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,6 +20,12 @@ class VersionedTransaction private constructor(
     fun sign(keypair: Keypair) {
         val data = message.serialize()
         val signature = keypair.sign(data)
+        sign(signature, data)
+    }
+
+    fun sign(signature: ByteArray, serializedMessage: ByteArray? = null) {
path: src/main/kotlin/org/sol4k/VersionedTransaction.kt
user: login: Shpota
body: 2. Let's remove it please. 
3. Thanks.
created_at: 2025-04-11T16:29:49Z
author_association: MEMBER
diff_hunk: @@ -20,6 +20,12 @@ class VersionedTransaction private constructor(
     fun sign(keypair: Keypair) {
         val data = message.serialize()
         val signature = keypair.sign(data)
+        sign(signature, data)
+    }
+
+    fun sign(signature: ByteArray, serializedMessage: ByteArray? = null) {
path: src/main/kotlin/org/sol4k/VersionedTransaction.kt
user: login: JMaio
body: 2. ✅ removed
3. ✅ made `Transaction::addSignature` public
created_at: 2025-04-11T16:45:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,6 +20,12 @@ class VersionedTransaction private constructor(
     fun sign(keypair: Keypair) {
         val data = message.serialize()
         val signature = keypair.sign(data)
+        sign(signature, data)
+    }
+
+    fun sign(signature: ByteArray, serializedMessage: ByteArray? = null) {
path: src/main/kotlin/org/sol4k/VersionedTransaction.kt
user: login: Shpota
body: Thank you. Could you please adjust `addSignature` to consume `String` instead of `ByteArray`? You can also add a private `addSignature` method that can consume a signature `ByteArray` and a data `ByteArray` (so that the message is not serialized two times and you don't have to convert String to `ByteArray` when calling `addSignature()` from `sign()`) 
created_at: 2025-04-11T17:36:44Z
author_association: MEMBER
diff_hunk: @@ -20,6 +20,12 @@ class VersionedTransaction private constructor(
     fun sign(keypair: Keypair) {
         val data = message.serialize()
         val signature = keypair.sign(data)
+        sign(signature, data)
+    }
+
+    fun sign(signature: ByteArray, serializedMessage: ByteArray? = null) {
path: src/main/kotlin/org/sol4k/VersionedTransaction.kt
user: login: JMaio
body: updated `addSignature` to consume a base58 `String` and adjusted the private method to access the serialized message
created_at: 2025-04-11T18:14:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: Shpota
created_at: 2025-04-11T19:24:14Z
body: Thank you, @JMaio. The changes are released in 0.5.12. They will be available within an hour from Maven Central.
author_association: MEMBER
user: login: JMaio
created_at: 2025-04-11T19:28:46Z
body: @Shpota thank you for the quick review and comments, looking forward to adding some more contributions! 😄 
author_association: CONTRIBUTOR