Skip to content

Commit 311dc41

Browse files
authored
Merge pull request #1964 from Agoric/119-decimal
feat: add a decimals parameter, and decimals method to brand
2 parents 5e1a3e2 + eff15a4 commit 311dc41

File tree

5 files changed

+144
-13
lines changed

5 files changed

+144
-13
lines changed

packages/ERTP/src/displayInfo.js

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { assert, details, q } from '@agoric/assert';
2+
import {
3+
pureCopy,
4+
passStyleOf,
5+
REMOTE_STYLE,
6+
getInterfaceOf,
7+
} from '@agoric/marshal';
8+
9+
// TODO: assertSubset and assertKeysAllowed are copied from Zoe. Move
10+
// this code to a location where it can be used by ERTP and Zoe
11+
// easily. Perhaps another package.
12+
13+
/**
14+
* Assert all values from `part` appear in `whole`.
15+
*
16+
* @param {string[]} whole
17+
* @param {string[]} part
18+
*/
19+
export const assertSubset = (whole, part) => {
20+
part.forEach(key => {
21+
assert.typeof(key, 'string');
22+
assert(
23+
whole.includes(key),
24+
details`key ${q(key)} was not one of the expected keys ${q(whole)}`,
25+
);
26+
});
27+
};
28+
29+
// Assert that the keys of `record` are all in `allowedKeys`. If a key
30+
// of `record` is not in `allowedKeys`, throw an error. If a key in
31+
// `allowedKeys` is not a key of record, we do not throw an error.
32+
export const assertKeysAllowed = (allowedKeys, record) => {
33+
const keys = Object.getOwnPropertyNames(record);
34+
assertSubset(allowedKeys, keys);
35+
// assert that there are no symbol properties.
36+
assert(
37+
Object.getOwnPropertySymbols(record).length === 0,
38+
details`no symbol properties allowed`,
39+
);
40+
};
41+
42+
export const assertDisplayInfo = allegedDisplayInfo => {
43+
if (allegedDisplayInfo === undefined) {
44+
return;
45+
}
46+
const displayInfoKeys = harden(['decimalPlaces']);
47+
assertKeysAllowed(displayInfoKeys, allegedDisplayInfo);
48+
};
49+
50+
export const coerceDisplayInfo = allegedDisplayInfo => {
51+
if (passStyleOf(allegedDisplayInfo) === REMOTE_STYLE) {
52+
// These condition together try to ensure that `allegedDisplayInfo`
53+
// is a plain empty object. It will accept all plain empty objects
54+
// that it should. It will reject most things we want to reject including
55+
// remotables that are explicitly declared `Remotable`. But a normal
56+
// HandledPromise presence not explicitly declared `Remotable` will
57+
// be mistaken for a plain empty object. Even in this case, the copy
58+
// has a new identity, so the only danger is that we didn't reject
59+
// with a diagnostic, potentially masking a programmer error.
60+
assert(Object.isFrozen(allegedDisplayInfo));
61+
assert.equal(Reflect.ownKeys(allegedDisplayInfo).length, 0);
62+
assert.equal(Object.getPrototypeOf(allegedDisplayInfo), Object.prototype);
63+
assert.equal(getInterfaceOf(allegedDisplayInfo), undefined);
64+
return harden({});
65+
}
66+
allegedDisplayInfo = pureCopy(allegedDisplayInfo);
67+
assertDisplayInfo(allegedDisplayInfo);
68+
return allegedDisplayInfo;
69+
};

packages/ERTP/src/issuer.js

+11-4
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@ import { isPromise } from '@agoric/promise-kit';
1010

1111
import { makeAmountMath, MathKind } from './amountMath';
1212
import { makeInterface, ERTPKind } from './interfaces';
13+
import { coerceDisplayInfo } from './displayInfo';
1314

1415
import './types';
1516

