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

feat: pay ui #45

Merged
merged 4 commits into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions contract/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
start-sell-concert-tickets-permit.json
start-sell-concert-tickets.js
bundles/
,tx.json
20 changes: 16 additions & 4 deletions contract/src/postal-service.contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,18 @@ export const meta = harden({
// compatibility with an earlier contract metadata API
export const { customTermsShape } = meta;

let issuerNumber = 1;
Copy link
Member

Choose a reason for hiding this comment

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

move this inside start(), please. No static mutable state.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I considered making it durable, but saw that this was what the swap contract does already ,so I figured it's not critical to preserve the issuer numbers. Is no static mutable state a hard rule? I can update it in both contracts if needed.

Copy link
Member

Choose a reason for hiding this comment

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

I can update it in both contracts if needed.

Please do.

Is no static mutable state a hard rule?

It's part of OCap Discipline that isn't (yet?) enforced by our platform.

SES lockdown docs say:

SES introduces the lockdown() function. Calling lockdown() alters the surrounding execution environment, or realm, such that no two programs running in the same realm can observe or affect each other until they have been introduced, and even then can only interact through their own exposed interfaces.

But that's not quite true: SES doesn't (yet?) get rid of module-level static mutable state such as

let callCount = 0;

export const complementaryColor = _ => {
  callCount += 1;
  return 'green';
}

export const colorCallCount = () => callCount;

So one program could do

import { complementaryColor } from './fun-stuff.js';

console.log(complementaryColor('blue');

and another could do

import { colorCallCount } from './fun-stuff.js`;

console.log('there have been', colorCallCount(), 'calls to complementaryColor');

cc @kriskowal @erights

Copy link
Member

Choose a reason for hiding this comment

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

Nothing in the postal service contract is durable, so that would be above end beyond the call of duty here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah I see what you mean, move it into the function scope but don't necessarily make it durable. Done.

const IssuerShape = M.remotable('Issuer');
Copy link
Member

Choose a reason for hiding this comment

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

import from ertp?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. I did a deep import because of a comment in the swap contract about minimizing bundle size.


/**
* @typedef {object} PostalSvcTerms
* @property {import('@agoric/vats').NameHub} namesByAddress
*/

/** @param {ZCF<PostalSvcTerms>} zcf */
export const start = zcf => {
const { namesByAddress, issuers } = zcf.getTerms();
const { namesByAddress } = zcf.getTerms();
mustMatch(namesByAddress, M.remotable('namesByAddress'));
console.log('postal-service issuers', Object.keys(issuers));

/**
* @param {string} addr
Expand All @@ -38,9 +40,19 @@ export const start = zcf => {
*/
const sendTo = (addr, pmt) => E(getDepositFacet(addr)).receive(pmt);

/** @param {string} recipient */
const makeSendInvitation = recipient => {
/**
* @param {string} recipient
* @param {Issuer[]} issuers
*/
const makeSendInvitation = (recipient, issuers) => {
assert.typeof(recipient, 'string');
mustMatch(issuers, M.arrayOf(IssuerShape));
Copy link
Member

Choose a reason for hiding this comment

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

Did you consider making the issuers arg optional?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The UI is always just going to send the issuers anyway so it doesn't seem worth the complexity. We'd have to query and check which issuers are already added first, I'm not sure if they're published anywhere either.


for (const i of issuers) {
if (!Object.values(zcf.getTerms().issuers).includes(i)) {
zcf.saveIssuer(i, `Issuer${(issuerNumber += 1)}`);
}
}

/** @type {OfferHandler} */
const handleSend = async seat => {
Expand Down
14 changes: 2 additions & 12 deletions contract/src/postal-service.proposal.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@
*/
// @ts-check

import { E } from '@endo/far';
import { fixHub } from './fixHub.js';
import {
installContract,
startContract,
} from './platform-goals/start-contract.js';
import { allValues } from './objectTools.js';

const { Fail } = assert;

Expand All @@ -23,17 +21,15 @@ const contractName = 'postalService';
* @param {BootstrapPowers} powers
* @param {{ options?: { postalService: {
* bundleID: string;
* issuerNames?: string[];
* }}}} [config]
*/
export const startPostalService = async (powers, config) => {
const {
consume: { namesByAddressAdmin, agoricNames },
consume: { namesByAddressAdmin },
} = powers;
const {
// must be supplied by caller or template-replaced
bundleID = Fail`no bundleID`,
issuerNames = ['IST', 'Invitation', 'BLD', 'ATOM'],
} = config?.options?.[contractName] ?? {};

const installation = await installContract(powers, {
Expand All @@ -44,15 +40,9 @@ export const startPostalService = async (powers, config) => {
const namesByAddress = await fixHub(namesByAddressAdmin);
const terms = harden({ namesByAddress });

const issuerKeywordRecord = await allValues(
Copy link
Member

Choose a reason for hiding this comment

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

This regression is a little surprising. But I guess I don't mind too much.

Object.fromEntries(
issuerNames.map(n => [n, E(agoricNames).lookup('issuer', n)]),
),
);

await startContract(powers, {
name: contractName,
startArgs: { installation, issuerKeywordRecord, terms },
startArgs: { installation, terms },
});
};

Expand Down
7 changes: 4 additions & 3 deletions contract/test/market-actors.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ const { entries, fromEntries, keys } = Object;
* }} mine
* @param {{
* rxAddr: string,
* toSend: AmountKeywordRecord;
* toSend: AmountKeywordRecord,
* issuers: Issuer[]
* }} shared
*/
export const payerPete = async (
t,
{ wallet, queryTool },
{ rxAddr, toSend },
{ rxAddr, toSend, issuers },
) => {
const hub = await makeAgoricNames(queryTool);
/** @type {WellKnown} */
Expand All @@ -55,7 +56,7 @@ export const payerPete = async (
source: 'contract',
instance,
publicInvitationMaker: 'makeSendInvitation',
invitationArgs: [rxAddr],
invitationArgs: [rxAddr, issuers],
},
proposal: { give: toSend },
};
Expand Down
3 changes: 3 additions & 0 deletions contract/test/snapshots/test-postalSvc.js.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ Generated by [AVA](https://avajs.dev).
instance: Object @Alleged: InstanceHandle {},
invitationArgs: [
'agoric1aap7m84dt0rwhhfw49d4kv2gqetzl56vn8aaxj',
[
Object @Alleged: ATOM issuer {},
],
],
publicInvitationMaker: 'makeSendInvitation',
source: 'contract',
Expand Down
Binary file modified contract/test/snapshots/test-postalSvc.js.snap
Binary file not shown.
5 changes: 3 additions & 2 deletions contract/test/test-postalSvc.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ test.serial('deploy contract with core eval: postalService / send', async t => {
behavior: startPostalService,
entryFile: scriptRoots.postalService,
config: {
options: { postalService: { bundleID, issuerNames: ['ATOM', 'Item'] } },
options: { postalService: { bundleID } },
},
});

Expand Down Expand Up @@ -160,6 +160,7 @@ test.serial('deliver payment using offer', async t => {
toSend: {
Pmt: amt(await agoricNames.brand.ATOM, 3n),
},
issuers: [await agoricNames.issuer.ATOM],
};

const wallet = {
Expand Down Expand Up @@ -192,7 +193,7 @@ test('send invitation* from contract using publicFacet of postalService', async
const postalPowers = extract(permit, powers);
await startPostalService(postalPowers, {
options: {
postalService: { bundleID, issuerNames: ['IST', 'Invitation'] },
postalService: { bundleID },
},
});

Expand Down
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "tsc && NODE_OPTIONS=--max-old-space-size=4096 vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "yarn lint --fix",
"preview": "vite preview",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { PurseJSONState } from '@agoric/react-components';
import type { DisplayInfoForBrand } from '../../store/displayInfo';
import type { DisplayInfoForBrand } from '../store/displayInfo';
import { stringifyValue, type AssetKind } from '@agoric/web-components';
import type { Amount } from '@agoric/ertp/src/types';
import { isCopyBagValue } from '@agoric/ertp';
import { useEffect, useRef, useState } from 'react';
import { stringifyData } from '../../utils/stringify';
import { stringifyData } from '../utils/stringify';

export const PurseValue = ({
purse,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { AmountInput, type PurseJSONState } from '@agoric/react-components';
import type { Amount, AssetKind } from '@agoric/web-components';
import { useState } from 'react';
import { CopyBagEntry, PurseValue, SetEntry } from './DisplayAmount';
import { stringifyData } from '../../utils/stringify';
import { stringifyData } from '../utils/stringify';
import { makeCopyBag } from '@endo/patterns';

type Props = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Amount } from '@agoric/web-components';
import { useDisplayInfo } from '../../store/displayInfo';
import { useDisplayInfo } from '../store/displayInfo';
import { AmountValue } from './DisplayAmount';

type Props = {
Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TabWrapper } from './TabWrapper';
import { Notifications } from './Notifications';
import { NotificationContext } from '../context/NotificationContext';
import Swap from './swap/Swap';
import Pay from './pay/Pay';

// notification related types
const dynamicToastChildStatuses = [
Expand Down Expand Up @@ -65,7 +66,7 @@ const Tabs = () => {
activeTab={activeTab}
handleTabClick={handleTabClick}
>
<div>TBD</div>
<Pay />
</TabWrapper>
<TabWrapper
tab="Vote"
Expand Down
148 changes: 148 additions & 0 deletions ui/src/components/pay/Pay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useAgoric } from '@agoric/react-components';
import ProposalAmountsBox from '../ProposalAmountsBox';
import RecipientInput from '../RecipientInput';
import { queryPurses } from '../../utils/queryPurses';
import { useContext, useEffect, useState } from 'react';
import type { Amount } from '@agoric/web-components';
import { useDisplayInfo } from '../../store/displayInfo';
import { NotificationContext } from '../../context/NotificationContext';
import { queryIssuers } from '../../utils/queryIssuers';

const Pay = () => {
const { addNotification } = useContext(NotificationContext);
const { purses, chainStorageWatcher, makeOffer } = useAgoric();
const [recipientAddr, setRecipientAddr] = useState('');
const [recipientError, setRecipientError] = useState('');
const [myAmounts, setMyAmounts] = useState<Amount[]>([]);
const { brandToDisplayInfo } = useDisplayInfo(({ brandToDisplayInfo }) => ({
brandToDisplayInfo,
}));

useEffect(() => {
let isCancelled = false;
const checkRecipientSmartWallet = async () => {
if (chainStorageWatcher && recipientAddr) {
try {
await queryPurses(chainStorageWatcher, recipientAddr);
} catch (e) {
if (!isCancelled) {
setRecipientError('Failed to fetch recipient wallet.');
}
}
}
};

if (!recipientAddr.length) {
setRecipientError('');
} else if (
recipientAddr.startsWith('agoric') &&
recipientAddr.length === 45
) {
setRecipientError('');
checkRecipientSmartWallet();
} else {
setRecipientError('Invalid address format');
}

return () => {
isCancelled = true;
};
}, [chainStorageWatcher, recipientAddr]);

const sendOffer = async () => {
assert(chainStorageWatcher && makeOffer);

assert(chainStorageWatcher && makeOffer);
try {
const brandPetnameToIssuer = await queryIssuers(chainStorageWatcher);
const issuers = new Set(
[...myAmounts].map(amount => {
const { petname } = brandToDisplayInfo.get(amount.brand)!;
return brandPetnameToIssuer.get(petname);
}),
);

const invitationSpec = {
source: 'agoricContract',
instancePath: ['postalService'],
callPipe: [['makeSendInvitation', [recipientAddr, [...issuers]]]],
};

const gives = myAmounts.map(amount => {
const { petname } = brandToDisplayInfo.get(amount.brand)!;
return [petname, amount];
});
const proposal = {
give: { ...Object.fromEntries(gives) },
want: {},
};

makeOffer(
invitationSpec,
proposal,
undefined,
(update: { status: string; data?: unknown }) => {
if (update.status === 'error') {
addNotification!({
text: `Payment Error: ${update.data}`,
status: 'error',
});
}
if (update.status === 'accepted') {
addNotification!({
text: 'Payment Sent',
status: 'success',
});
}
if (update.status === 'refunded') {
addNotification!({
text: 'Payment Refunded',
status: 'warning',
});
}
},
);
} catch (e) {
addNotification!({
text: `Offer error: ${e}`,
status: 'error',
});
}
};

const isButtonDisabled = !makeOffer || !recipientAddr || !myAmounts.length;

return (
<div className="items-top flex w-full flex-col justify-around lg:flex-row">
<div>
<h2 className="daisyui-card-title mb-2 w-full">Send Payment</h2>
<div className="daisyui-card h-fit w-96 bg-base-300 px-4 py-4 shadow-xl">
<RecipientInput
address={recipientAddr}
onChange={addr => setRecipientAddr(addr)}
error={recipientError}
/>
<div className="my-1">
<h2 className="mb-2 text-lg font-medium">Give</h2>
<ProposalAmountsBox
actionLabel="Add from Your Purse"
amounts={myAmounts}
purses={purses}
onChange={setMyAmounts}
warning={purses ? undefined : 'Wallet Not Connected'}
/>
</div>
<button
onClick={sendOffer}
disabled={isButtonDisabled}
className="daisyui-btn daisyui-btn-primary mt-4 w-full self-center text-lg"
>
Send Payment
</button>
</div>
</div>
</div>
);
};

export default Pay;
2 changes: 1 addition & 1 deletion ui/src/components/swap/FeeInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Amount } from '@agoric/web-components';
import { AmountValue } from './DisplayAmount';
import { AmountValue } from '../DisplayAmount';

type Props = {
fee: Amount;
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/swap/IncomingOffer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Amount, AssetKind, type Brand } from '@agoric/web-components';
import { AmountValue } from './DisplayAmount';
import { AmountValue } from '../DisplayAmount';
import { useAgoric } from '@agoric/react-components';
import { useContext, useEffect, useState } from 'react';
import { useDisplayInfo } from '../../store/displayInfo';
Expand Down
Loading