mirror of
https://github.com/corda/corda.git
synced 2025-01-21 03:55:00 +00:00
Added new project core.
Project Contracts depends only on core.
This commit is contained in:
parent
00977c0d83
commit
994abb3edb
5
.gitignore
vendored
5
.gitignore
vendored
@ -4,6 +4,8 @@ TODO
|
||||
|
||||
.gradle
|
||||
/build/
|
||||
/contracts/build
|
||||
/core/build
|
||||
/docs/build/doctrees
|
||||
|
||||
buyer
|
||||
@ -62,5 +64,4 @@ atlassian-ide-plugin.xml
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
|
||||
crashlytics-build.properties
|
@ -55,6 +55,8 @@ configurations.all() {
|
||||
dependencies {
|
||||
testCompile 'junit:junit:4.12'
|
||||
|
||||
compile project(':contracts')
|
||||
|
||||
compile "com.google.code.findbugs:jsr305:3.0.1"
|
||||
compile "org.slf4j:slf4j-jdk14:1.7.13"
|
||||
|
||||
|
@ -70,5 +70,5 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compile rootProject
|
||||
compile project(':core')
|
||||
}
|
@ -1,160 +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 contracts
|
||||
|
||||
import core.*
|
||||
import core.crypto.NullPublicKey
|
||||
import core.crypto.SecureHash
|
||||
import core.crypto.toStringShort
|
||||
import core.utilities.Emoji
|
||||
import java.security.PublicKey
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* This is an ultra-trivial implementation of commercial paper, which is essentially a simpler version of a corporate
|
||||
* bond. It can be seen as a company-specific currency. A company issues CP with a particular face value, say $100,
|
||||
* but sells it for less, say $90. The paper can be redeemed for cash at a given date in the future. Thus this example
|
||||
* would have a 10% interest rate with a single repayment. Commercial paper is often rolled over (the maturity date
|
||||
* is adjusted as if the paper was redeemed and immediately repurchased, but without having to front the cash).
|
||||
*
|
||||
* This contract is not intended to realistically model CP. It is here only to act as a next step up above cash in
|
||||
* the prototyping phase. It is thus very incomplete.
|
||||
*
|
||||
* Open issues:
|
||||
* - In this model, you cannot merge or split CP. Can you do this normally? We could model CP as a specialised form
|
||||
* of cash, or reuse some of the cash code? Waiting on response from Ayoub and Rajar about whether CP can always
|
||||
* be split/merged or only in secondary markets. Even if current systems can't do this, would it be a desirable
|
||||
* feature to have anyway?
|
||||
* - The funding steps of CP is totally ignored in this model.
|
||||
* - No attention is paid to the existing roles of custodians, funding banks, etc.
|
||||
* - There are regional variations on the CP concept, for instance, American CP requires a special "CUSIP number"
|
||||
* which may need to be tracked. That, in turn, requires validation logic (there is a bean validator that knows how
|
||||
* to do this in the Apache BVal project).
|
||||
*/
|
||||
|
||||
val CP_PROGRAM_ID = SecureHash.sha256("replace-me-later-with-bytecode-hash")
|
||||
|
||||
// TODO: Generalise the notion of an owned instrument into a superclass/supercontract. Consider composition vs inheritance.
|
||||
class CommercialPaper : Contract {
|
||||
// TODO: should reference the content of the legal agreement, not its URI
|
||||
override val legalContractReference: SecureHash = SecureHash.sha256("https://en.wikipedia.org/wiki/Commercial_paper")
|
||||
|
||||
data class State(
|
||||
val issuance: PartyReference,
|
||||
override val owner: PublicKey,
|
||||
val faceValue: Amount,
|
||||
val maturityDate: Instant
|
||||
) : OwnableState, ICommercialPaperState {
|
||||
override val programRef = CP_PROGRAM_ID
|
||||
|
||||
fun withoutOwner() = copy(owner = NullPublicKey)
|
||||
override fun withNewOwner(newOwner: PublicKey) = Pair(Commands.Move(), copy(owner = newOwner))
|
||||
override fun toString() = "${Emoji.newspaper}CommercialPaper(of $faceValue redeemable on $maturityDate by '$issuance', owned by ${owner.toStringShort()})"
|
||||
|
||||
// Although kotlin is smart enough not to need these, as we are using the ICommercialPaperState, we need to declare them explicitly for use later,
|
||||
override fun withOwner(newOwner: PublicKey): ICommercialPaperState = copy(owner = newOwner)
|
||||
override fun withIssuance(newIssuance: PartyReference): ICommercialPaperState = copy(issuance = newIssuance)
|
||||
override fun withFaceValue(newFaceValue: Amount): ICommercialPaperState = copy(faceValue = newFaceValue)
|
||||
override fun withMaturityDate(newMaturityDate: Instant): ICommercialPaperState = copy(maturityDate = newMaturityDate)
|
||||
}
|
||||
|
||||
interface Commands : CommandData {
|
||||
class Move : TypeOnlyCommandData(), Commands
|
||||
class Redeem : TypeOnlyCommandData(), Commands
|
||||
// We don't need a nonce in the issue command, because the issuance.reference field should already be unique per CP.
|
||||
// However, nothing in the platform enforces that uniqueness: it's up to the issuer.
|
||||
class Issue : TypeOnlyCommandData(), Commands
|
||||
}
|
||||
|
||||
override fun verify(tx: TransactionForVerification) {
|
||||
// Group by everything except owner: any modification to the CP at all is considered changing it fundamentally.
|
||||
val groups = tx.groupStates<State>() { it.withoutOwner() }
|
||||
|
||||
// There are two possible things that can be done with this CP. The first is trading it. The second is redeeming
|
||||
// it for cash on or after the maturity date.
|
||||
val command = tx.commands.requireSingleCommand<CommercialPaper.Commands>()
|
||||
val timestamp: TimestampCommand? = tx.getTimestampBy(DummyTimestampingAuthority.identity)
|
||||
|
||||
for (group in groups) {
|
||||
when (command.value) {
|
||||
is Commands.Move -> {
|
||||
val input = group.inputs.single()
|
||||
requireThat {
|
||||
"the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner))
|
||||
"the state is propagated" by (group.outputs.size == 1)
|
||||
}
|
||||
}
|
||||
|
||||
is Commands.Redeem -> {
|
||||
val input = group.inputs.single()
|
||||
val received = tx.outStates.sumCashBy(input.owner)
|
||||
val time = timestamp?.after ?: throw IllegalArgumentException("Redemptions must be timestamped")
|
||||
requireThat {
|
||||
"the paper must have matured" by (time > input.maturityDate)
|
||||
"the received amount equals the face value" by (received == input.faceValue)
|
||||
"the paper must be destroyed" by group.outputs.isEmpty()
|
||||
"the transaction is signed by the owner of the CP" by (command.signers.contains(input.owner))
|
||||
}
|
||||
}
|
||||
|
||||
is Commands.Issue -> {
|
||||
val output = group.outputs.single()
|
||||
val time = timestamp?.before ?: throw IllegalArgumentException("Issuances must be timestamped")
|
||||
requireThat {
|
||||
// Don't allow people to issue commercial paper under other entities identities.
|
||||
"the issuance is signed by the claimed issuer of the paper" by
|
||||
(command.signers.contains(output.issuance.party.owningKey))
|
||||
"the face value is not zero" by (output.faceValue.pennies > 0)
|
||||
"the maturity date is not in the past" by (time < output.maturityDate)
|
||||
// Don't allow an existing CP state to be replaced by this issuance.
|
||||
"there is no input state" by group.inputs.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Think about how to evolve contracts over time with new commands.
|
||||
else -> throw IllegalArgumentException("Unrecognised command")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a transaction that issues commercial paper, owned by the issuing parties key. Does not update
|
||||
* an existing transaction because you aren't able to issue multiple pieces of CP in a single transaction
|
||||
* at the moment: this restriction is not fundamental and may be lifted later.
|
||||
*/
|
||||
fun generateIssue(issuance: PartyReference, faceValue: Amount, maturityDate: Instant): TransactionBuilder {
|
||||
val state = State(issuance, issuance.party.owningKey, faceValue, maturityDate)
|
||||
return TransactionBuilder().withItems(state, Command(Commands.Issue(), issuance.party.owningKey))
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the given partial transaction with an input/output/command to reassign ownership of the paper.
|
||||
*/
|
||||
fun generateMove(tx: TransactionBuilder, paper: StateAndRef<State>, newOwner: PublicKey) {
|
||||
tx.addInputState(paper.ref)
|
||||
tx.addOutputState(paper.state.copy(owner = newOwner))
|
||||
tx.addCommand(Commands.Move(), paper.state.owner)
|
||||
}
|
||||
|
||||
/**
|
||||
* Intended to be called by the issuer of some commercial paper, when an owner has notified us that they wish
|
||||
* to redeem the paper. We must therefore send enough money to the key that owns the paper to satisfy the face
|
||||
* value, and then ensure the paper is removed from the ledger.
|
||||
*
|
||||
* @throws InsufficientBalanceException if the wallet doesn't contain enough money to pay the redeemer
|
||||
*/
|
||||
@Throws(InsufficientBalanceException::class)
|
||||
fun generateRedeem(tx: TransactionBuilder, paper: StateAndRef<State>, wallet: List<StateAndRef<Cash.State>>) {
|
||||
// Add the cash movement using the states in our wallet.
|
||||
Cash().generateSpend(tx, paper.state.faceValue, paper.state.owner, wallet)
|
||||
tx.addInputState(paper.ref)
|
||||
tx.addCommand(CommercialPaper.Commands.Redeem(), paper.state.owner)
|
||||
}
|
||||
}
|
||||
|
23
core/build.gradle
Normal file
23
core/build.gradle
Normal file
@ -0,0 +1,23 @@
|
||||
group 'com.r3cev.prototyping'
|
||||
version '1.0-SNAPSHOT'
|
||||
|
||||
apply plugin: 'java'
|
||||
apply plugin: 'kotlin'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
compile "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
||||
compile "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
|
||||
|
||||
compile "org.slf4j:slf4j-jdk14:1.7.13"
|
||||
|
||||
compile("com.google.guava:guava:19.0")
|
||||
|
||||
// Quasar: for the bytecode rewriting for state machines.
|
||||
compile("co.paralleluniverse:quasar-core:${quasar_version}:jdk8")
|
||||
// quasar("co.paralleluniverse:quasar-core:${quasar_version}:jdk8@jar")
|
||||
}
|
13
core/src/main/kotlin/core/IndentityService.kt
Normal file
13
core/src/main/kotlin/core/IndentityService.kt
Normal file
@ -0,0 +1,13 @@
|
||||
package core
|
||||
|
||||
import core.Party
|
||||
import java.security.PublicKey
|
||||
|
||||
/**
|
||||
* An identity service maintains an bidirectional map of [Party]s to their associated public keys and thus supports
|
||||
* lookup of a party given its key. This is obviously very incomplete and does not reflect everything a real identity
|
||||
* service would provide.
|
||||
*/
|
||||
interface IdentityService {
|
||||
fun partyFromKey(key: PublicKey): Party?
|
||||
}
|
29
core/src/main/kotlin/core/TimestamperService.kt
Normal file
29
core/src/main/kotlin/core/TimestamperService.kt
Normal file
@ -0,0 +1,29 @@
|
||||
package core
|
||||
|
||||
import co.paralleluniverse.fibers.Suspendable
|
||||
import core.crypto.DigitalSignature
|
||||
import core.crypto.generateKeyPair
|
||||
import core.serialization.SerializedBytes
|
||||
|
||||
/**
|
||||
* Simple interface (for testing) to an abstract timestamping service. Note that this is not "timestamping" in the
|
||||
* blockchain sense of a total ordering of transactions, but rather, a signature from a well known/trusted timestamping
|
||||
* service over a transaction that indicates the timestamp in it is accurate. Such a signature may not always be
|
||||
* necessary: if there are multiple parties involved in a transaction then they can cross-check the timestamp
|
||||
* themselves.
|
||||
*/
|
||||
interface TimestamperService {
|
||||
@Suspendable
|
||||
fun timestamp(wtxBytes: SerializedBytes<WireTransaction>): DigitalSignature.LegallyIdentifiable
|
||||
|
||||
/** The name+pubkey that this timestamper will sign with. */
|
||||
val identity: Party
|
||||
}
|
||||
|
||||
// Smart contracts may wish to specify explicitly which timestamping authorities are trusted to assert the time.
|
||||
// We define a dummy authority here to allow to us to develop prototype contracts in the absence of a real authority.
|
||||
// The timestamper itself is implemented in the unit test part of the code (in TestUtils.kt).
|
||||
object DummyTimestampingAuthority {
|
||||
val key = generateKeyPair()
|
||||
val identity = Party("The dummy timestamper", key.public)
|
||||
}
|
139
core/src/main/kotlin/core/TransactionVerification.kt
Normal file
139
core/src/main/kotlin/core/TransactionVerification.kt
Normal file
@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import core.crypto.SecureHash
|
||||
import java.util.*
|
||||
|
||||
class TransactionResolutionException(val hash: SecureHash) : Exception()
|
||||
class TransactionConflictException(val conflictRef: StateRef, val tx1: LedgerTransaction, val tx2: LedgerTransaction) : Exception()
|
||||
|
||||
/**
|
||||
* A TransactionGroup defines a directed acyclic graph of transactions that can be resolved with each other and then
|
||||
* verified. Successful verification does not imply the non-existence of other conflicting transactions: simply that
|
||||
* this subgraph does not contain conflicts and is accepted by the involved contracts.
|
||||
*
|
||||
* The inputs of the provided transactions must be resolvable either within the [transactions] set, or from the
|
||||
* [nonVerifiedRoots] set. Transactions in the non-verified set are ignored other than for looking up input states.
|
||||
*/
|
||||
class TransactionGroup(val transactions: Set<LedgerTransaction>, val nonVerifiedRoots: Set<LedgerTransaction>) {
|
||||
|
||||
/**
|
||||
* Verifies the group and returns the set of resolved transactions.
|
||||
*/
|
||||
fun verify(programMap: ContractFactory): Set<TransactionForVerification> {
|
||||
// Check that every input can be resolved to an output.
|
||||
// Check that no output is referenced by more than one input.
|
||||
// Cycles should be impossible due to the use of hashes as pointers.
|
||||
check(transactions.intersect(nonVerifiedRoots).isEmpty())
|
||||
|
||||
val hashToTXMap: Map<SecureHash, List<LedgerTransaction>> = (transactions + nonVerifiedRoots).groupBy { it.hash }
|
||||
val refToConsumingTXMap = hashMapOf<StateRef, LedgerTransaction>()
|
||||
|
||||
val resolved = HashSet<TransactionForVerification>(transactions.size)
|
||||
for (tx in transactions) {
|
||||
val inputs = ArrayList<ContractState>(tx.inputs.size)
|
||||
for (ref in tx.inputs) {
|
||||
val conflict = refToConsumingTXMap[ref]
|
||||
if (conflict != null)
|
||||
throw TransactionConflictException(ref, tx, conflict)
|
||||
refToConsumingTXMap[ref] = tx
|
||||
|
||||
// Look up the connecting transaction.
|
||||
val ltx = hashToTXMap[ref.txhash]?.single() ?: throw TransactionResolutionException(ref.txhash)
|
||||
// Look up the output in that transaction by index.
|
||||
inputs.add(ltx.outputs[ref.index])
|
||||
}
|
||||
resolved.add(TransactionForVerification(inputs, tx.outputs, tx.commands, tx.hash))
|
||||
}
|
||||
|
||||
for (tx in resolved)
|
||||
tx.verify(programMap)
|
||||
return resolved
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** A transaction in fully resolved and sig-checked form, ready for passing as input to a verification function. */
|
||||
data class TransactionForVerification(val inStates: List<ContractState>,
|
||||
val outStates: List<ContractState>,
|
||||
val commands: List<AuthenticatedObject<CommandData>>,
|
||||
val origHash: SecureHash) {
|
||||
override fun hashCode() = origHash.hashCode()
|
||||
override fun equals(other: Any?) = other is TransactionForVerification && other.origHash == origHash
|
||||
|
||||
/**
|
||||
* @throws TransactionVerificationException if a contract throws an exception, the original is in the cause field
|
||||
* @throws IllegalStateException if a state refers to an unknown contract.
|
||||
*/
|
||||
@Throws(TransactionVerificationException::class, IllegalStateException::class)
|
||||
fun verify(programMap: ContractFactory) {
|
||||
// For each input and output state, locate the program to run. Then execute the verification function. If any
|
||||
// throws an exception, the entire transaction is invalid.
|
||||
val programHashes = (inStates.map { it.programRef } + outStates.map { it.programRef }).toSet()
|
||||
for (hash in programHashes) {
|
||||
val program: Contract = programMap[hash]
|
||||
try {
|
||||
program.verify(this)
|
||||
} catch(e: Throwable) {
|
||||
throw TransactionVerificationException(this, program, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilities for contract writers to incorporate into their logic.
|
||||
*/
|
||||
|
||||
data class InOutGroup<T : ContractState>(val inputs: List<T>, val outputs: List<T>)
|
||||
|
||||
// A shortcut to make IDE auto-completion more intuitive for Java users.
|
||||
fun getTimestampBy(timestampingAuthority: Party): TimestampCommand? = commands.getTimestampBy(timestampingAuthority)
|
||||
|
||||
// For Java users.
|
||||
fun <T : ContractState> groupStates(ofType: Class<T>, selector: (T) -> Any): List<InOutGroup<T>> {
|
||||
val inputs = inStates.filterIsInstance(ofType)
|
||||
val outputs = outStates.filterIsInstance(ofType)
|
||||
|
||||
val inGroups = inputs.groupBy(selector)
|
||||
val outGroups = outputs.groupBy(selector)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
return groupStatesInternal(inGroups, outGroups)
|
||||
}
|
||||
|
||||
// For Kotlin users: this version has nicer syntax and avoids reflection/object creation for the lambda.
|
||||
inline fun <reified T : ContractState> groupStates(selector: (T) -> Any): List<InOutGroup<T>> {
|
||||
val inputs = inStates.filterIsInstance<T>()
|
||||
val outputs = outStates.filterIsInstance<T>()
|
||||
|
||||
val inGroups = inputs.groupBy(selector)
|
||||
val outGroups = outputs.groupBy(selector)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
return groupStatesInternal(inGroups, outGroups)
|
||||
}
|
||||
|
||||
@Deprecated("Do not use this directly: exposed as public only due to function inlining")
|
||||
fun <T : ContractState> groupStatesInternal(inGroups: Map<Any, List<T>>, outGroups: Map<Any, List<T>>): List<InOutGroup<T>> {
|
||||
val result = ArrayList<InOutGroup<T>>()
|
||||
|
||||
for ((k, v) in inGroups.entries)
|
||||
result.add(InOutGroup(v, outGroups[k] ?: emptyList()))
|
||||
for ((k, v) in outGroups.entries) {
|
||||
if (inGroups[k] == null)
|
||||
result.add(InOutGroup(emptyList(), v))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown if a verification fails due to a contract rejection. */
|
||||
class TransactionVerificationException(val tx: TransactionForVerification, val contract: Contract, cause: Throwable?) : Exception(cause)
|
15
core/src/main/kotlin/core/node/TimestampingError.kt
Normal file
15
core/src/main/kotlin/core/node/TimestampingError.kt
Normal file
@ -0,0 +1,15 @@
|
||||
package core.node
|
||||
|
||||
sealed class TimestampingError : Exception() {
|
||||
class RequiresExactlyOneCommand : TimestampingError()
|
||||
/**
|
||||
* 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 NotOnTimeException : TimestampingError()
|
||||
|
||||
/** Thrown if the command in the transaction doesn't list this timestamping authorities public key as a signer */
|
||||
class NotForMe : TimestampingError()
|
||||
}
|
||||
|
@ -1,2 +1,3 @@
|
||||
rootProject.name = 'r3prototyping'
|
||||
include 'contracts'
|
||||
include 'contracts'
|
||||
include 'core'
|
@ -64,30 +64,6 @@ interface KeyManagementService {
|
||||
fun freshKey(): KeyPair
|
||||
}
|
||||
|
||||
/**
|
||||
* An identity service maintains an bidirectional map of [Party]s to their associated public keys and thus supports
|
||||
* lookup of a party given its key. This is obviously very incomplete and does not reflect everything a real identity
|
||||
* service would provide.
|
||||
*/
|
||||
interface IdentityService {
|
||||
fun partyFromKey(key: PublicKey): Party?
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple interface (for testing) to an abstract timestamping service. Note that this is not "timestamping" in the
|
||||
* blockchain sense of a total ordering of transactions, but rather, a signature from a well known/trusted timestamping
|
||||
* service over a transaction that indicates the timestamp in it is accurate. Such a signature may not always be
|
||||
* necessary: if there are multiple parties involved in a transaction then they can cross-check the timestamp
|
||||
* themselves.
|
||||
*/
|
||||
interface TimestamperService {
|
||||
@Suspendable
|
||||
fun timestamp(wtxBytes: SerializedBytes<WireTransaction>): DigitalSignature.LegallyIdentifiable
|
||||
|
||||
/** The name+pubkey that this timestamper will sign with. */
|
||||
val identity: Party
|
||||
}
|
||||
|
||||
// Smart contracts may wish to specify explicitly which timestamping authorities are trusted to assert the time.
|
||||
// We define a dummy authority here to allow to us to develop prototype contracts in the absence of a real authority.
|
||||
// The timestamper itself is implemented in the unit test part of the code (in TestUtils.kt).
|
||||
|
@ -31,19 +31,6 @@ class TimestampingMessages {
|
||||
data class Request(val tx: SerializedBytes<WireTransaction>, val replyTo: MessageRecipients, val replyToTopic: String)
|
||||
}
|
||||
|
||||
sealed class TimestampingError : Exception() {
|
||||
class RequiresExactlyOneCommand : TimestampingError()
|
||||
/**
|
||||
* 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 NotOnTimeException : TimestampingError()
|
||||
|
||||
/** Thrown if the command in the transaction doesn't list this timestamping authorities public key as a signer */
|
||||
class NotForMe : TimestampingError()
|
||||
}
|
||||
|
||||
/**
|
||||
* This class implements the server side of the timestamping protocol, using the local clock. A future version might
|
||||
* add features like checking against other NTP servers to make sure the clock hasn't drifted by too much.
|
||||
|
Loading…
Reference in New Issue
Block a user