1617
/**
17-
* @param {string} allegedName
18-
* @param {AmountMathKind} [amountMathKind=MathKind.NAT]
19-
* @returns {IssuerKit}
18+
* @type {MakeIssuerKit}
2019
*/
21-
function makeIssuerKit(allegedName, amountMathKind = MathKind.NAT) {
20+
function makeIssuerKit(
21+
allegedName,
22+
amountMathKind = MathKind.NAT,
23+
displayInfo = undefined,
24+
) {
2225
assert.typeof(allegedName, 'string');
26+
displayInfo = coerceDisplayInfo(displayInfo);
2327

2428
const brand = Remotable(
2529
makeInterface(allegedName, ERTPKind.BRAND),
@@ -32,6 +36,9 @@ function makeIssuerKit(allegedName, amountMathKind = MathKind.NAT) {
3236
});
3337
},
3438
getAllegedName: () => allegedName,
39+
40+
// Give information to UI on how to display the amount.
41+
getDisplayInfo: () => displayInfo,
3542
},
3643
);
3744

packages/ERTP/src/types.js

+20-1
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,20 @@
9797
* to set subtraction.
9898
*/
9999

100+
/**
101+
* @typedef {Object} DisplayInfo
102+
* @property {number=} decimalPlaces
103+
* Tells the display software how many decimal places to move the
104+
* decimal over to the left, or in other words, which position corresponds to whole
105+
* numbers. We require fungible digital assets to be represented in
106+
* integers, in the smallest unit (i.e. USD might be represented in mill,
107+
* a thousandth of a dollar. In that case, `decimalPlaces` would be 3.)
108+
* This property is optional, and for non-fungible digital assets,
109+
* should not be specified.
110+
* The decimalPlaces property should be used for *display purposes only*. Any
111+
* other use is an anti-pattern.
112+
*/
113+
100114
/**
101115
* @typedef {Object} Brand
102116
* The brand identifies the kind of issuer, and has a function to get the
@@ -112,6 +126,8 @@
112126
* @property {(allegedIssuer: ERef<Issuer>) => Promise<boolean>} isMyIssuer Should be used with
113127
* `issuer.getBrand` to ensure an issuer and brand match.
114128
* @property {() => string} getAllegedName
129+
* @property {() => DisplayInfo} getDisplayInfo
130+
* Give information to UI on how to display the amount.
115131
*/
116132

