diff --git a/library/src/androidTest/java/org/xmtp/android/library/ClientTest.kt b/library/src/androidTest/java/org/xmtp/android/library/ClientTest.kt index 45b0eb209..5c46ea650 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/ClientTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/ClientTest.kt @@ -331,7 +331,74 @@ class ClientTest { listOf(fixtures.boClient.inboxId, fixtures.caroClient.inboxId) ) } - assertEquals(states.first().recoveryAddress.lowercase(), fixtures.bo.walletAddress.lowercase()) - assertEquals(states.last().recoveryAddress.lowercase(), fixtures.caro.walletAddress.lowercase()) + assertEquals( + states.first().recoveryAddress.lowercase(), + fixtures.bo.walletAddress.lowercase() + ) + assertEquals( + states.last().recoveryAddress.lowercase(), + fixtures.caro.walletAddress.lowercase() + ) + } + + @Test + fun testAddAccounts() { + val fixtures = fixtures() + val alix2Wallet = PrivateKeyBuilder() + val alix3Wallet = PrivateKeyBuilder() + runBlocking { fixtures.alixClient.addAccount(fixtures.alixAccount, alix2Wallet) } + runBlocking { fixtures.alixClient.addAccount(fixtures.alixAccount, alix3Wallet) } + + val state = runBlocking { fixtures.alixClient.inboxState(true) } + assertEquals(state.installations.size, 1) + assertEquals(state.addresses.size, 3) + assertEquals(state.recoveryAddress, fixtures.alixClient.address.lowercase()) + assertEquals( + state.addresses.sorted(), + listOf( + alix2Wallet.address.lowercase(), + alix3Wallet.address.lowercase(), + fixtures.alixClient.address.lowercase() + ).sorted() + ) + } + + @Test + fun testRemovingAccounts() { + val fixtures = fixtures() + val alix2Wallet = PrivateKeyBuilder() + val alix3Wallet = PrivateKeyBuilder() + runBlocking { fixtures.alixClient.addAccount(fixtures.alixAccount, alix2Wallet) } + runBlocking { fixtures.alixClient.addAccount(fixtures.alixAccount, alix3Wallet) } + + var state = runBlocking { fixtures.alixClient.inboxState(true) } + assertEquals(state.addresses.size, 3) + assertEquals(state.recoveryAddress, fixtures.alixClient.address.lowercase()) + + runBlocking { fixtures.alixClient.removeAccount(fixtures.alixAccount, alix2Wallet.address) } + state = runBlocking { fixtures.alixClient.inboxState(true) } + assertEquals(state.addresses.size, 2) + assertEquals(state.recoveryAddress, fixtures.alixClient.address.lowercase()) + assertEquals( + state.addresses.sorted(), + listOf( + alix3Wallet.address.lowercase(), + fixtures.alixClient.address.lowercase() + ).sorted() + ) + assertEquals(state.installations.size, 1) + + // Cannot remove the recovery address + assertThrows( + "Client error: Unknown Signer", + GenericException::class.java + ) { + runBlocking { + fixtures.alixClient.removeAccount( + alix3Wallet, + fixtures.alixClient.address + ) + } + } } } diff --git a/library/src/androidTest/java/org/xmtp/android/library/SmartContractWalletTest.kt b/library/src/androidTest/java/org/xmtp/android/library/SmartContractWalletTest.kt index b10c31c1f..2db2485ca 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/SmartContractWalletTest.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/SmartContractWalletTest.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.BeforeClass import org.junit.Test @@ -14,6 +15,7 @@ import org.xmtp.android.library.libxmtp.Message import org.xmtp.android.library.messages.PrivateKey import org.xmtp.android.library.messages.PrivateKeyBuilder import org.xmtp.android.library.messages.walletAddress +import uniffi.xmtpv3.GenericException @RunWith(AndroidJUnit4::class) class SmartContractWalletTest { @@ -23,9 +25,9 @@ class SmartContractWalletTest { private lateinit var eriSCW: FakeSCWWallet private lateinit var eriSCWClient: Client private lateinit var options: ClientOptions - private lateinit var boV3Wallet: PrivateKeyBuilder - private lateinit var boV3: PrivateKey - private lateinit var boV3Client: Client + private lateinit var boEOAWallet: PrivateKeyBuilder + private lateinit var boEOA: PrivateKey + private lateinit var boEOAClient: Client @BeforeClass @JvmStatic @@ -44,11 +46,11 @@ class SmartContractWalletTest { ) // EOA - boV3Wallet = PrivateKeyBuilder() - boV3 = boV3Wallet.getPrivateKey() - boV3Client = runBlocking { + boEOAWallet = PrivateKeyBuilder() + boEOA = boEOAWallet.getPrivateKey() + boEOAClient = runBlocking { Client().create( - account = boV3Wallet, + account = boEOAWallet, options = options ) } @@ -85,10 +87,58 @@ class SmartContractWalletTest { assertEquals(davonSCWClient.inboxId, davonSCWClient2.inboxId) } + @Test + fun testAddAndRemovingAccounts() { + val davonEOA = PrivateKeyBuilder() + val davonSCW2 = FakeSCWWallet.generate(ANVIL_TEST_PRIVATE_KEY_3) + + runBlocking { davonSCWClient.addAccount(davonSCW, davonEOA) } + runBlocking { davonSCWClient.addAccount(davonSCW, davonSCW2) } + + var state = runBlocking { davonSCWClient.inboxState(true) } + assertEquals(state.installations.size, 1) + assertEquals(state.addresses.size, 3) + assertEquals(state.recoveryAddress, davonSCWClient.address.lowercase()) + assertEquals( + state.addresses.sorted(), + listOf( + davonEOA.address.lowercase(), + davonSCW2.address.lowercase(), + davonSCWClient.address.lowercase() + ).sorted() + ) + + runBlocking { davonSCWClient.removeAccount(davonSCW, davonSCW2.address) } + state = runBlocking { davonSCWClient.inboxState(true) } + assertEquals(state.addresses.size, 2) + assertEquals(state.recoveryAddress, davonSCWClient.address.lowercase()) + assertEquals( + state.addresses.sorted(), + listOf( + davonEOA.address.lowercase(), + davonSCWClient.address.lowercase() + ).sorted() + ) + assertEquals(state.installations.size, 1) + + // Cannot remove the recovery address + Assert.assertThrows( + "Client error: Unknown Signer", + GenericException::class.java + ) { + runBlocking { + davonSCWClient.removeAccount( + davonEOA, + davonSCWClient.address + ) + } + } + } + @Test fun testsCanCreateGroup() { val group1 = runBlocking { - boV3Client.conversations.newGroup( + boEOAClient.conversations.newGroup( listOf( davonSCW.walletAddress, eriSCW.walletAddress @@ -98,7 +148,7 @@ class SmartContractWalletTest { val group2 = runBlocking { davonSCWClient.conversations.newGroup( listOf( - boV3.walletAddress, + boEOA.walletAddress, eriSCW.walletAddress ) ) @@ -106,18 +156,18 @@ class SmartContractWalletTest { assertEquals( runBlocking { group1.members().map { it.inboxId }.sorted() }, - listOf(davonSCWClient.inboxId, boV3Client.inboxId, eriSCWClient.inboxId).sorted() + listOf(davonSCWClient.inboxId, boEOAClient.inboxId, eriSCWClient.inboxId).sorted() ) assertEquals( runBlocking { group2.members().map { it.addresses.first() }.sorted() }, - listOf(davonSCWClient.address, boV3Client.address, eriSCWClient.address).sorted() + listOf(davonSCWClient.address, boEOAClient.address, eriSCWClient.address).sorted() ) } @Test fun testsCanSendMessages() { val boGroup = runBlocking { - boV3Client.conversations.newGroup( + boEOAClient.conversations.newGroup( listOf( davonSCW.walletAddress, eriSCW.walletAddress @@ -156,7 +206,7 @@ class SmartContractWalletTest { val davonGroup = runBlocking { davonSCWClient.conversations.newGroup( listOf( - boV3.walletAddress, + boEOA.walletAddress, eriSCW.walletAddress ) ) @@ -197,46 +247,46 @@ class SmartContractWalletTest { val davonGroup = runBlocking { davonSCWClient.conversations.newGroup( listOf( - boV3.walletAddress, + boEOA.walletAddress, eriSCW.walletAddress ) ) } assertEquals( - davonSCWClient.preferences.consentList.inboxIdState(boV3Client.inboxId), + davonSCWClient.preferences.consentList.inboxIdState(boEOAClient.inboxId), ConsentState.UNKNOWN ) davonSCWClient.preferences.consentList.setConsentState( listOf( ConsentListEntry( - boV3Client.inboxId, + boEOAClient.inboxId, EntryType.INBOX_ID, ConsentState.ALLOWED ) ) ) - var alixMember = davonGroup.members().firstOrNull { it.inboxId == boV3Client.inboxId } + var alixMember = davonGroup.members().firstOrNull { it.inboxId == boEOAClient.inboxId } assertEquals(alixMember!!.consentState, ConsentState.ALLOWED) assertEquals( - davonSCWClient.preferences.consentList.inboxIdState(boV3Client.inboxId), + davonSCWClient.preferences.consentList.inboxIdState(boEOAClient.inboxId), ConsentState.ALLOWED ) davonSCWClient.preferences.consentList.setConsentState( listOf( ConsentListEntry( - boV3Client.inboxId, + boEOAClient.inboxId, EntryType.INBOX_ID, ConsentState.DENIED ) ) ) - alixMember = davonGroup.members().firstOrNull { it.inboxId == boV3Client.inboxId } + alixMember = davonGroup.members().firstOrNull { it.inboxId == boEOAClient.inboxId } assertEquals(alixMember!!.consentState, ConsentState.DENIED) assertEquals( - davonSCWClient.preferences.consentList.inboxIdState(boV3Client.inboxId), + davonSCWClient.preferences.consentList.inboxIdState(boEOAClient.inboxId), ConsentState.DENIED ) @@ -267,13 +317,13 @@ class SmartContractWalletTest { val group1 = runBlocking { davonSCWClient.conversations.newGroup( listOf( - boV3.walletAddress, + boEOA.walletAddress, eriSCW.walletAddress ) ) } val group2 = runBlocking { - boV3Client.conversations.newGroup( + boEOAClient.conversations.newGroup( listOf( davonSCW.walletAddress, eriSCW.walletAddress @@ -281,7 +331,7 @@ class SmartContractWalletTest { ) } val dm1 = runBlocking { davonSCWClient.conversations.findOrCreateDm(eriSCW.walletAddress) } - val dm2 = runBlocking { boV3Client.conversations.findOrCreateDm(davonSCW.walletAddress) } + val dm2 = runBlocking { boEOAClient.conversations.findOrCreateDm(davonSCW.walletAddress) } runBlocking { davonSCWClient.conversations.sync() } val allMessages = mutableListOf() @@ -323,10 +373,10 @@ class SmartContractWalletTest { Thread.sleep(1000) runBlocking { - eriSCWClient.conversations.newGroup(listOf(boV3.walletAddress, davonSCW.walletAddress)) - boV3Client.conversations.newGroup(listOf(eriSCW.walletAddress, davonSCW.walletAddress)) + eriSCWClient.conversations.newGroup(listOf(boEOA.walletAddress, davonSCW.walletAddress)) + boEOAClient.conversations.newGroup(listOf(eriSCW.walletAddress, davonSCW.walletAddress)) eriSCWClient.conversations.findOrCreateDm(davonSCW.walletAddress) - boV3Client.conversations.findOrCreateDm(davonSCW.walletAddress) + boEOAClient.conversations.findOrCreateDm(davonSCW.walletAddress) } Thread.sleep(1000) diff --git a/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt b/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt index 78fcfde49..430128966 100644 --- a/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt +++ b/library/src/androidTest/java/org/xmtp/android/library/TestHelpers.kt @@ -55,6 +55,8 @@ const val ANVIL_TEST_PRIVATE_KEY_1 = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" const val ANVIL_TEST_PRIVATE_KEY_2 = "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" +const val ANVIL_TEST_PRIVATE_KEY_3 = + "5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" private const val ANVIL_TEST_PORT = "http://10.0.2.2:8545" class FakeSCWWallet : SigningKey { diff --git a/library/src/main/java/org/xmtp/android/library/Client.kt b/library/src/main/java/org/xmtp/android/library/Client.kt index e2dc101f5..60deb0e1a 100644 --- a/library/src/main/java/org/xmtp/android/library/Client.kt +++ b/library/src/main/java/org/xmtp/android/library/Client.kt @@ -9,6 +9,7 @@ import org.xmtp.android.library.libxmtp.XMTPLogger import org.xmtp.android.library.messages.rawData import uniffi.xmtpv3.FfiConversationType import uniffi.xmtpv3.FfiDeviceSyncKind +import uniffi.xmtpv3.FfiSignatureRequest import uniffi.xmtpv3.FfiXmtpClient import uniffi.xmtpv3.createClient import uniffi.xmtpv3.generateInboxId @@ -183,31 +184,54 @@ class Client() { } } ffiClient.signatureRequest()?.let { signatureRequest -> - if (signingKey != null) { - if (signingKey.type == WalletType.SCW) { - val chainId = signingKey.chainId - ?: throw XMTPException("ChainId is required for smart contract wallets") - signatureRequest.addScwSignature( - signingKey.signSCW(signatureRequest.signatureText()), - signingKey.address.lowercase(), - chainId.toULong(), - signingKey.blockNumber?.toULong() - ) - } else { - signingKey.sign(signatureRequest.signatureText())?.let { - signatureRequest.addEcdsaSignature(it.rawData) - } - } - - ffiClient.registerIdentity(signatureRequest) - } else { - throw XMTPException("No signer passed but signer was required.") - } + signingKey?.let { handleSignature(signatureRequest, it) } + ?: throw XMTPException("No signer passed but signer was required.") + ffiClient.registerIdentity(signatureRequest) } return Pair(ffiClient, dbPath) } + suspend fun revokeAllOtherInstallations(signingKey: SigningKey) { + val signatureRequest = ffiClient.revokeAllOtherInstallations() + handleSignature(signatureRequest, signingKey) + ffiClient.applySignatureRequest(signatureRequest) + } + + suspend fun addAccount(recoverAccount: SigningKey, newAccount: SigningKey) { + val signatureRequest = + ffiClient.addWallet(address.lowercase(), newAccount.address.lowercase()) + handleSignature(signatureRequest, recoverAccount) + handleSignature(signatureRequest, newAccount) + ffiClient.applySignatureRequest(signatureRequest) + } + + suspend fun removeAccount(recoverAccount: SigningKey, addressToRemove: String) { + val signatureRequest = ffiClient.revokeWallet(addressToRemove.lowercase()) + handleSignature(signatureRequest, recoverAccount) + ffiClient.applySignatureRequest(signatureRequest) + } + + private suspend fun handleSignature( + signatureRequest: FfiSignatureRequest, + signingKey: SigningKey, + ) { + if (signingKey.type == WalletType.SCW) { + val chainId = signingKey.chainId + ?: throw XMTPException("ChainId is required for smart contract wallets") + signatureRequest.addScwSignature( + signingKey.signSCW(signatureRequest.signatureText()), + signingKey.address.lowercase(), + chainId.toULong(), + signingKey.blockNumber?.toULong() + ) + } else { + signingKey.sign(signatureRequest.signatureText())?.let { + signatureRequest.addEcdsaSignature(it.rawData) + } + } + } + fun signWithInstallationKey(message: String): ByteArray { return ffiClient.signWithInstallationKey(message) } @@ -303,14 +327,6 @@ class Client() { ffiClient.sendSyncRequest(FfiDeviceSyncKind.CONSENT) } - suspend fun revokeAllOtherInstallations(signingKey: SigningKey) { - val signatureRequest = ffiClient.revokeAllOtherInstallations() - signingKey.sign(signatureRequest.signatureText())?.let { - signatureRequest.addEcdsaSignature(it.rawData) - ffiClient.applySignatureRequest(signatureRequest) - } - } - suspend fun inboxStatesForInboxIds( refreshFromNetwork: Boolean, inboxIds: List,