diff --git a/src/main/kotlin/core/Transactions.kt b/src/main/kotlin/core/Transactions.kt index cd713a7557..936bb07bad 100644 --- a/src/main/kotlin/core/Transactions.kt +++ b/src/main/kotlin/core/Transactions.kt @@ -14,6 +14,7 @@ import core.serialization.serialize import java.security.KeyPair import java.security.PublicKey import java.security.SignatureException +import java.time.Clock import java.time.Duration import java.time.Instant import java.util.* @@ -63,7 +64,7 @@ data class WireTransaction(val inputStates: List, * Thrown if an attempt is made to timestamp a transaction using a trusted timestamper, but the time on the transaction * is too far in the past or future relative to the local clock and thus the timestamper would reject it. */ -class TooLateException : Exception() +class NotOnTimeException : Exception() /** A mutable transaction that's in the process of being built, before all signatures are present. */ class PartialTransaction(private val inputStates: MutableList = arrayListOf(), @@ -74,12 +75,20 @@ class PartialTransaction(private val inputStates: MutableList /** * Places a [TimestampCommand] in this transaction, removing any existing command if there is one. - * To get the right signature from the timestamping service, use the [timestamp] method. + * To get the right signature from the timestamping service, use the [timestamp] method after building is + * finished. + * + * The window of time in which the final timestamp may lie is defined as [time] +/- [timeTolerance]. + * If you want a non-symmetrical time window you must add the command via [addCommand] yourself. The tolerance + * should be chosen such that your code can finish building the transaction and sending it to the TSA within that + * window of time, taking into account factors such as network latency. Transactions being built by a group of + * collaborating parties may therefore require a higher time tolerance than a transaction being built by a single + * node. */ - fun setTime(time: Instant, authenticatedBy: Party) { + fun setTime(time: Instant, authenticatedBy: Party, timeTolerance: Duration) { check(currentSigs.isEmpty()) { "Cannot change timestamp after signing" } commands.removeAll { it.data is TimestampCommand } - addCommand(TimestampCommand(time, 30.seconds), authenticatedBy.owningKey) + addCommand(TimestampCommand(time, timeTolerance), authenticatedBy.owningKey) } /** A more convenient way to add items to this transaction that calls the add* methods for you based on type */ @@ -115,17 +124,17 @@ class PartialTransaction(private val inputStates: MutableList * * The signature of the trusted timestamper merely asserts that the time field of this transaction is valid. */ - fun timestamp(timestamper: TimestamperService) { + fun timestamp(timestamper: TimestamperService, clock: Clock = Clock.systemUTC()) { // TODO: Once we switch to a more advanced bytecode rewriting framework, we can call into a real implementation. check(timestamper.javaClass.simpleName == "DummyTimestamper") val t = time ?: throw IllegalStateException("Timestamping requested but no time was inserted into the transaction") // Obviously this is just a hard-coded dummy value for now. val maxExpectedLatency = 5.seconds - if (Duration.between(Instant.now(), t.before) > maxExpectedLatency) - throw TooLateException() + if (Duration.between(clock.instant(), t.before) > maxExpectedLatency) + throw NotOnTimeException() - // The timestamper may also throw TooLateException if our clocks are desynchronised or if we are right on the + // The timestamper may also throw NotOnTimeException if our clocks are desynchronised or if we are right on the // boundary of t.notAfter and network latency pushes us over the edge. By "synchronised" here we mean relative // to GPS time i.e. the United States Naval Observatory. val sig = timestamper.timestamp(toWireTransaction().serialize()) diff --git a/src/test/kotlin/contracts/CommercialPaperTests.kt b/src/test/kotlin/contracts/CommercialPaperTests.kt index b7d9276532..77ca095c79 100644 --- a/src/test/kotlin/contracts/CommercialPaperTests.kt +++ b/src/test/kotlin/contracts/CommercialPaperTests.kt @@ -11,7 +11,9 @@ package contracts import core.* import core.testutils.* import org.junit.Test +import java.time.Clock import java.time.Instant +import java.time.ZoneOffset import kotlin.test.assertFailsWith import kotlin.test.assertTrue @@ -72,6 +74,28 @@ class CommercialPaperTests { } } + @Test + fun `timestamp out of range`() { + // Check what happens if the timestamp on the transaction itself defines a range that doesn't include true + // time as measured by a TSA (which is running five hours ahead in this test). + CommercialPaper().craftIssue(MINI_CORP.ref(123), 10000.DOLLARS, TEST_TX_TIME + 30.days).apply { + setTime(TEST_TX_TIME, DummyTimestampingAuthority.identity, 30.seconds) + signWith(MINI_CORP_KEY) + assertFailsWith(NotOnTimeException::class) { + timestamp(DummyTimestamper(Clock.fixed(TEST_TX_TIME + 5.hours, ZoneOffset.UTC))) + } + } + // Check that it also fails if true time is before the threshold (we are trying to timestamp too early). + CommercialPaper().craftIssue(MINI_CORP.ref(123), 10000.DOLLARS, TEST_TX_TIME + 30.days).apply { + setTime(TEST_TX_TIME, DummyTimestampingAuthority.identity, 30.seconds) + signWith(MINI_CORP_KEY) + assertFailsWith(NotOnTimeException::class) { + val tsaClock = Clock.fixed(TEST_TX_TIME - 5.hours, ZoneOffset.UTC) + timestamp(DummyTimestamper(tsaClock), Clock.fixed(TEST_TX_TIME, ZoneOffset.UTC)) + } + } + } + @Test fun `issue cannot replace an existing state`() { transactionGroup { @@ -109,7 +133,7 @@ class CommercialPaperTests { // MiniCorp issues $10,000 of commercial paper, to mature in 30 days, owned initially by itself. val issueTX: LedgerTransaction = run { val ptx = CommercialPaper().craftIssue(MINI_CORP.ref(123), 10000.DOLLARS, TEST_TX_TIME + 30.days).apply { - setTime(TEST_TX_TIME, DummyTimestampingAuthority.identity) + setTime(TEST_TX_TIME, DummyTimestampingAuthority.identity, 30.seconds) signWith(MINI_CORP_KEY) timestamp(DUMMY_TIMESTAMPER) } @@ -142,7 +166,7 @@ class CommercialPaperTests { fun makeRedeemTX(time: Instant): LedgerTransaction { val ptx = PartialTransaction() - ptx.setTime(time, DummyTimestampingAuthority.identity ) + ptx.setTime(time, DummyTimestampingAuthority.identity, 30.seconds) CommercialPaper().craftRedeem(ptx, moveTX.outRef(1), corpWallet) ptx.signWith(ALICE_KEY) ptx.signWith(MINI_CORP_KEY) diff --git a/src/test/kotlin/contracts/CrowdFundTests.kt b/src/test/kotlin/contracts/CrowdFundTests.kt index 56704f3c0a..e42e3132cb 100644 --- a/src/test/kotlin/contracts/CrowdFundTests.kt +++ b/src/test/kotlin/contracts/CrowdFundTests.kt @@ -108,7 +108,7 @@ class CrowdFundTests { val registerTX: LedgerTransaction = run { // craftRegister returns a partial transaction val ptx = CrowdFund().craftRegister(MINI_CORP.ref(123), 1000.DOLLARS, "crowd funding", TEST_TX_TIME + 7.days).apply { - setTime(TEST_TX_TIME, DummyTimestampingAuthority.identity) + setTime(TEST_TX_TIME, DummyTimestampingAuthority.identity, 30.seconds) signWith(MINI_CORP_KEY) timestamp(DUMMY_TIMESTAMPER) } @@ -128,7 +128,7 @@ class CrowdFundTests { val ptx = PartialTransaction() CrowdFund().craftPledge(ptx, registerTX.outRef(0), ALICE) Cash().craftSpend(ptx, 1000.DOLLARS, MINI_CORP_PUBKEY, aliceWallet) - ptx.setTime(TEST_TX_TIME, DummyTimestampingAuthority.identity) + ptx.setTime(TEST_TX_TIME, DummyTimestampingAuthority.identity, 30.seconds) ptx.signWith(ALICE_KEY) ptx.timestamp(DUMMY_TIMESTAMPER) val stx = ptx.toSignedTransaction() @@ -144,7 +144,7 @@ class CrowdFundTests { // MiniCorp closes their campaign. fun makeFundedTX(time: Instant): LedgerTransaction { val ptx = PartialTransaction() - ptx.setTime(time, DUMMY_TIMESTAMPER.identity) + ptx.setTime(time, DUMMY_TIMESTAMPER.identity, 30.seconds) CrowdFund().craftClose(ptx, pledgeTX.outRef(0), miniCorpWallet) ptx.signWith(MINI_CORP_KEY) ptx.timestamp(DUMMY_TIMESTAMPER) diff --git a/src/test/kotlin/core/serialization/TransactionSerializationTests.kt b/src/test/kotlin/core/serialization/TransactionSerializationTests.kt index a96cda885b..add4682705 100644 --- a/src/test/kotlin/core/serialization/TransactionSerializationTests.kt +++ b/src/test/kotlin/core/serialization/TransactionSerializationTests.kt @@ -85,7 +85,7 @@ class TransactionSerializationTests { @Test fun timestamp() { - tx.setTime(TEST_TX_TIME, DUMMY_TIMESTAMPER.identity) + tx.setTime(TEST_TX_TIME, DUMMY_TIMESTAMPER.identity, 30.seconds) tx.timestamp(DUMMY_TIMESTAMPER) tx.signWith(TestUtils.keypair) val stx = tx.toSignedTransaction() diff --git a/src/test/kotlin/core/testutils/TestUtils.kt b/src/test/kotlin/core/testutils/TestUtils.kt index 468eb4651a..73e167eef7 100644 --- a/src/test/kotlin/core/testutils/TestUtils.kt +++ b/src/test/kotlin/core/testutils/TestUtils.kt @@ -78,7 +78,7 @@ class DummyTimestamper(var clock: Clock = Clock.fixed(TEST_TX_TIME, ZoneId.syste val wtx = wtxBytes.deserialize() val timestamp = wtx.commands.mapNotNull { it.data as? TimestampCommand }.single() if (Duration.between(timestamp.before, clock.instant()) > tolerance) - throw TooLateException() + throw NotOnTimeException() return DummyTimestampingAuthority.key.signWithECDSA(wtxBytes.bits, identity) } } @@ -182,7 +182,12 @@ abstract class AbstractTransactionForTest { } fun timestamp(time: Instant) { - commands.add(Command(TimestampCommand(time, 30.seconds), DUMMY_TIMESTAMPER.identity.owningKey)) + val data = TimestampCommand(time, 30.seconds) + timestamp(data) + } + + fun timestamp(data: TimestampCommand) { + commands.add(Command(data, DUMMY_TIMESTAMPER.identity.owningKey)) } // Forbid patterns like: transaction { ... transaction { ... } }