@agoric/ertp
Version:
Electronic Rights Transfer Protocol (ERTP). A smart contract framework for exchanging electronic rights
505 lines (469 loc) • 16 kB
JavaScript
/* eslint-disable no-use-before-define */
// @ts-check
import { isPromise } from '@endo/promise-kit';
import { assertCopyArray } from '@endo/marshal';
import { fit, M } from '@agoric/store';
import {
provideDurableWeakMapStore,
vivifyFarInstance,
} from '@agoric/vat-data';
import { AmountMath } from './amountMath.js';
import { vivifyPaymentKind } from './payment.js';
import { vivifyPurseKind } from './purse.js';
import '@agoric/store/exported.js';
import { BrandI, makeIssuerInterfaces } from './typeGuards.js';
/** @typedef {import('@agoric/vat-data').Baggage} Baggage */
const { details: X, quote: q } = assert;
const amountShapeFromElementShape = (brand, assetKind, elementShape) => {
let valueShape;
switch (assetKind) {
case 'nat': {
valueShape = M.nat();
elementShape === undefined ||
assert.fail(
X`Fungible assets cannot have an elementShape: ${q(elementShape)}`,
);
break;
}
case 'set': {
if (elementShape === undefined) {
valueShape = M.arrayOf(M.key());
} else {
valueShape = M.arrayOf(M.and(M.key(), elementShape));
}
break;
}
case 'copySet': {
if (elementShape === undefined) {
valueShape = M.set();
} else {
valueShape = M.setOf(elementShape);
}
break;
}
case 'copyBag': {
if (elementShape === undefined) {
valueShape = M.bag();
} else {
valueShape = M.bagOf(elementShape);
}
break;
}
default: {
assert.fail(X`unexpected asset kind ${q(assetKind)}`);
}
}
const amountShape = harden({
brand, // matches only this exact brand
value: valueShape,
});
return amountShape;
};
/**
* Make the paymentLedger, the source of truth for the balances of
* payments. All minting and transfer authority originates here.
*
* @template {AssetKind} [K=AssetKind]
* @param {Baggage} issuerBaggage
* @param {string} name
* @param {K} assetKind
* @param {DisplayInfo} displayInfo
* @param {Pattern} elementShape
* @param {ShutdownWithFailure=} optShutdownWithFailure
* @returns {PaymentLedger}
*/
export const vivifyPaymentLedger = (
issuerBaggage,
name,
assetKind,
displayInfo,
elementShape,
optShutdownWithFailure = undefined,
) => {
/** @type {Brand} */
const brand = vivifyFarInstance(issuerBaggage, `${name} brand`, BrandI, {
isMyIssuer(allegedIssuer) {
// BrandI delays calling this method until `allegedIssuer` is a Remotable
return allegedIssuer === issuer;
},
getAllegedName() {
return name;
},
// Give information to UI on how to display the amount.
getDisplayInfo() {
return displayInfo;
},
getAmountShape() {
return amountShape;
},
});
const emptyAmount = AmountMath.makeEmpty(brand, assetKind);
const amountShape = amountShapeFromElementShape(
brand,
assetKind,
elementShape,
);
const { IssuerI, MintI, PaymentI, PurseIKit } = makeIssuerInterfaces(
brand,
assetKind,
amountShape,
);
const makePayment = vivifyPaymentKind(issuerBaggage, name, brand, PaymentI);
/** @type {ShutdownWithFailure} */
const shutdownLedgerWithFailure = reason => {
// TODO This should also destroy ledger state.
// See https://github.com/Agoric/agoric-sdk/issues/3434
if (optShutdownWithFailure !== undefined) {
try {
optShutdownWithFailure(reason);
} catch (errInShutdown) {
assert.note(errInShutdown, X`Caused by: ${reason}`);
throw errInShutdown;
}
}
throw reason;
};
/** @type {WeakMapStore<Payment, Amount>} */
const paymentLedger = provideDurableWeakMapStore(
issuerBaggage,
'paymentLedger',
);
/**
* 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 interesting invariants here:
* * Every payment that is a key in the outer `paymentRecoverySets`
* weakMap is also in the recovery set indexed by that payment.
* * Implied by the above but worth stating: the payment is only
* in at most one recovery set.
* * A recovery set only contains such payments.
* * Every purse is associated with exactly one recovery set unique to
* it.
* * A purse's recovery set only contains payments withdrawn from
* that purse and not yet consumed.
*
* @type {WeakMapStore<Payment, SetStore<Payment>>}
*/
const paymentRecoverySets = provideDurableWeakMapStore(
issuerBaggage,
'paymentRecoverySets',
);
/**
* To maintain the invariants listed in the `paymentRecoverySets` comment,
* `initPayment` should contain the only
* call to `paymentLedger.init`.
*
* @param {Payment} payment
* @param {Amount} amount
* @param {SetStore<Payment>} [optRecoverySet]
*/
const initPayment = (payment, amount, optRecoverySet = undefined) => {
if (optRecoverySet !== undefined) {
optRecoverySet.add(payment);
paymentRecoverySets.init(payment, optRecoverySet);
}
paymentLedger.init(payment, amount);
};
/**
* To maintain the invariants listed in the `paymentRecoverySets` comment,
* `deletePayment` should contain the only
* call to `paymentLedger.delete`.
*
* @param {Payment} payment
*/
const deletePayment = payment => {
paymentLedger.delete(payment);
if (paymentRecoverySets.has(payment)) {
const recoverySet = paymentRecoverySets.get(payment);
paymentRecoverySets.delete(payment);
recoverySet.delete(payment);
}
};
/** @type {(left: Amount, right: Amount) => Amount } */
const add = (left, right) => AmountMath.add(left, right, brand);
/** @type {(left: Amount, right: Amount) => Amount } */
const subtract = (left, right) => AmountMath.subtract(left, right, brand);
/** @type {(allegedAmount: Amount) => Amount} */
const coerce = allegedAmount => AmountMath.coerce(brand, allegedAmount);
/** @type {(left: Amount, right: Amount) => boolean } */
const isEqual = (left, right) => AmountMath.isEqual(left, right, brand);
/**
* Methods like deposit() have an optional second parameter
* `optAmountShape`
* which, if present, is supposed to match the balance of the
* payment. This helper function does that check.
*
* Note: `optAmountShape` is user-supplied with no previous validation.
*
* @param {Amount} paymentBalance
* @param {Pattern=} optAmountShape
* @returns {void}
*/
const assertAmountConsistent = (paymentBalance, optAmountShape) => {
if (optAmountShape !== undefined) {
fit(paymentBalance, optAmountShape, 'amount');
}
};
/**
* @param {Payment} payment
* @returns {void}
*/
const assertLivePayment = payment => {
paymentLedger.has(payment) ||
assert.fail(
X`${payment} was not a live payment for brand ${q(
brand,
)}. It could be a used-up payment, a payment for another brand, or it might not be a payment at all.`,
);
};
/**
* Reallocate assets from the `payments` passed in to new payments
* created and returned, with balances from `newPaymentBalances`.
* Enforces that total assets are conserved.
*
* Note that this is not the only operation that moves assets.
* `purse.deposit` and `purse.withdraw` move assets between a purse and
* a payment, and so must also enforce conservation there.
*
* @param {Payment[]} payments
* @param {Amount[]} newPaymentBalances
* @returns {Payment[]}
*/
const moveAssets = (payments, newPaymentBalances) => {
assertCopyArray(payments, 'payments');
assertCopyArray(newPaymentBalances, 'newPaymentBalances');
// There may be zero, one, or many payments as input to
// moveAssets. We want to protect against someone passing in
// what appears to be multiple payments that turn out to actually
// be the same payment (an aliasing issue). The `combine` method
// legitimately needs to take in multiple payments, but we don't
// need to pay the costs of protecting against aliasing for the
// other uses.
if (payments.length > 1) {
const antiAliasingStore = new Set();
payments.forEach(payment => {
!antiAliasingStore.has(payment) ||
assert.fail(X`same payment ${payment} seen twice`);
antiAliasingStore.add(payment);
});
}
const total = payments.map(paymentLedger.get).reduce(add, emptyAmount);
const newTotal = newPaymentBalances.reduce(add, emptyAmount);
// Invariant check
isEqual(total, newTotal) ||
assert.fail(X`rights were not conserved: ${total} vs ${newTotal}`);
let newPayments;
try {
// COMMIT POINT
payments.forEach(payment => deletePayment(payment));
newPayments = newPaymentBalances.map(balance => {
const newPayment = makePayment();
initPayment(newPayment, balance, undefined);
return newPayment;
});
} catch (err) {
shutdownLedgerWithFailure(err);
throw err;
}
return harden(newPayments);
};
/**
* Used by the purse code to implement purse.deposit
*
* @param {Amount} currentBalance - the current balance of the purse
* before a deposit
* @param {(newPurseBalance: Amount) => void} updatePurseBalance -
* commit the purse balance
* @param {Payment} srcPayment
* @param {Pattern=} optAmountShape
* @returns {Amount}
*/
const depositInternal = (
currentBalance,
updatePurseBalance,
srcPayment,
optAmountShape = undefined,
) => {
assert(
!isPromise(srcPayment),
`deposit does not accept promises as first argument. Instead of passing the promise (deposit(paymentPromise)), consider unwrapping the promise first: E.when(paymentPromise, (actualPayment => deposit(actualPayment))`,
TypeError,
);
assertLivePayment(srcPayment);
const srcPaymentBalance = paymentLedger.get(srcPayment);
assertAmountConsistent(srcPaymentBalance, optAmountShape);
const newPurseBalance = add(srcPaymentBalance, currentBalance);
try {
// COMMIT POINT
// Move the assets in `srcPayment` into this purse, using up the
// source payment, such that total assets are conserved.
deletePayment(srcPayment);
updatePurseBalance(newPurseBalance);
} catch (err) {
shutdownLedgerWithFailure(err);
throw err;
}
return srcPaymentBalance;
};
/**
* Used by the purse code to implement purse.withdraw
*
* @param {Amount} currentBalance - the current balance of the purse
* before a withdrawal
* @param {(newPurseBalance: Amount) => void} updatePurseBalance -
* commit the purse balance
* @param {Amount} amount - the amount to be withdrawn
* @param {SetStore<Payment>} recoverySet
* @returns {Payment}
*/
const withdrawInternal = (
currentBalance,
updatePurseBalance,
amount,
recoverySet,
) => {
amount = coerce(amount);
AmountMath.isGTE(currentBalance, amount) ||
assert.fail(
X`Withdrawal of ${amount} failed because the purse only contained ${currentBalance}`,
);
const newPurseBalance = subtract(currentBalance, amount);
const payment = makePayment();
try {
// COMMIT POINT Move the withdrawn assets from this purse into
// payment. Total assets must remain conserved.
updatePurseBalance(newPurseBalance);
initPayment(payment, amount, recoverySet);
} catch (err) {
shutdownLedgerWithFailure(err);
throw err;
}
return payment;
};
const makeEmptyPurse = vivifyPurseKind(
issuerBaggage,
name,
assetKind,
brand,
PurseIKit,
harden({
depositInternal,
withdrawInternal,
}),
);
/** @type {Issuer} */
const issuer = vivifyFarInstance(issuerBaggage, `${name} issuer`, IssuerI, {
getBrand() {
return brand;
},
getAllegedName() {
return name;
},
getAssetKind() {
return assetKind;
},
getDisplayInfo() {
return displayInfo;
},
makeEmptyPurse() {
return makeEmptyPurse();
},
isLive(payment) {
// IssuerI delays calling this method until `payment` is a Remotable
return paymentLedger.has(payment);
},
getAmountOf(payment) {
// IssuerI delays calling this method until `payment` is a Remotable
assertLivePayment(payment);
return paymentLedger.get(payment);
},
burn(payment, optAmountShape = undefined) {
// IssuerI delays calling this method until `payment` is a Remotable
assertLivePayment(payment);
const paymentBalance = paymentLedger.get(payment);
assertAmountConsistent(paymentBalance, optAmountShape);
try {
// COMMIT POINT.
deletePayment(payment);
} catch (err) {
shutdownLedgerWithFailure(err);
throw err;
}
return paymentBalance;
},
claim(srcPayment, optAmountShape = undefined) {
// IssuerI delays calling this method until `srcPayment` is a Remotable
assertLivePayment(srcPayment);
const srcPaymentBalance = paymentLedger.get(srcPayment);
assertAmountConsistent(srcPaymentBalance, optAmountShape);
// Note COMMIT POINT within moveAssets.
const [payment] = moveAssets(
harden([srcPayment]),
harden([srcPaymentBalance]),
);
return payment;
},
combine(fromPaymentsPArray, optTotalAmount = undefined) {
// IssuerI does *not* delay calling `combine`, but rather leaves it
// to `combine` to delay further processing until all the elements of
// `fromPaymentsPArray` have fulfilled.
// Payments in `fromPaymentsPArray` must be distinct. Alias
// checking is delegated to the `moveAssets` function.
return Promise.all(fromPaymentsPArray).then(fromPaymentsArray => {
fromPaymentsArray.every(assertLivePayment);
const totalPaymentsBalance = fromPaymentsArray
.map(paymentLedger.get)
.reduce(add, emptyAmount);
assertAmountConsistent(totalPaymentsBalance, optTotalAmount);
// Note COMMIT POINT within moveAssets.
const [payment] = moveAssets(
harden(fromPaymentsArray),
harden([totalPaymentsBalance]),
);
return payment;
});
},
split(srcPayment, paymentAmountA) {
// IssuerI delays calling this method until `srcPayment` is a Remotable
paymentAmountA = coerce(paymentAmountA);
assertLivePayment(srcPayment);
const srcPaymentBalance = paymentLedger.get(srcPayment);
const paymentAmountB = subtract(srcPaymentBalance, paymentAmountA);
// Note COMMIT POINT within moveAssets.
const newPayments = moveAssets(
harden([srcPayment]),
harden([paymentAmountA, paymentAmountB]),
);
return newPayments;
},
splitMany(srcPayment, amounts) {
// IssuerI delays calling this method until `srcPayment` is a Remotable
assertLivePayment(srcPayment);
assertCopyArray(amounts, 'amounts');
amounts = amounts.map(coerce);
// Note COMMIT POINT within moveAssets.
const newPayments = moveAssets(harden([srcPayment]), harden(amounts));
return newPayments;
},
});
/** @type {Mint} */
const mint = vivifyFarInstance(issuerBaggage, `${name} mint`, MintI, {
getIssuer() {
return issuer;
},
mintPayment(newAmount) {
newAmount = coerce(newAmount);
fit(newAmount, amountShape, 'minted amount');
const payment = makePayment();
initPayment(payment, newAmount, undefined);
return payment;
},
});
const issuerKit = harden({ issuer, mint, brand });
return issuerKit;
};
harden(vivifyPaymentLedger);