Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make Recovery sets optional #9013

Merged
merged 3 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 69 additions & 10 deletions packages/ERTP/src/issuerKit.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @jessie-check

import { assert } from '@agoric/assert';
import { assert, Fail } from '@agoric/assert';
import { assertPattern } from '@agoric/store';
import { makeScalarBigMapStore } from '@agoric/vat-data';
import { makeDurableZone } from '@agoric/zone/durable.js';
Expand All @@ -26,6 +26,8 @@ import './types-ambient.js';
* @template {AssetKind} K
* @param {IssuerRecord<K>} issuerRecord
* @param {import('@agoric/zone').Zone} issuerZone
* @param {RecoverySetsOption} recoverySetsState Omitted from issuerRecord
* because it was added in an upgrade.
* @param {ShutdownWithFailure} [optShutdownWithFailure] If this issuer fails in
* the middle of an atomic action (which btw should never happen), it
* potentially leaves its ledger in a corrupted state. If this function was
Expand All @@ -38,6 +40,7 @@ import './types-ambient.js';
const setupIssuerKit = (
{ name, assetKind, displayInfo, elementShape },
issuerZone,
recoverySetsState,
optShutdownWithFailure = undefined,
) => {
assert.typeof(name, 'string');
Expand All @@ -62,6 +65,7 @@ const setupIssuerKit = (
assetKind,
cleanDisplayInfo,
elementShape,
recoverySetsState,
optShutdownWithFailure,
);

Expand All @@ -77,6 +81,12 @@ harden(setupIssuerKit);

/** The key at which the issuer record is stored. */
const INSTANCE_KEY = 'issuer';
/**
* The key at which the issuerKit's `RecoverySetsOption` state is stored.
* Introduced by an upgrade, so may be absent on a predecessor incarnation. See
* `RecoverySetsOption` for defaulting behavior.
*/
const RECOVERY_SETS_STATE = 'recoverySetsState';

/**
* Used _only_ to upgrade a predecessor issuerKit. Use `makeDurableIssuerKit` to
Expand All @@ -91,15 +101,39 @@ const INSTANCE_KEY = 'issuer';
* unit of computation, like the enclosing vat, can be shutdown before
* anything else is corrupted by that corrupted state. See
* https://github.com/Agoric/agoric-sdk/issues/3434
* @param {RecoverySetsOption} [recoverySetsOption] Added in upgrade, so last
* and optional. See `RecoverySetsOption` for defaulting behavior.
* @returns {IssuerKit<K>}
*/
export const upgradeIssuerKit = (
issuerBaggage,
optShutdownWithFailure = undefined,
recoverySetsOption = undefined,
) => {
const issuerRecord = issuerBaggage.get(INSTANCE_KEY);
const issuerZone = makeDurableZone(issuerBaggage);
return setupIssuerKit(issuerRecord, issuerZone, optShutdownWithFailure);
const oldRecoverySetsState = issuerBaggage.has(RECOVERY_SETS_STATE)
? issuerBaggage.get(RECOVERY_SETS_STATE)
: 'hasRecoverySets';
if (
oldRecoverySetsState === 'noRecoverySets' &&
recoverySetsOption === 'hasRecoverySets'
) {
Fail`Cannot (yet?) upgrade from 'noRecoverySets' to 'hasRecoverySets'`;
}
if (
oldRecoverySetsState === 'hasRecoverySets' &&
recoverySetsOption === 'noRecoverySets'
) {
Fail`Cannot (yet?) upgrade from 'hasRecoverySets' to 'noRecoverySets'`;
}
const recoverySetsState = recoverySetsOption || oldRecoverySetsState;
return setupIssuerKit(
issuerRecord,
issuerZone,
recoverySetsState,
optShutdownWithFailure,
);
};
harden(upgradeIssuerKit);

Expand All @@ -119,8 +153,14 @@ export const hasIssuer = baggage => baggage.has(INSTANCE_KEY);
* typically, the amount of an invitation payment is a singleton set. Such a
* payment is often referred to in the singular as "an invitation".)
*
* `recoverySetsOption` added in upgrade. Note that `IssuerOptionsRecord` is
* never stored, so we never need to worry about inheriting one from a
* predecessor predating the introduction of recovery sets. See
* `RecoverySetsOption` for defaulting behavior.
*
* @typedef {Partial<{
* elementShape: Pattern;
* recoverySetsOption: RecoverySetsOption;
* }>} IssuerOptionsRecord
*/