117133
/**
@@ -188,7 +204,8 @@
188204
/**
189205
* @callback MakeIssuerKit
190206
* @param {string} allegedName
191-
* @param {AmountMathKind=} amountMathKind
207+
* @param {AmountMathKind} [amountMathKind=MathKind.NAT]
208+
* @param {DisplayInfo=} [displayInfo=undefined]
192209
* @returns {IssuerKit}
193210
*
194211
* The allegedName becomes part of the brand in asset descriptions. The
@@ -200,6 +217,8 @@
200217
* from the mathHelpers library. For example, natMathHelpers, the
201218
* default, is used for basic fungible tokens.
202219
*
220+
* `displayInfo` gives information to UI on how to display the amount.
221+
*
203222
* @typedef {Object} IssuerKit
204223
* The return value of makeIssuerKit
205224
*

packages/ERTP/test/unitTests/test-issuerObj.js

+42-7
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,40 @@ test('issuer.getBrand, brand.isMyIssuer', t => {
1414
);
1515
t.is(issuer.getAllegedName(), myBrand.getAllegedName());
1616
t.is(issuer.getAllegedName(), 'fungible');
17+
t.is(brand.getDisplayInfo(), undefined);
18+
});
19+
20+
test('brand.getDisplayInfo()', t => {
21+
const displayInfo = harden({ decimalPlaces: 3 });
22+
const { brand } = makeIssuerKit('fungible', MathKind.NAT, displayInfo);
23+
t.deepEqual(brand.getDisplayInfo(), displayInfo);
24+
const display = amount => {
25+
const { brand: myBrand, value } = amount;
26+
const { decimalPlaces } = myBrand.getDisplayInfo();
27+
const valueDisplay = value.toString();
28+
const length = valueDisplay.length;
29+
return [
30+
valueDisplay.slice(0, length - decimalPlaces),
31+
'.',
32+
valueDisplay.slice(length - decimalPlaces),
33+
].join('');
34+
};
35+
t.is(display({ brand, value: 3000 }), '3.000');
36+
});
37+
38+
test('bad display info', t => {
39+
const displayInfo = harden({ somethingUnexpected: 3 });
40+
// @ts-ignore
41+
t.throws(() => makeIssuerKit('fungible', MathKind.NAT, displayInfo), {
42+
message:
43+
'key "somethingUnexpected" was not one of the expected keys ["decimalPlaces"]',
44+
});
45+
});
46+
47+
test('empty display info', t => {
48+
const displayInfo = harden({});
49+
const { brand } = makeIssuerKit('fungible', MathKind.NAT, displayInfo);
50+
t.deepEqual(brand.getDisplayInfo(), displayInfo);
1751
});
1852

1953
test('amountMath from makeIssuerKit', async t => {
@@ -117,7 +151,7 @@ test('purse.deposit', async t => {
117151
.then(checkDeposit(fungible17, fungibleSum));
118152
});
119153

120-
test('purse.deposit promise', t => {
154+
test('purse.deposit promise', async t => {
121155
t.plan(1);
122156
const { issuer, mint, amountMath } = makeIssuerKit('fungible');
123157
const fungible25 = amountMath.make(25);
@@ -126,7 +160,8 @@ test('purse.deposit promise', t => {
126160
const payment = mint.mintPayment(fungible25);
127161
const exclusivePaymentP = E(issuer).claim(payment);
128162

129-
return t.throwsAsync(
163+
await t.throwsAsync(
164+
// @ts-ignore
130165
() => E(purse).deposit(exclusivePaymentP, fungible25),
131166
{ message: /deposit does not accept promises/ },
132167
'failed to reject a promise for a payment',
@@ -198,11 +233,11 @@ test('issuer.claim', async t => {
198233
});
199234
});
200235

201-
test('issuer.splitMany bad amount', t => {
236+
test('issuer.splitMany bad amount', async t => {
202237
const { mint, issuer, amountMath } = makeIssuerKit('fungible');
203238
const payment = mint.mintPayment(amountMath.make(1000));
204239
const badAmounts = Array(2).fill(amountMath.make(10));
205-
return t.throwsAsync(
240+
await t.throwsAsync(
206241
_ => E(issuer).splitMany(payment, badAmounts),
207242
{ message: /rights were not conserved/ },
208243
'successfully throw if rights are not conserved in proposed new payments',
@@ -238,11 +273,11 @@ test('issuer.splitMany good amount', async t => {
238273
.then(checkPayments);
239274
});
240275

241-
test('issuer.split bad amount', t => {
276+
test('issuer.split bad amount', async t => {
242277
const { mint, issuer, amountMath } = makeIssuerKit('fungible');
243278
const { amountMath: otherUnitOps } = makeIssuerKit('other fungible');
244279
const payment = mint.mintPayment(amountMath.make(1000));
245-
return t.throwsAsync(
280+
await t.throwsAsync(
246281
_ => E(issuer).split(payment, otherUnitOps.make(10)),
247282
{
248283
message: /the brand in the allegedAmount in 'coerce' didn't match the amountMath brand/,
@@ -343,7 +378,7 @@ test('issuer.combine bad payments', async t => {
343378
const otherPayment = otherMint.mintPayment(otherAmountMath.make(10));
344379
payments.push(otherPayment);
345380

346-
return t.throwsAsync(
381+
await t.throwsAsync(
347382
() => E(issuer).combine(payments),
348383
{ message: /"payment" not found/ },
349384
'payment from other mint is not found',

packages/zoe/test/unitTests/zcf/test-zcf.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,8 @@ test(`zcf.saveIssuer - bad issuer`, async t => {
213213
await t.throwsAsync(() => zcf.saveIssuer(moolaKit.brand, 'A'), {
214214
// TODO: improve error message
215215
// https://github.com/Agoric/agoric-sdk/issues/1701
216-
message: 'target has no method "getBrand", has [getAllegedName,isMyIssuer]',
216+
message:
217+
'target has no method "getBrand", has [getAllegedName,getDisplayInfo,isMyIssuer]',
217218
});
218219
});
219220

0 commit comments

Comments
 (0)