From 1330f33abaaa29bb86d73c9b27cbdc08cdbfde89 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Mon, 29 Feb 2016 22:02:21 +0100 Subject: [PATCH] First pass trivial wallet: basic tracking of relevant states. --- src/main/kotlin/core/Services.kt | 20 +++++ src/main/kotlin/core/node/AbstractNode.kt | 2 +- ...tWalletService.kt => NodeWalletService.kt} | 85 ++++++++++++++++-- src/main/kotlin/core/node/TraderDemo.kt | 2 +- src/test/kotlin/core/MockServices.kt | 7 ++ .../core/node/E2ETestWalletServiceTest.kt | 46 ---------- .../kotlin/core/node/NodeWalletServiceTest.kt | 90 +++++++++++++++++++ 7 files changed, 195 insertions(+), 57 deletions(-) rename src/main/kotlin/core/node/{E2ETestWalletService.kt => NodeWalletService.kt} (52%) delete mode 100644 src/test/kotlin/core/node/E2ETestWalletServiceTest.kt create mode 100644 src/test/kotlin/core/node/NodeWalletServiceTest.kt diff --git a/src/main/kotlin/core/Services.kt b/src/main/kotlin/core/Services.kt index 44e035a08b..105d81c64b 100644 --- a/src/main/kotlin/core/Services.kt +++ b/src/main/kotlin/core/Services.kt @@ -16,6 +16,7 @@ import java.io.InputStream import java.security.KeyPair import java.security.PrivateKey import java.security.PublicKey +import java.util.* /** * This file defines various 'services' which are not currently fleshed out. A service is a module that provides @@ -45,6 +46,25 @@ interface WalletService { * keys in this wallet, you must inform the wallet service so it can update its internal state. */ val currentWallet: Wallet + + /** + * Returns a snapshot of how much cash we have in each currency, ignoring details like issuer. Note: currencies for + * which we have no cash evaluate to null, not 0. + */ + val cashBalances: Map + + /** + * Possibly update the wallet by marking as spent states that these transactions consume, and adding any relevant + * new states that they create. You should only insert transactions that have been successfully verified here! + * + * Returns the new wallet that resulted from applying the transactions (note: it may quickly become out of date). + * + * TODO: Consider if there's a good way to enforce the must-be-verified requirement in the type system. + */ + fun notifyAll(txns: Iterable): Wallet + + /** Same as notifyAll but with a single transaction. */ + fun notify(tx: WireTransaction): Wallet = notifyAll(listOf(tx)) } /** diff --git a/src/main/kotlin/core/node/AbstractNode.kt b/src/main/kotlin/core/node/AbstractNode.kt index 41c7b026ae..418e949a67 100644 --- a/src/main/kotlin/core/node/AbstractNode.kt +++ b/src/main/kotlin/core/node/AbstractNode.kt @@ -86,7 +86,7 @@ abstract class AbstractNode(val dir: Path, val configuration: NodeConfiguration, storage = makeStorageService(dir) net = makeMessagingService() smm = StateMachineManager(services, serverThread) - wallet = E2ETestWalletService(services) + wallet = NodeWalletService(services) keyManagement = E2ETestKeyManagementService() // Insert a network map entry for the timestamper: this is all temp scaffolding and will go away. If we are diff --git a/src/main/kotlin/core/node/E2ETestWalletService.kt b/src/main/kotlin/core/node/NodeWalletService.kt similarity index 52% rename from src/main/kotlin/core/node/E2ETestWalletService.kt rename to src/main/kotlin/core/node/NodeWalletService.kt index 967892165c..3c53978d15 100644 --- a/src/main/kotlin/core/node/E2ETestWalletService.kt +++ b/src/main/kotlin/core/node/NodeWalletService.kt @@ -10,6 +10,9 @@ package core.node import contracts.Cash import core.* +import core.utilities.loggerFor +import core.utilities.trace +import java.security.PublicKey import java.util.* import javax.annotation.concurrent.ThreadSafe @@ -19,7 +22,9 @@ import javax.annotation.concurrent.ThreadSafe * states relevant to us into a database and once such a wallet is implemented, this scaffolding can be removed. */ @ThreadSafe -class E2ETestWalletService(private val services: ServiceHub) : WalletService { +class NodeWalletService(private val services: ServiceHub) : WalletService { + private val log = loggerFor() + // Variables inside InnerState are protected with a lock by the ThreadBox and aren't in scope unless you're // inside mutex.locked {} code block. So we can't forget to take the lock unless we accidentally leak a reference // to wallet somewhere. @@ -30,6 +35,71 @@ class E2ETestWalletService(private val services: ServiceHub) : WalletService { override val currentWallet: Wallet get() = mutex.locked { wallet } + /** + * Returns a snapshot of how much cash we have in each currency, ignoring details like issuer. Note: currencies for + * which we have no cash evaluate to null, not 0. + */ + override val cashBalances: Map + get() = mutex.locked { wallet }.let { wallet -> + wallet.states. + // Select the states we own which are cash, ignore the rest, take the amounts. + mapNotNull { (it.state as? Cash.State)?.amount }. + // Turn into a Map> like { GBP -> (£100, £500, etc), USD -> ($2000, $50) } + groupBy { it.currency }. + // Collapse to Map by summing all the amounts of the same currency together. + mapValues { it.value.sumOrThrow() } + } + + override fun notifyAll(txns: Iterable): Wallet { + val ourKeys = services.keyManagementService.keys.keys + + // Note how terribly incomplete this all is! + // + // - We don't notify anyone of anything, there are no event listeners. + // - We don't handle or even notice invalidations due to double spends of things in our wallet. + // - We have no concept of confidence (for txns where there is no definite finality). + // - No notification that keys are used, for the case where we observe a spend of our own states. + // - No ability to create complex spends. + // - No logging or tracking of how the wallet got into this state. + // - No persistence. + // - Does tx relevancy calculation and key management need to be interlocked? Probably yes. + // + // ... and many other things .... (Wallet.java in bitcoinj is several thousand lines long) + + mutex.locked { + // Starting from the current wallet, keep applying the transaction updates, calculating a new Wallet each + // time, until we get to the result (this is perhaps a bit inefficient, but it's functional and easily + // unit tested). + wallet = txns.fold(currentWallet) { current, tx -> current.update(tx, ourKeys) } + return wallet + } + } + + private fun Wallet.update(tx: WireTransaction, ourKeys: Set): Wallet { + val ourNewStates = tx.outputs. + filterIsInstance(). + filter { it.owner in ourKeys }. + map { tx.outRef(it) } + + // Now calculate the states that are being spent by this transaction. + val consumed: Set = states.map { it.ref }.intersect(tx.inputs) + + // Is transaction irrelevant? + if (consumed.isEmpty() && ourNewStates.isEmpty()) { + log.trace { "tx ${tx.id} was irrelevant to this wallet, ignoring" } + return this + } + + // And calculate the new wallet. + val newStates = states.filter { it.ref !in consumed } + ourNewStates + + log.trace { + "Applied tx ${tx.id.prefixChars()} to the wallet: consumed ${consumed.size} states and added ${newStates.size}" + } + + return Wallet(newStates) + } + /** * Creates a random set of between (by default) 3 and 10 cash states that add up to the given amount and adds them * to the wallet. @@ -37,8 +107,11 @@ class E2ETestWalletService(private val services: ServiceHub) : WalletService { * The cash is self issued with the current nodes identity, as fetched from the storage service. Thus it * would not be trusted by any sensible market participant and is effectively an IOU. If it had been issued by * the central bank, well ... that'd be a different story altogether. + * + * TODO: Move this out of NodeWalletService */ - fun fillWithSomeTestCash(howMuch: Amount, atLeastThisManyStates: Int = 3, atMostThisManyStates: Int = 10, rng: Random = Random()) { + fun fillWithSomeTestCash(howMuch: Amount, atLeastThisManyStates: Int = 3, atMostThisManyStates: Int = 10, + rng: Random = Random()): Wallet { val amounts = calculateRandomlySizedAmounts(howMuch, atLeastThisManyStates, atMostThisManyStates, rng) val myIdentity = services.storageService.myLegalIdentity @@ -62,13 +135,7 @@ class E2ETestWalletService(private val services: ServiceHub) : WalletService { // TODO: Centralise the process of transaction acceptance and filtering into the wallet, then move this out. services.storageService.validatedTransactions.putAll(transactions.associateBy { it.id }) - val statesAndRefs = transactions.map { - StateAndRef(it.tx.outputs[0] as OwnableState, StateRef(it.id, 0)) - } - - mutex.locked { - wallet = wallet.copy(wallet.states + statesAndRefs) - } + return notifyAll(transactions.map { it.tx }) } private fun calculateRandomlySizedAmounts(howMuch: Amount, min: Int, max: Int, rng: Random): LongArray { diff --git a/src/main/kotlin/core/node/TraderDemo.kt b/src/main/kotlin/core/node/TraderDemo.kt index 631997aad7..4236b9f41e 100644 --- a/src/main/kotlin/core/node/TraderDemo.kt +++ b/src/main/kotlin/core/node/TraderDemo.kt @@ -123,7 +123,7 @@ class TraderDemoProtocolBuyer() : ProtocolLogic() { override fun call() { // Give us some cash. Note that as nodes do not currently track forward pointers, we can spend the same cash over // and over again and the double spends will never be detected! Fixing that is the next step. - (serviceHub.walletService as E2ETestWalletService).fillWithSomeTestCash(1500.DOLLARS) + (serviceHub.walletService as NodeWalletService).fillWithSomeTestCash(1500.DOLLARS) while (true) { // Wait around until a node asks to start a trade with us. In a real system, this part would happen out of band diff --git a/src/test/kotlin/core/MockServices.kt b/src/test/kotlin/core/MockServices.kt index 6f9f8009ad..f1fd64aff3 100644 --- a/src/test/kotlin/core/MockServices.kt +++ b/src/test/kotlin/core/MockServices.kt @@ -75,6 +75,13 @@ class MockKeyManagementService(vararg initialKeys: KeyPair) : KeyManagementServi } class MockWalletService(val states: List>) : WalletService { + override val cashBalances: Map + get() = TODO("Use NodeWalletService instead") + + override fun notifyAll(txns: Iterable): Wallet { + TODO("Use NodeWalletService instead") + } + override val currentWallet = Wallet(states) } diff --git a/src/test/kotlin/core/node/E2ETestWalletServiceTest.kt b/src/test/kotlin/core/node/E2ETestWalletServiceTest.kt deleted file mode 100644 index 6b0c78db08..0000000000 --- a/src/test/kotlin/core/node/E2ETestWalletServiceTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2015 Distributed Ledger Group LLC. Distributed as Licensed Company IP to DLG Group Members - * pursuant to the August 7, 2015 Advisory Services Agreement and subject to the Company IP License terms - * set forth therein. - * - * All other rights reserved. - */ - -package core.node - -import contracts.Cash -import core.DOLLARS -import core.MockKeyManagementService -import core.MockServices -import core.ServiceHub -import core.testutils.ALICE -import core.testutils.ALICE_KEY -import org.junit.Test -import java.util.* -import kotlin.test.assertEquals - -class E2ETestWalletServiceTest { - val kms = MockKeyManagementService() - val services: ServiceHub = MockServices( - keyManagement = kms - ) - - @Test fun splits() { - val wallet = E2ETestWalletService(services) - kms.nextKeys += Array(3) { ALICE_KEY } - // Fix the PRNG so that we get the same splits every time. - wallet.fillWithSomeTestCash(100.DOLLARS, 3, 3, Random(0L)) - - val w = wallet.currentWallet - assertEquals(3, w.states.size) - - val state = w.states[0].state as Cash.State - assertEquals(services.storageService.myLegalIdentity, state.deposit.party) - assertEquals(services.storageService.myLegalIdentityKey.public, state.deposit.party.owningKey) - assertEquals(29.01.DOLLARS, state.amount) - assertEquals(ALICE, state.owner) - - assertEquals(33.34.DOLLARS, (w.states[2].state as Cash.State).amount) - assertEquals(35.61.DOLLARS, (w.states[1].state as Cash.State).amount) - } -} diff --git a/src/test/kotlin/core/node/NodeWalletServiceTest.kt b/src/test/kotlin/core/node/NodeWalletServiceTest.kt new file mode 100644 index 0000000000..5c697f21ef --- /dev/null +++ b/src/test/kotlin/core/node/NodeWalletServiceTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2015 Distributed Ledger Group LLC. Distributed as Licensed Company IP to DLG Group Members + * pursuant to the August 7, 2015 Advisory Services Agreement and subject to the Company IP License terms + * set forth therein. + * + * All other rights reserved. + */ + +package core.node + +import contracts.Cash +import core.* +import core.testutils.* +import core.utilities.BriefLogFormatter +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NodeWalletServiceTest { + val kms = MockKeyManagementService(ALICE_KEY) + val services: ServiceHub = MockServices(keyManagement = kms) + + @Before + fun setUp() { + BriefLogFormatter.loggingOn(NodeWalletService::class) + } + + @After + fun tearDown() { + BriefLogFormatter.loggingOff(NodeWalletService::class) + } + + @Test + fun splits() { + val wallet = NodeWalletService(services) + kms.nextKeys += Array(3) { ALICE_KEY } + // Fix the PRNG so that we get the same splits every time. + wallet.fillWithSomeTestCash(100.DOLLARS, 3, 3, Random(0L)) + + val w = wallet.currentWallet + assertEquals(3, w.states.size) + + val state = w.states[0].state as Cash.State + assertEquals(services.storageService.myLegalIdentity, state.deposit.party) + assertEquals(services.storageService.myLegalIdentityKey.public, state.deposit.party.owningKey) + assertEquals(29.01.DOLLARS, state.amount) + assertEquals(ALICE, state.owner) + + assertEquals(33.34.DOLLARS, (w.states[2].state as Cash.State).amount) + assertEquals(35.61.DOLLARS, (w.states[1].state as Cash.State).amount) + } + + @Test + fun basics() { + val wallet = NodeWalletService(services) + + // A tx that sends us money. + val freshKey = services.keyManagementService.freshKey() + val usefulTX = TransactionBuilder().apply { + Cash().generateIssue(this, 100.DOLLARS, MEGA_CORP.ref(1), freshKey.public) + signWith(MEGA_CORP_KEY) + }.toSignedTransaction() + val myOutput = usefulTX.verifyToLedgerTransaction(MockIdentityService).outRef(0) + + // A tx that spends our money. + val spendTX = TransactionBuilder().apply { + Cash().generateSpend(this, 80.DOLLARS, BOB, listOf(myOutput)) + signWith(freshKey) + }.toSignedTransaction() + + // A tx that doesn't send us anything. + val irrelevantTX = TransactionBuilder().apply { + Cash().generateIssue(this, 100.DOLLARS, MEGA_CORP.ref(1), BOB_KEY.public) + signWith(MEGA_CORP_KEY) + }.toSignedTransaction() + + assertNull(wallet.cashBalances[USD]) + wallet.notify(usefulTX.tx) + assertEquals(100.DOLLARS, wallet.cashBalances[USD]) + wallet.notify(irrelevantTX.tx) + assertEquals(100.DOLLARS, wallet.cashBalances[USD]) + wallet.notify(spendTX.tx) + assertEquals(20.DOLLARS, wallet.cashBalances[USD]) + + // TODO: Flesh out these tests as needed. + } +}