Expand Down Expand Up @@ -161,12 +201,24 @@ export const makeDurableIssuerKit = (
assetKind = AssetKind.NAT,
displayInfo = harden({}),
optShutdownWithFailure = undefined,
{ elementShape = undefined } = {},
{ elementShape = undefined, recoverySetsOption = undefined } = {},
) => {
const issuerData = harden({ name, assetKind, displayInfo, elementShape });
const issuerData = harden({
name,
assetKind,
displayInfo,
elementShape,
});
issuerBaggage.init(INSTANCE_KEY, issuerData);
const issuerZone = makeDurableZone(issuerBaggage);
return setupIssuerKit(issuerData, issuerZone, optShutdownWithFailure);
const recoverySetsState = recoverySetsOption || 'hasRecoverySets';
issuerBaggage.init(RECOVERY_SETS_STATE, recoverySetsState);
return setupIssuerKit(
issuerData,
issuerZone,
recoverySetsState,
optShutdownWithFailure,
);
};
harden(makeDurableIssuerKit);

Expand Down Expand Up @@ -210,12 +262,19 @@ export const prepareIssuerKit = (
options = {},
) => {
if (hasIssuer(issuerBaggage)) {
const { elementShape: _ = undefined } = options;
const issuerKit = upgradeIssuerKit(issuerBaggage, optShutdownWithFailure);
const { elementShape: _ = undefined, recoverySetsOption = undefined } =
options;
const issuerKit = upgradeIssuerKit(
issuerBaggage,
optShutdownWithFailure,
recoverySetsOption,
);

// TODO check consistency with name, assetKind, displayInfo, elementShape.
// Consistency either means that these are the same, or that they differ
// in a direction we are prepared to upgrade.
// in a direction we are prepared to upgrade. Note that it is the
// responsibility of `upgradeIssuerKit` to check consistency of
// `recoverySetsOption`, so continue to not do that here.

// @ts-expect-error Type parameter confusion.
return issuerKit;
Expand Down Expand Up @@ -273,14 +332,14 @@ export const makeIssuerKit = (
assetKind = AssetKind.NAT,
displayInfo = harden({}),
optShutdownWithFailure = undefined,
{ elementShape = undefined } = {},
{ elementShape = undefined, recoverySetsOption = undefined } = {},
) =>
makeDurableIssuerKit(
makeScalarBigMapStore('dropped issuer kit', { durable: true }),
name,
assetKind,
displayInfo,
optShutdownWithFailure,
{ elementShape },
{ elementShape, recoverySetsOption },
);
harden(makeIssuerKit);
27 changes: 19 additions & 8 deletions packages/ERTP/src/paymentLedger.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const amountShapeFromElementShape = (brand, assetKind, elementShape) => {
* @param {K} assetKind
* @param {DisplayInfo<K>} displayInfo
* @param {Pattern} elementShape
* @param {RecoverySetsOption} recoverySetsState
* @param {ShutdownWithFailure} [optShutdownWithFailure]
* @returns {PaymentLedger<K>}
*/
Expand All @@ -81,6 +82,7 @@ export const preparePaymentLedger = (
assetKind,
displayInfo,
elementShape,
recoverySetsState,
optShutdownWithFailure = undefined,
) => {
/** @type {Brand<K>} */
Expand Down Expand Up @@ -141,11 +143,11 @@ export const preparePaymentLedger = (
});

/**
* A withdrawn live payment is associated with the recovery set of the purse
* it was withdrawn from. Let's call these "recoverable" payments. All
* recoverable payments are live, but not all live payments are recoverable.
* We do the bookkeeping for payment recovery with this weakmap from
* recoverable payments to the recovery set they are in. A bunch of
* A (non-empty) withdrawn live payment is associated with the recovery set of
* the purse it was withdrawn from. Let's call these "recoverable" payments.
* All recoverable payments are live, but not all live payments are
* recoverable. We do the bookkeeping for payment recovery with this weakmap
* from recoverable payments to the recovery set they are in. A bunch of
* interesting invariants here:
*
* - Every payment that is a key in the outer `paymentRecoverySets` weakMap is
Expand All @@ -157,6 +159,9 @@ export const preparePaymentLedger = (
* - A purse's recovery set only contains payments withdrawn from that purse and
* not yet consumed.
*
* If `recoverySetsState === 'noRecoverySets'`, then nothing should ever be
* added to this WeakStore.
*
* @type {WeakMapStore<Payment, SetStore<Payment>>}
*/
const paymentRecoverySets = issuerZone.weakMapStore('paymentRecoverySets');
Expand All @@ -170,7 +175,11 @@ export const preparePaymentLedger = (
* @param {SetStore<Payment>} [optRecoverySet]
*/
const initPayment = (payment, amount, optRecoverySet = undefined) => {
if (optRecoverySet !== undefined) {
if (recoverySetsState === 'noRecoverySets') {
optRecoverySet === undefined ||
Fail`when recoverSetsState === 'noRecoverySets', optRecoverySet must be empty`;
Comment on lines +179 to +180
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thx for expanding this out.

}
if (optRecoverySet !== undefined && !AmountMath.isEmpty(amount)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to see the isEmpty check arrive too!

optRecoverySet.add(payment);
paymentRecoverySets.init(payment, optRecoverySet);
}
Expand Down Expand Up @@ -263,10 +272,10 @@ export const preparePaymentLedger = (
*
* @param {import('./amountStore.js').AmountStore} balanceStore
* @param {Amount} amount - the amount to be withdrawn
* @param {SetStore<Payment>} recoverySet
* @param {SetStore<Payment>} [recoverySet]
* @returns {Payment}
*/
const withdrawInternal = (balanceStore, amount, recoverySet) => {
const withdrawInternal = (balanceStore, amount, recoverySet = undefined) => {
amount = coerce(amount);
const payment = makePayment();
// COMMIT POINT Move the withdrawn assets from this purse into
Expand Down Expand Up @@ -294,6 +303,8 @@ export const preparePaymentLedger = (
depositInternal,
withdrawInternal,
}),
recoverySetsState,
paymentRecoverySets,
);

/** @type {Issuer<K>} */
Expand Down
55 changes: 50 additions & 5 deletions packages/ERTP/src/purse.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { M } from '@agoric/store';
import { M, makeCopySet } from '@agoric/store';
import { AmountMath } from './amountMath.js';
import { makeTransientNotifierKit } from './transientNotifier.js';
import { makeAmountStore } from './amountStore.js';

const { Fail } = assert;

const EMPTY_COPY_SET = makeCopySet([]);

// TODO Type InterfaceGuard better than InterfaceGuard<any>
/**
* @param {import('@agoric/zone').Zone} issuerZone
Expand All @@ -19,6 +21,8 @@ const { Fail } = assert;
* depositInternal: any;
* withdrawInternal: any;
* }} purseMethods
* @param {RecoverySetsOption} recoverySetsState
* @param {WeakMapStore<Payment, SetStore<Payment>>} paymentRecoverySets
*/
export const preparePurseKind = (
issuerZone,
Expand All @@ -27,6 +31,8 @@ export const preparePurseKind = (
brand,
PurseIKit,
purseMethods,
recoverySetsState,
paymentRecoverySets,
) => {
const amountShape = brand.getAmountShape();

Expand All @@ -35,6 +41,34 @@ export const preparePurseKind = (
// TODO propagate zonifying to notifiers, maybe?
const { provideNotifier, update: updateBalance } = makeTransientNotifierKit();

/**
* If `recoverySetsState === 'hasRecoverySets'` (the normal state), then just
* return `state.recoverySet`.
*
* If `recoverySetsState === 'noRecoverySets'`, return `undefined`. Callers
* must be aware that the `undefined` return happens iff `recoverySetsState
* === 'noRecoverySets'`, and to avoid storing or retrieving anything from the
* actual recovery set.
*
* @param {{ recoverySet: SetStore<Payment> }} state
* @returns {SetStore<Payment> | undefined}
*/
const maybeRecoverySet = state => {
const { recoverySet } = state;
if (recoverySetsState === 'hasRecoverySets') {
return recoverySet;
} else {
recoverySetsState === 'noRecoverySets' ||
Fail`recoverSetsState must be noRecoverySets if it isn't hasRecoverSets`;
paymentRecoverySets !== undefined ||
Fail`paymentRecoverySets must always be defined`;
recoverySet.getSize() === 0 ||
Fail`With noRecoverySets, recoverySet must be empty`;

return undefined;
}
};

// - This kind is a pair of purse and depositFacet that have a 1:1
// correspondence.
// - They are virtualized together to share a single state record.
Expand Down Expand Up @@ -76,12 +110,14 @@ export const preparePurseKind = (
withdraw(amount) {
const { state } = this;
const { purse } = this.facets;

const optRecoverySet = maybeRecoverySet(state);
const balanceStore = makeAmountStore(state, 'currentBalance');
// Note COMMIT POINT within withdraw.
const payment = withdrawInternal(
balanceStore,
amount,
state.recoverySet,
optRecoverySet,
);
updateBalance(purse, balanceStore.getAmount());
return payment;
Expand All @@ -103,18 +139,27 @@ export const preparePurseKind = (
},

getRecoverySet() {
return this.state.recoverySet.snapshot();
const { state } = this;
const optRecoverySet = maybeRecoverySet(state);
if (optRecoverySet === undefined) {
return EMPTY_COPY_SET;
}
return optRecoverySet.snapshot();
},
recoverAll() {
const { state, facets } = this;
let amount = AmountMath.makeEmpty(brand, assetKind);
for (const payment of state.recoverySet.keys()) {
const optRecoverySet = maybeRecoverySet(state);
if (optRecoverySet === undefined) {
return amount; // empty at this time
}
for (const payment of optRecoverySet.keys()) {
// This does cause deletions from the set while iterating,
// but this special case is allowed.
const delta = facets.purse.deposit(payment);
amount = AmountMath.add(amount, delta, brand);
}
state.recoverySet.getSize() === 0 ||
optRecoverySet.getSize() === 0 ||
Fail`internal: Remaining unrecovered payments: ${facets.purse.getRecoverySet()}`;
return amount;
},
Expand Down
Loading
Loading