diff options
5 files changed, 790 insertions, 85 deletions
diff --git a/packages/taler-wallet-core/src/operations/backup.ts b/packages/taler-wallet-core/src/operations/backup.ts index dbcb33374..948a4e7a3 100644 --- a/packages/taler-wallet-core/src/operations/backup.ts +++ b/packages/taler-wallet-core/src/operations/backup.ts @@ -29,7 +29,9 @@ import { BackupCoin, BackupCoinSource, BackupCoinSourceType, + BackupDenomination, BackupExchangeData, + BackupExchangeWireFee, WalletBackupContentV1, } from "../types/backupTypes"; import { TransactionHandle } from "../util/query"; @@ -128,21 +130,88 @@ export async function exportBackup( ): Promise<WalletBackupContentV1> { await provideBackupState(ws); return ws.db.runWithWriteTransaction( - [Stores.config, Stores.exchanges, Stores.coins], + [Stores.config, Stores.exchanges, Stores.coins, Stores.denominations], async (tx) => { const bs = await getWalletBackupState(ws, tx); const exchanges: BackupExchangeData[] = []; const coins: BackupCoin[] = []; + const denominations: BackupDenomination[] = []; await tx.iter(Stores.exchanges).forEach((ex) => { + // Only back up permanently added exchanges. + if (!ex.details) { return; } + if (!ex.wireInfo) { + return; + } + if (!ex.addComplete) { + return; + } + if (!ex.permanent) { + return; + } + const wi = ex.wireInfo; + const wireFees: BackupExchangeWireFee[] = []; + + Object.keys(wi.feesForType).forEach((x) => { + for (const f of wi.feesForType[x]) { + wireFees.push({ + wireType: x, + closingFee: Amounts.stringify(f.closingFee), + endStamp: f.endStamp, + sig: f.sig, + startStamp: f.startStamp, + wireFee: Amounts.stringify(f.wireFee), + }); + } + }); + exchanges.push({ - exchangeBaseUrl: ex.baseUrl, - exchangeMasterPub: ex.details?.masterPublicKey, + baseUrl: ex.baseUrl, + accounts: ex.wireInfo.accounts.map((x) => ({ + paytoUri: x.payto_uri, + })), + auditors: ex.details.auditors.map((x) => ({ + auditorPub: x.auditor_pub, + auditorUrl: x.auditor_url, + denominationKeys: x.denomination_keys, + })), + masterPublicKey: ex.details.masterPublicKey, + currency: ex.details.currency, + protocolVersion: ex.details.protocolVersion, + wireFees, + signingKeys: ex.details.signingKeys.map((x) => ({ + key: x.key, + masterSig: x.master_sig, + stampEnd: x.stamp_end, + stampExpire: x.stamp_expire, + stampStart: x.stamp_start, + })), termsOfServiceAcceptedEtag: ex.termsOfServiceAcceptedEtag, + termsOfServiceLastEtag: ex.termsOfServiceLastEtag, + }); + }); + + await tx.iter(Stores.denominations).forEach((denom) => { + denominations.push({ + denomPub: denom.denomPub, + denomPubHash: denom.denomPubHash, + exchangeBaseUrl: canonicalizeBaseUrl(denom.exchangeBaseUrl), + feeDeposit: Amounts.stringify(denom.feeDeposit), + feeRefresh: Amounts.stringify(denom.feeRefresh), + feeRefund: Amounts.stringify(denom.feeRefund), + feeWithdraw: Amounts.stringify(denom.feeWithdraw), + isOffered: denom.isOffered, + isRevoked: denom.isRevoked, + masterSig: denom.masterSig, + stampExpireDeposit: denom.stampExpireDeposit, + stampExpireLegal: denom.stampExpireLegal, + stampExpireWithdraw: denom.stampExpireWithdraw, + stampStart: denom.stampStart, + value: Amounts.stringify(denom.value), }); }); @@ -192,6 +261,7 @@ export async function exportBackup( planchets: [], refreshSessions: [], reserves: [], + denominations: [], walletRootPub: bs.walletRootPub, }; diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index d598e3987..b82700365 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -359,7 +359,6 @@ export async function acceptExchangeTermsOfService( return; } r.termsOfServiceAcceptedEtag = etag; - r.termsOfServiceAcceptedTimestamp = getTimestampNow(); await tx.put(Stores.exchanges, r); }); } @@ -490,9 +489,7 @@ async function updateExchangeFromUrlImpl( updateStatus: ExchangeUpdateStatus.FetchKeys, updateStarted: now, updateReason: ExchangeUpdateReason.Initial, - timestampAdded: getTimestampNow(), termsOfServiceAcceptedEtag: undefined, - termsOfServiceAcceptedTimestamp: undefined, termsOfServiceLastEtag: undefined, termsOfServiceText: undefined, retryInfo: initRetryInfo(false), diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index b5670a82d..d09903cbb 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -793,9 +793,9 @@ export async function getExchangeWithdrawalInfo( let tosAccepted = false; - if (exchangeInfo.termsOfServiceAcceptedTimestamp) { + if (exchangeInfo.termsOfServiceLastEtag) { if ( - exchangeInfo.termsOfServiceAcceptedEtag == + exchangeInfo.termsOfServiceAcceptedEtag === exchangeInfo.termsOfServiceLastEtag ) { tosAccepted = true; diff --git a/packages/taler-wallet-core/src/types/backupTypes.ts b/packages/taler-wallet-core/src/types/backupTypes.ts index 72d0486b1..cfbfdc49b 100644 --- a/packages/taler-wallet-core/src/types/backupTypes.ts +++ b/packages/taler-wallet-core/src/types/backupTypes.ts @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { Timestamp } from "../util/time"; /** * Type declarations for backup. @@ -21,6 +22,23 @@ * Contains some redundancy with the other type declarations, * as the backup schema must be very stable. * + * Current limitations: + * 1. Contracts that are claimed but not accepted aren't + * exported yet. + * 2. There is no garbage collection mechanism for the export yet. + * (But this should actually become the main GC mechanism!) + * 3. Reserve history isn't backed up yet. + * 4. Recoup metadata isn't exported yet. + * + * General considerations / decisions: + * 1. Information about previously occurring errors and + * retries is never backed up. + * 2. The ToS text of an exchange is never backed up. + * 3. Public keys are always exported in the backup + * and never recomputed (this allows the import to + * complete within a DB transaction that can't access + * the crypto worker). + * * @author Florian Dold <dold@taler.net> */ @@ -51,19 +69,566 @@ export interface WalletBackupContentV1 { */ exchanges: BackupExchangeData[]; - reserves: ReserveBackupData[]; + denominations: BackupDenomination[]; + + reserves: BackupReserveData[]; + + withdrawalGroups: BackupWithdrawalGroup[]; + + refreshGroups: BackupRefreshGroup[]; coins: BackupCoin[]; - planchets: BackupWithdrawalPlanchet[]; + purchases: BackupPurchase[]; +} + +export enum BackupCoinSourceType { + Withdraw = "withdraw", + Refresh = "refresh", + Tip = "tip", +} + +export interface BackupWithdrawCoinSource { + type: BackupCoinSourceType.Withdraw; + + /** + * Can be the empty string for orphaned coins. + */ + withdrawalGroupId: string; + + /** + * Index of the coin in the withdrawal session. + */ + coinIndex: number; + + /** + * Reserve public key for the reserve we got this coin from. + */ + reservePub: string; +} + +export interface BackupRefreshCoinSource { + type: BackupCoinSourceType.Refresh; + oldCoinPub: string; +} + +export interface BackupTipCoinSource { + type: BackupCoinSourceType.Tip; + walletTipId: string; + coinIndex: number; +} + +export type BackupCoinSource = + | BackupWithdrawCoinSource + | BackupRefreshCoinSource + | BackupTipCoinSource; + +export interface BackupCoin { + /** + * Where did the coin come from? Used for recouping coins. + */ + coinSource: BackupCoinSource; + + /** + * Public key of the coin. + */ + coinPub: string; + + /** + * Private key to authorize operations on the coin. + */ + coinPriv: string; + + /** + * Key used by the exchange used to sign the coin. + */ + denomPub: string; + + /** + * Hash of the public key that signs the coin. + */ + denomPubHash: string; + + /** + * Unblinded signature by the exchange. + */ + denomSig: string; + + /** + * Amount that's left on the coin. + */ + currentAmount: BackupAmountString; + + /** + * Base URL that identifies the exchange from which we got the + * coin. + */ + exchangeBaseUrl: string; + + /** + * Blinding key used when withdrawing the coin. + * Potentionally used again during payback. + */ + blindingKey: string; + + fresh: boolean; +} + +/** + * Status of a tip we got from a merchant. + */ +export interface BackupTip { + /** + * Tip ID chosen by the wallet. + */ + walletTipId: string; + + /** + * The merchant's identifier for this tip. + */ + merchantTipId: string; + + /** + * Has the user accepted the tip? Only after the tip has been accepted coins + * withdrawn from the tip may be used. + */ + acceptedTimestamp: Timestamp | undefined; + + createdTimestamp: Timestamp; + + /** + * Timestamp for when the wallet finished picking up the tip + * from the merchant. + */ + pickedUpTimestamp: Timestamp | undefined; + + /** + * The tipped amount. + */ + tipAmountRaw: BackupAmountString; + + tipAmountEffective: BackupAmountString; + + /** + * Timestamp, the tip can't be picked up anymore after this deadline. + */ + tipExpiration: Timestamp; + + /** + * The exchange that will sign our coins, chosen by the merchant. + */ + exchangeBaseUrl: string; + + /** + * Base URL of the merchant that is giving us the tip. + */ + merchantBaseUrl: string; + + /** + * Planchets, the members included in TipPlanchetDetail will be sent to the + * merchant. + */ + planchets?: { + blindingKey: string; + coinEv: string; + coinPriv: string; + coinPub: string; + }[]; + + totalCoinValue: BackupAmountString; + totalWithdrawCost: BackupAmountString; + + selectedDenoms: { + denomPubHash: string; + count: number; + }[]; +} + +/** + * Reasons for why a coin is being refreshed. + */ +export enum BackupRefreshReason { + Manual = "manual", + Pay = "pay", + Refund = "refund", + AbortPay = "abort-pay", + Recoup = "recoup", + BackupRestored = "backup-restored", + Scheduled = "scheduled", +} + +/** + * Planchet for a coin during refrehs. + */ +export interface BackupRefreshPlanchet { + /** + * Public key for the coin. + */ + publicKey: string; + /** + * Private key for the coin. + */ + privateKey: string; + /** + * Blinded public key. + */ + coinEv: string; + /** + * Blinding key used. + */ + blindingKey: string; +} + +export interface BackupRefreshSessionRecord { + /** + * Public key that's being melted in this session. + */ + meltCoinPub: string; + + /** + * How much of the coin's value is melted away + * with this refresh session? + */ + amountRefreshInput: BackupAmountString; + + /** + * Sum of the value of denominations we want + * to withdraw in this session, without fees. + */ + amountRefreshOutput: BackupAmountString; + + /** + * Signature to confirm the melting. + */ + confirmSig: string; + + /** + * Hased denominations of the newly requested coins. + */ + newDenomHashes: string[]; + + /** + * Denominations of the newly requested coins. + */ + newDenoms: string[]; + + /** + * Planchets for each cut-and-choose instance. + */ + planchetsForGammas: BackupRefreshPlanchet[][]; + + /** + * The transfer keys, kappa of them. + */ + transferPubs: string[]; + + /** + * Private keys for the transfer public keys. + */ + transferPrivs: string[]; + + /** + * The no-reveal-index after we've done the melting. + */ + norevealIndex?: number; + + /** + * Hash of the session. + */ + hash: string; + + /** + * Timestamp when the refresh session finished. + */ + finishedTimestamp: Timestamp | undefined; + + /** + * When has this refresh session been created? + */ + timestampCreated: Timestamp; + + /** + * Base URL for the exchange we're doing the refresh with. + */ + exchangeBaseUrl: string; +} + +export interface BackupRefreshGroup { + refreshGroupId: string; + + reason: BackupRefreshReason; + + oldCoinPubs: string[]; + + refreshSessionPerCoin: (BackupRefreshSessionRecord | undefined)[]; + + inputPerCoin: BackupAmountString[]; + + estimatedOutputPerCoin: BackupAmountString[]; + + /** + * Timestamp when the refresh session finished. + */ + timestampFinished: Timestamp | undefined; +} + +export interface BackupWithdrawalGroup { + withdrawalGroupId: string; + + reservePub: string; + + /** + * When was the withdrawal operation started started? + * Timestamp in milliseconds. + */ + timestampStart: Timestamp; + + /** + * When was the withdrawal operation completed? + */ + timestampFinish?: Timestamp; + + /** + * Amount including fees (i.e. the amount subtracted from the + * reserve to withdraw all coins in this withdrawal session). + */ + rawWithdrawalAmount: BackupAmountString; + + totalCoinValue: BackupAmountString; + totalWithdrawCost: BackupAmountString; + + selectedDenoms: { + denomPubHash: string; + count: number; + }[]; - refreshSessions: BackupRefreshSession[]; + /** + * One planchet/coin for each selected denomination. + */ + planchets: { + blindingKey: string; + coinPriv: string; + coinPub: string; + }[]; +} + +export enum BackupRefundState { + Failed = "failed", + Applied = "applied", + Pending = "pending", } -export interface BackupRefreshSession { +export interface BackupRefundItemCommon { + // Execution time as claimed by the merchant + executionTime: Timestamp; + + /** + * Time when the wallet became aware of the refund. + */ + obtainedTime: Timestamp; + + refundAmount: BackupAmountString; + refundFee: BackupAmountString; + /** + * Upper bound on the refresh cost incurred by + * applying this refund. + * + * Might be lower in practice when two refunds on the same + * coin are refreshed in the same refresh operation. + */ + totalRefreshCostBound: BackupAmountString; +} + +/** + * Failed refund, either because the merchant did + * something wrong or it expired. + */ +export interface BackupRefundFailedItem extends BackupRefundItemCommon { + type: BackupRefundState.Failed; } +export interface BackupRefundPendingItem extends BackupRefundItemCommon { + type: BackupRefundState.Pending; +} + +export interface BackupRefundAppliedItem extends BackupRefundItemCommon { + type: BackupRefundState.Applied; +} + +/** + * State of one refund from the merchant, maintained by the wallet. + */ +export type BackupRefundItem = + | BackupRefundFailedItem + | BackupRefundPendingItem + | BackupRefundAppliedItem; + +export interface BackupPurchase { + /** + * Proposal ID for this purchase. Uniquely identifies the + * purchase and the proposal. + */ + proposalId: string; + + /** + * Contract terms we got from the merchant. + */ + contractTermsRaw: string; + + /** + * Amount requested by the merchant. + */ + paymentAmount: BackupAmountString; + + /** + * Public keys of the coins that were selected. + */ + coinPubs: string[]; + + /** + * Deposit permission signature of each coin. + */ + coinSigs: string[]; + + /** + * Amount that each coin contributes. + */ + coinContributions: BackupAmountString[]; + + /** + * How much of the wire fees is the customer paying? + */ + customerWireFees: BackupAmountString; + + /** + * How much of the deposit fees is the customer paying? + */ + customerDepositFees: BackupAmountString; + + totalPayCost: BackupAmountString; + + /** + * Timestamp of the first time that sending a payment to the merchant + * for this purchase was successful. + */ + timestampFirstSuccessfulPay: Timestamp | undefined; + + merchantPaySig: string | undefined; + + /** + * When was the purchase made? + * Refers to the time that the user accepted. + */ + timestampAccept: Timestamp; + + /** + * Pending refunds for the purchase. A refund is pending + * when the merchant reports a transient error from the exchange. + */ + refunds: { [refundKey: string]: BackupRefundItem }; + + /** + * When was the last refund made? + * Set to 0 if no refund was made on the purchase. + */ + timestampLastRefundStatus: Timestamp | undefined; + + abortStatus?: "abort-refund" | "abort-finished"; + + /** + * Continue querying the refund status until this deadline has expired. + */ + autoRefundDeadline: Timestamp | undefined; +} + +/** + * Info about one denomination in the backup. + * + * Note that the wallet only backs up validated denominations. + */ +export interface BackupDenomination { + /** + * Value of one coin of the denomination. + */ + value: BackupAmountString; + + /** + * The denomination public key. + */ + denomPub: string; + + /** + * Hash of the denomination public key. + * Stored in the database for faster lookups. + */ + denomPubHash: string; + + /** + * Fee for withdrawing. + */ + feeWithdraw: BackupAmountString; + + /** + * Fee for depositing. + */ + feeDeposit: BackupAmountString; + + /** + * Fee for refreshing. + */ + feeRefresh: BackupAmountString; + + /** + * Fee for refunding. + */ + feeRefund: BackupAmountString; + + /** + * Validity start date of the denomination. + */ + stampStart: Timestamp; + + /** + * Date after which the currency can't be withdrawn anymore. + */ + stampExpireWithdraw: Timestamp; + + /** + * Date after the denomination officially doesn't exist anymore. + */ + stampExpireLegal: Timestamp; + + /** + * Data after which coins of this denomination can't be deposited anymore. + */ + stampExpireDeposit: Timestamp; + + /** + * Signature by the exchange's master key over the denomination + * information. + */ + masterSig: string; + + /** + * Was this denomination still offered by the exchange the last time + * we checked? + * Only false when the exchange redacts a previously published denomination. + */ + isOffered: boolean; + + /** + * Did the exchange revoke the denomination? + * When this field is set to true in the database, the same transaction + * should also mark all affected coins as revoked. + */ + isRevoked: boolean; + + /** + * Base URL of the exchange. + */ + exchangeBaseUrl: string; +} export interface BackupReserve { reservePub: string; @@ -82,7 +647,7 @@ export interface BackupReserve { senderWire?: string; } -export interface ReserveBackupData { +export interface BackupReserveData { /** * The reserve public key. */ @@ -98,118 +663,199 @@ export interface ReserveBackupData { */ exchangeBaseUrl: string; - instructedAmount: string; + /** + * Time when the reserve was created. + */ + timestampCreated: Timestamp; + + /** + * Time when the information about this reserve was posted to the bank. + * + * Only applies if bankWithdrawStatusUrl is defined. + * + * Set to 0 if that hasn't happened yet. + */ + timestampReserveInfoPosted: Timestamp | undefined; + + /** + * Time when the reserve was confirmed by the bank. + * + * Set to undefined if not confirmed yet. + */ + timestampBankConfirmed: Timestamp | undefined; /** * Wire information (as payto URI) for the bank account that * transfered funds for this reserve. */ senderWire?: string; -} -export interface BackupExchangeData { - exchangeBaseUrl: string; - exchangeMasterPub: string; + /** + * Amount that was sent by the user to fund the reserve. + */ + instructedAmount: BackupAmountString; /** - * ETag for last terms of service download. + * Extra state for when this is a withdrawal involving + * a Taler-integrated bank. */ - termsOfServiceAcceptedEtag: string | undefined; + bankInfo?: { + /** + * Status URL that the wallet will use to query the status + * of the Taler withdrawal operation on the bank's side. + */ + statusUrl: string; + + confirmUrl?: string; + + /** + * Exchange payto URI that the bank will use to fund the reserve. + */ + exchangePaytoUri: string; + + /** + * Do we still need to register the reserve with the bank? + */ + registerPending: boolean; + }; + + initialWithdrawalGroupId: string; + + initialTotalCoinValue: BackupAmountString; + + initialTotalWithdrawCost: BackupAmountString; + initialSelectedDenoms: { + denomPubHash: string; + count: number; + }[]; } +export interface ExchangeBankAccount { + paytoUri: string; +} -export interface BackupWithdrawalPlanchet { - coinSource: BackupWithdrawCoinSource | BackupTipCoinSource; - blindingKey: string; - coinPriv: string; - coinPub: string; - denomPubHash: string; +/** + * Wire fee for one wire method as stored in the + * wallet's database. + */ +export interface BackupExchangeWireFee { + wireType: string; /** - * Base URL that identifies the exchange from which we are getting the - * coin. + * Fee for wire transfers. */ - exchangeBaseUrl: string; -} + wireFee: string; + /** + * Fees to close and refund a reserve. + */ + closingFee: string; -export enum BackupCoinSourceType { - Withdraw = "withdraw", - Refresh = "refresh", - Tip = "tip", -} - -export interface BackupWithdrawCoinSource { - type: BackupCoinSourceType.Withdraw; - withdrawalGroupId: string; + /** + * Start date of the fee. + */ + startStamp: Timestamp; /** - * Index of the coin in the withdrawal session. + * End date of the fee. */ - coinIndex: number; + endStamp: Timestamp; /** - * Reserve public key for the reserve we got this coin from. + * Signature made by the exchange master key. */ - reservePub: string; + sig: string; } -export interface BackupRefreshCoinSource { - type: BackupCoinSourceType.Refresh; - oldCoinPub: string; +/** + * Structure of one exchange signing key in the /keys response. + */ +export class BackupExchangeSignKey { + stampStart: Timestamp; + stampExpire: Timestamp; + stampEnd: Timestamp; + key: string; + masterSig: string; } -export interface BackupTipCoinSource { - type: BackupCoinSourceType.Tip; - walletTipId: string; - coinIndex: number; -} +/** + * Signature by the auditor that a particular denomination key is audited. + */ +export class AuditorDenomSig { + /** + * Denomination public key's hash. + */ + denom_pub_h: string; -export type BackupCoinSource = - | BackupWithdrawCoinSource - | BackupRefreshCoinSource - | BackupTipCoinSource; + /** + * The signature. + */ + auditor_sig: string; +} /** - * Coin that has been withdrawn and might have been - * (partially) spent. + * Auditor information as given by the exchange in /keys. */ -export interface BackupCoin { +export class BackupExchangeAuditor { /** - * Public key of the coin. + * Auditor's public key. */ - coinPub: string; + auditorPub: string; /** - * Private key of the coin. + * Base URL of the auditor. */ - coinPriv: string; + auditorUrl: string; /** - * Where did the coin come from (withdrawal/refresh/tip)? - * Used for recouping coins. + * List of signatures for denominations by the auditor. */ - coinSource: BackupCoinSource; + denominationKeys: AuditorDenomSig[]; +} + +export interface BackupExchangeData { + /** + * Base url of the exchange. + */ + baseUrl: string; /** - * Is the coin still fresh + * Master public key of the exchange. */ - fresh: boolean; + masterPublicKey: string; /** - * Blinding key used when withdrawing the coin. - * Potentionally used again during payback. + * Auditors (partially) auditing the exchange. */ - blindingKey: string; + auditors: BackupExchangeAuditor[]; /** - * Amount that's left on the coin. + * Currency that the exchange offers. */ - currentAmount: BackupAmountString; + currency: string; /** - * Base URL that identifies the exchange from which we got the - * coin. + * Last observed protocol version. */ - exchangeBaseUrl: string; + protocolVersion: string; + + /** + * Signing keys we got from the exchange, can also contain + * older signing keys that are not returned by /keys anymore. + */ + signingKeys: BackupExchangeSignKey[]; + + wireFees: BackupExchangeWireFee[]; + + accounts: ExchangeBankAccount[]; + + /** + * ETag for last terms of service download. + */ + termsOfServiceLastEtag: string | undefined; + + /** + * ETag for last terms of service download. + */ + termsOfServiceAcceptedEtag: string | undefined; } diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts index 26400dd3a..dc1ee1046 100644 --- a/packages/taler-wallet-core/src/types/dbTypes.ts +++ b/packages/taler-wallet-core/src/types/dbTypes.ts @@ -555,12 +555,9 @@ export interface ExchangeRecord { wireInfo: ExchangeWireInfo | undefined; /** - * When was the exchange added to the wallet? - */ - timestampAdded: Timestamp; - - /** * Terms of service text or undefined if not downloaded yet. + * + * This is just used as a cache of the last downloaded ToS. */ termsOfServiceText: string | undefined; @@ -575,11 +572,6 @@ export interface ExchangeRecord { termsOfServiceAcceptedEtag: string | undefined; /** - * ETag for last terms of service download. - */ - termsOfServiceAcceptedTimestamp: Timestamp | undefined; - - /** * Time when the update to the exchange has been started or * undefined if no update is in progress. */ |