diff --git a/tools/contract/.gitignore b/tools/contract/.gitignore new file mode 100644 index 000000000..eb994f6c6 --- /dev/null +++ b/tools/contract/.gitignore @@ -0,0 +1,3 @@ +start-offer-up-permit.json +start-offer-up.js +bundles/ diff --git a/tools/contract/Makefile b/tools/contract/Makefile new file mode 100644 index 000000000..f2b850691 --- /dev/null +++ b/tools/contract/Makefile @@ -0,0 +1,121 @@ +CHAINID=agoriclocal +USER1ADDR=$(shell agd keys show user1 -a --keyring-backend="test") +ACCT_ADDR=$(USER1ADDR) +BLD=000000ubld + +ATOM_DENOM=ibc/BA313C4A19DFBF943586C0387E6B11286F9E416B4DD27574E6909CABE0E342FA +ATOM=000000$(ATOM_DENOM) + +.PHONY: list +# https://stackoverflow.com/a/73159833/7963 +list: + @make -npq : 2> /dev/null | grep -v PHONY |\ + awk -v RS= -F: '$$1 ~ /^[^#%]+$$/ { print $$1 }' + +balance-q: + agd keys show user1 -a --keyring-backend="test" + agd query bank balances $(ACCT_ADDR) + +GAS_ADJUSTMENT=1.2 +SIGN_BROADCAST_OPTS=--keyring-backend=test --chain-id=$(CHAINID) \ + --gas=auto --gas-adjustment=$(GAS_ADJUSTMENT) \ + --yes -b block + +mint100: + make FUNDS=1000$(ATOM) fund-acct + cd /usr/src/agoric-sdk && \ + yarn --silent agops vaults open --wantMinted 100 --giveCollateral 100 >/tmp/want-ist.json && \ + yarn --silent agops perf satisfaction --executeOffer /tmp/want-ist.json --from user1 --keyring-backend=test + +# https://agoric.explorers.guru/proposal/61 +lower-bundle-cost: bundles/lower-bundle-cost.json ./scripts/voteLatestProposalAndWait.sh + agd tx gov submit-proposal param-change bundles/lower-bundle-cost.json \ + $(SIGN_BROADCAST_OPTS) \ + --from user1 + ./scripts/voteLatestProposalAndWait.sh + # agd query swingset params + + +bundles/swingset-params.json: + mkdir -p bundles/ + agd query swingset params -o json >$@ + +.ONESHELL: +bundles/lower-bundle-cost.json: bundles/swingset-params.json + @read PARAMS < bundles/swingset-params.json; export PARAMS + node - <<- EOF >$@ + const storageByte = '20000000'; + const paramChange = { + title: 'Lower Bundle Cost to 0.02 IST/Kb (a la mainnet 61)', + description: '0.02 IST/Kb', + deposit: '10000000ubld', + changes: [{ + subspace: 'swingset', + key: 'beans_per_unit', + value: '...', + }], + }; + const params = JSON.parse(process.env.PARAMS); + const ix = params.beans_per_unit.findIndex(({key}) => key === 'storageByte'); + params.beans_per_unit[ix].beans = storageByte; + paramChange.changes[0].value = params.beans_per_unit; + console.log(JSON.stringify(paramChange, null, 2)); + EOF + +# Keep mint4k around a while for compatibility +mint4k: + make FUNDS=1000$(ATOM) fund-acct + cd /usr/src/agoric-sdk && \ + yarn --silent agops vaults open --wantMinted 4000 --giveCollateral 1000 >/tmp/want4k.json && \ + yarn --silent agops perf satisfaction --executeOffer /tmp/want4k.json --from user1 --keyring-backend=test + +FUNDS=321$(BLD) +fund-acct: + agd tx bank send validator $(ACCT_ADDR) $(FUNDS) \ + $(SIGN_BROADCAST_OPTS) \ + -o json >,tx.json + jq '{code: .code, height: .height}' ,tx.json + +gov-q: + agd query gov proposals --output json | \ + jq -c '.proposals[] | [.proposal_id,.voting_end_time,.status]' + +gov-voting-q: + agd query gov proposals --status=voting_period --output json | \ + jq -c '.proposals[].proposal_id' + +PROPOSAL=1 +VOTE_OPTION=yes +vote: + agd tx gov vote $(PROPOSAL) $(VOTE_OPTION) --from=validator \ + $(SIGN_BROADCAST_OPTS) \ + -o json >,tx.json + jq '{code: .code, height: .height}' ,tx.json + +instance-q: + agd query vstorage data published.agoricNames.instance -o json + +print-key: /root/.agoric/user1.key + @echo Import the following mnemonic into Keplr: + @cat $< + @echo + @echo -n 'The resulting address should be: ' + @agd keys show user1 -a --keyring-backend="test" + @echo + +SCRIPT=start-offer-up.js +PERMIT=start-offer-up-permit.json +start-contract: $(SCRIPT) $(PERMIT) install-bundles + scripts/propose-start-contract.sh + +install-bundles: bundles/bundle-list + ./scripts/install-bundles.sh + +build-proposal: bundles/bundle-list + +bundles/bundle-list $(SCRIPT) $(PERMIT): + ./scripts/build-proposal.sh + + +clean: + @rm -rf $(SCRIPT) $(PERMIT) bundles/ diff --git a/tools/contract/jsconfig.json b/tools/contract/jsconfig.json new file mode 100644 index 000000000..e7dca0046 --- /dev/null +++ b/tools/contract/jsconfig.json @@ -0,0 +1,19 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "compilerOptions": { + "target": "esnext", + "module": "ES2022", + + "noEmit": true, + /* + // The following flags are for creating .d.ts files: + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, +*/ + "downlevelIteration": true, + "strictNullChecks": true, + "moduleResolution": "node" + }, + "include": ["src/**/*.js", "test/**/*.js", "exported.js", "globals.d.ts"] +} diff --git a/tools/contract/package.json b/tools/contract/package.json new file mode 100644 index 000000000..3a53aca50 --- /dev/null +++ b/tools/contract/package.json @@ -0,0 +1,85 @@ +{ + "name": "demo2-contract", + "version": "0.1.0", + "private": true, + "description": "Offer Up Contract", + "type": "module", + "scripts": { + "start:docker": "docker compose up -d", + "docker:logs": "docker compose logs --tail 200 -f", + "docker:bash": "docker compose exec agd bash", + "docker:make": "docker compose exec agd make -C /workspace/contract", + "make:help": "make list", + "start": "yarn docker:make clean start-contract print-key", + "build": "agoric run scripts/build-contract-deployer.js", + "test": "ava --verbose", + "lint": "eslint '**/*.js'", + "lint:fix": "eslint --fix '**/*.js'" + }, + "devDependencies": { + "@agoric/deploy-script-support": "^0.10.4-u12.0", + "@agoric/eslint-config": "dev", + "@endo/bundle-source": "^2.8.0", + "@endo/eslint-plugin": "^0.5.2", + "@endo/init": "^0.5.60", + "@endo/promise-kit": "0.2.56", + "@endo/ses-ava": "^0.2.44", + "@jessie.js/eslint-plugin": "^0.4.0", + "@typescript-eslint/eslint-plugin": "^6.7.0", + "@typescript-eslint/parser": "^6.7.0", + "agoric": "^0.21.2-u12.0", + "ava": "^5.3.0", + "eslint": "^8.47.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-jessie": "^0.0.6", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-ava": "^14.0.0", + "eslint-plugin-github": "^4.10.0", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jsdoc": "^46.4.3", + "eslint-plugin-prettier": "^5.0.0", + "import-meta-resolve": "^2.2.1", + "prettier": "^3.0.3", + "prettier-plugin-jsdoc": "^1.0.0", + "type-coverage": "^2.26.3", + "typescript": "~5.2.2" + }, + "dependencies": { + "@agoric/ertp": "^0.16.3-u12.0", + "@agoric/zoe": "^0.26.3-u12.0", + "@endo/far": "^0.2.22", + "@endo/marshal": "^0.8.9", + "@endo/patterns": "^0.2.5" + }, + "ava": { + "files": [ + "test/**/test-*.js" + ], + "timeout": "10m" + }, + "keywords": [], + "repository": { + "type": "git", + "url": "git+https://github.com/Agoric/dapp-offer-up" + }, + "author": "Agoric", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/Agoric/dapp-offer-up/issues" + }, + "homepage": "https://github.com/Agoric/dapp-offer-up#readme", + "eslintConfig": { + "parserOptions": { + "sourceType": "module", + "ecmaVersion": 2021 + }, + "extends": [ + "@agoric" + ] + }, + "prettier": { + "trailingComma": "all", + "arrowParens": "avoid", + "singleQuote": true + } +} \ No newline at end of file diff --git a/tools/contract/scripts/build-contract-deployer.js b/tools/contract/scripts/build-contract-deployer.js new file mode 100644 index 000000000..f9732f1a3 --- /dev/null +++ b/tools/contract/scripts/build-contract-deployer.js @@ -0,0 +1,42 @@ +/** + * @file Permission Contract Deployment builder + * + * Creates files for starting an instance of the contract: + * * contract source and instantiation proposal bundles to be published via + * `agd tx swingset install-bundle` + * * start-offer-up-permit.json and start-offer-up.js to submit the + * instantiation proposal via `agd tx gov submit-proposal swingset-core-eval` + * + * Usage: + * agoric run build-contract-deployer.js + */ + +import { makeHelpers } from '@agoric/deploy-script-support'; +import { getManifestForOfferUp } from '../src/offer-up-proposal.js'; + +/** @type {import('@agoric/deploy-script-support/src/externalTypes.js').ProposalBuilder} */ +export const offerUpProposalBuilder = async ({ publishRef, install }) => { + return harden({ + sourceSpec: '../src/offer-up-proposal.js', + getManifestCall: [ + getManifestForOfferUp.name, + { + offerUpRef: publishRef( + install( + '../src/offer-up.contract.js', + '../bundles/bundle-offer-up.js', + { + persist: true, + }, + ), + ), + }, + ], + }); +}; + +/** @type {DeployScriptFunction} */ +export default async (homeP, endowments) => { + const { writeCoreProposal } = await makeHelpers(homeP, endowments); + await writeCoreProposal('start-offer-up', offerUpProposalBuilder); +}; diff --git a/tools/contract/scripts/build-proposal.sh b/tools/contract/scripts/build-proposal.sh new file mode 100755 index 000000000..68bdcadb5 --- /dev/null +++ b/tools/contract/scripts/build-proposal.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# NOTE: intended to run _inside_ the agd container + +cd /workspace/contract + +mkdir -p bundles +(agoric run ./scripts/build-contract-deployer.js )>/tmp/,run.log +./scripts/parseProposals.mjs bundles/bundle-list + + diff --git a/tools/contract/scripts/install-bundles.sh b/tools/contract/scripts/install-bundles.sh new file mode 100755 index 000000000..717257bb2 --- /dev/null +++ b/tools/contract/scripts/install-bundles.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# NOTE: intended to run _inside_ the agd container + +set -xueo pipefail + +cd /workspace/contract + +# TODO: try `agoric publish` to better track outcome +install_bundle() { + ls -sh "$1" + agd tx swingset install-bundle --compress "@$1" \ + --from user1 --keyring-backend=test --gas=auto --gas-adjustment=1.2 \ + --chain-id=agoriclocal -bblock --yes -o json +} + +# exit fail if bundle-list is emtpy +[ -s bundles/bundle-list ] || exit 1 + +make balance-q # do we have enough IST? + +for b in $(cat bundles/bundle-list); do + echo installing $b + install_bundle $b +done diff --git a/tools/contract/scripts/parseProposals.mjs b/tools/contract/scripts/parseProposals.mjs new file mode 100755 index 000000000..daab1be36 --- /dev/null +++ b/tools/contract/scripts/parseProposals.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +import fs from 'fs'; + +const Fail = (template, ...args) => { + throw Error(String.raw(template, ...args.map(val => String(val)))); +}; + +/** + * Parse output of `agoric run proposal-builder.js` + * + * @param {string} txt + * + * adapted from packages/boot/test/bootstrapTests/supports.js + */ +const parseProposalParts = txt => { + const evals = [ + ...txt.matchAll(/swingset-core-eval (?\S+) (? + + diff --git a/tools/ui/package.json b/tools/ui/package.json new file mode 100644 index 000000000..19111626b --- /dev/null +++ b/tools/ui/package.json @@ -0,0 +1,52 @@ +{ + "name": "demo2-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "test": "vitest spec", + "test:e2e": "vitest e2e", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint:fix": "yarn lint --fix", + "preview": "vite preview" + }, + "dependencies": { + "@endo/eventual-send": "0.17.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@agoric/eventual-send": "^0.14.1", + "@agoric/notifier": "^0.6.2", + "@agoric/rpc": "0.9.1-dev-f471a83.0", + "@agoric/store": "^0.9.2", + "@agoric/ui-components": "^0.9.0", + "@agoric/web-components": "^0.15.0", + "@testing-library/react": "^14.1.2", + "@types/react": "^18.2.15", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "@vitejs/plugin-react": "^4.0.3", + "eslint": "^8.45.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.3", + "happy-dom": "^13.3.1", + "prettier": "^3.2.4", + "puppeteer": "^21.9.0", + "ses": "1.3.0", + "typescript": "^5.0.2", + "vite": "^4.4.5", + "vitest": "^1.2.1", + "zustand": "^4.4.1" + }, + "prettier": { + "trailingComma": "all", + "arrowParens": "avoid", + "singleQuote": true + } +} \ No newline at end of file diff --git a/tools/ui/public/agoric.svg b/tools/ui/public/agoric.svg new file mode 100644 index 000000000..28822fa58 --- /dev/null +++ b/tools/ui/public/agoric.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/tools/ui/public/vite.svg b/tools/ui/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/tools/ui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tools/ui/src/App.css b/tools/ui/src/App.css new file mode 100644 index 000000000..b4d6ec708 --- /dev/null +++ b/tools/ui/src/App.css @@ -0,0 +1,109 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 1rem; + text-align: center; +} + +.logo { + height: 2em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} +.logo.agoric:hover { + filter: drop-shadow(0 0 2em #fa4a49aa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1em; +} + +.read-the-docs { + color: #888; +} + +.piece { + width: 6em; + border-radius: 10%; +} + +.coin { + width: 2em; + margin: 10px; +} + +.trade { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: #171717; + border-radius: 25px; + margin-bottom: 15px; +} + +.item-col { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 15px 25px 15px; + margin: 5px; +} + +.row-center { + display: flex; + flex-direction: row; + align-items: center; +} + +input { + border: none; + background: #242424; + text-align: center; + padding: 5px 10px; + border-radius: 15px; + font-size: 1.2rem; + width: 75px; +} + +@media (prefers-color-scheme: light) { + .trade { + background: #fafafa; + border: 1px solid #e5e5e5; + } + input { + background: #e5e5e5; + } +} + +.error { + background-color: #E11D48; + color: #fff; +} + +/* increment/decrement arrows always visible */ +input[type=number]::-webkit-inner-spin-button { + opacity: 1 +} diff --git a/tools/ui/src/App.spec.tsx b/tools/ui/src/App.spec.tsx new file mode 100644 index 000000000..aa6bf43c8 --- /dev/null +++ b/tools/ui/src/App.spec.tsx @@ -0,0 +1,22 @@ +import './installSesLockdown'; +import { render, screen } from '@testing-library/react'; +import App from './App'; + +describe('App.tsx', () => { + it('renders app title', async () => { + render(); + + const titleElement = await screen.findByText('Items Listed on Offer Up', { + selector: 'h1', + }); + expect(titleElement).toBeTruthy(); + }); + + it('renders the wallet connection button', async () => { + render(); + const buttonEl = await screen.findByRole('button', { + name: 'Connect Wallet', + }); + expect(buttonEl).toBeTruthy(); + }); +}); diff --git a/tools/ui/src/App.tsx b/tools/ui/src/App.tsx new file mode 100644 index 000000000..a43c4760b --- /dev/null +++ b/tools/ui/src/App.tsx @@ -0,0 +1,160 @@ +import { useEffect } from 'react'; + +import './App.css'; +import { + makeAgoricChainStorageWatcher, + AgoricChainStoragePathKind as Kind, +} from '@agoric/rpc'; +import { create } from 'zustand'; +import { + makeAgoricWalletConnection, + suggestChain, +} from '@agoric/web-components'; +import { subscribeLatest } from '@agoric/notifier'; +import { makeCopyBag } from '@agoric/store'; +import { Logos } from './components/Logos'; +import { Inventory } from './components/Inventory'; +import { Trade } from './components/Trade'; + +const { entries, fromEntries } = Object; + +const RUN_ENV = import.meta.env.VITE_RUN_ENV || 'localhost'; +if (!['localhost', 'agoric_chain'].includes(RUN_ENV)) + throw new Error("VITE_RUN_ENV can only be 'agoric_chain' or 'localhost'"); + +type Wallet = Awaited>; + +const ENDPOINTS = { + RPC: `http://${RUN_ENV}:26657`, + API: `http://${RUN_ENV}:1317`, +}; + +const watcher = makeAgoricChainStorageWatcher(ENDPOINTS.API, 'agoriclocal'); + +interface AppState { + wallet?: Wallet; + offerUpInstance?: unknown; + brands?: Record; + purses?: Array; +} + +const useAppStore = create(() => ({})); + +const setup = async () => { + watcher.watchLatest>( + [Kind.Data, 'published.agoricNames.instance'], + instances => { + console.log('got instances', instances); + useAppStore.setState({ + offerUpInstance: instances.find(([name]) => name === 'offerUp')!.at(1), + }); + }, + ); + + watcher.watchLatest>( + [Kind.Data, 'published.agoricNames.brand'], + brands => { + console.log('Got brands', brands); + useAppStore.setState({ + brands: fromEntries(brands), + }); + }, + ); +}; + +const connectWallet = async () => { + await suggestChain(`http://localhost:3004/${RUN_ENV}`); + const wallet = await makeAgoricWalletConnection(watcher, ENDPOINTS.RPC); + useAppStore.setState({ wallet }); + const { pursesNotifier } = wallet; + for await (const purses of subscribeLatest(pursesNotifier)) { + console.log('got purses', purses); + useAppStore.setState({ purses }); + } +}; + +const makeOffer = (giveValue: bigint, wantChoices: Record) => { + const { wallet, offerUpInstance, brands } = useAppStore.getState(); + if (!offerUpInstance) throw Error('no contract instance'); + if (!(brands && brands.IST && brands.Item)) + throw Error('brands not available'); + + const value = makeCopyBag(entries(wantChoices)); + const want = { Items: { brand: brands.Item, value } }; + const give = { Price: { brand: brands.IST, value: giveValue } }; + + wallet?.makeOffer( + { + source: 'contract', + instance: offerUpInstance, + publicInvitationMaker: 'makeTradeInvitation', + }, + { give, want }, + undefined, + (update: { status: string; data?: unknown }) => { + if (update.status === 'error') { + alert(`Offer error: ${update.data}`); + } + if (update.status === 'accepted') { + alert('Offer accepted'); + } + if (update.status === 'refunded') { + alert('Offer rejected'); + } + }, + ); +}; + +function App() { + useEffect(() => { + setup(); + }, []); + + const { wallet, purses } = useAppStore(({ wallet, purses }) => ({ + wallet, + purses, + })); + const istPurse = purses?.find(p => p.brandPetname === 'IST'); + const itemsPurse = purses?.find(p => p.brandPetname === 'Item'); + + const tryConnectWallet = () => { + connectWallet().catch(err => { + switch (err.message) { + case 'KEPLR_CONNECTION_ERROR_NO_SMART_WALLET': + alert( + 'no smart wallet at that address; try: yarn docker:make print-key', + ); + break; + default: + alert(err.message); + } + }); + }; + + return ( + <> + +

Items Listed on Offer Up

+ +
+ +
+ {wallet && istPurse ? ( + + ) : ( + + )} +
+ + ); +} + +export default App; diff --git a/tools/ui/src/assets/IST.svg b/tools/ui/src/assets/IST.svg new file mode 100644 index 000000000..1fcb75c89 --- /dev/null +++ b/tools/ui/src/assets/IST.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/tools/ui/src/assets/map.png b/tools/ui/src/assets/map.png new file mode 100644 index 000000000..278c0936b Binary files /dev/null and b/tools/ui/src/assets/map.png differ diff --git a/tools/ui/src/assets/potionBlue.png b/tools/ui/src/assets/potionBlue.png new file mode 100644 index 000000000..f8ff0e4b2 Binary files /dev/null and b/tools/ui/src/assets/potionBlue.png differ diff --git a/tools/ui/src/assets/react.svg b/tools/ui/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/tools/ui/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tools/ui/src/assets/scroll.png b/tools/ui/src/assets/scroll.png new file mode 100644 index 000000000..b61fe085b Binary files /dev/null and b/tools/ui/src/assets/scroll.png differ diff --git a/tools/ui/src/components/Inventory.tsx b/tools/ui/src/components/Inventory.tsx new file mode 100644 index 000000000..53dea99b3 --- /dev/null +++ b/tools/ui/src/components/Inventory.tsx @@ -0,0 +1,49 @@ +import { stringifyAmountValue } from '@agoric/ui-components'; + +type InventoryProps = { + address: string; + istPurse: Purse; + itemsPurse: Purse; +}; + +const Inventory = ({ address, istPurse, itemsPurse }: InventoryProps) => ( +
+

My Wallet

+
+
+ + {address} + +
+ +
+
+ IST: + {stringifyAmountValue( + istPurse.currentAmount, + istPurse.displayInfo.assetKind, + istPurse.displayInfo.decimalPlaces, + )} +
+
+ Items: + {itemsPurse ? ( +
    + {(itemsPurse.currentAmount.value as CopyBag).payload.map( + ([name, number]) => ( +
  • + {String(number)} {name} +
  • + ), + )} +
+ ) : ( + 'None' + )} +
+
+
+
+); + +export { Inventory }; diff --git a/tools/ui/src/components/Logos.tsx b/tools/ui/src/components/Logos.tsx new file mode 100644 index 000000000..13e9e5c85 --- /dev/null +++ b/tools/ui/src/components/Logos.tsx @@ -0,0 +1,19 @@ +import reactLogo from '../assets/react.svg'; +import viteLogo from '/vite.svg'; +import agoricLogo from '/agoric.svg'; + +const Logos = () => ( + +); + +export { Logos }; diff --git a/tools/ui/src/components/Trade.tsx b/tools/ui/src/components/Trade.tsx new file mode 100644 index 000000000..9ce8b8751 --- /dev/null +++ b/tools/ui/src/components/Trade.tsx @@ -0,0 +1,141 @@ +import { FormEvent, useState } from 'react'; +import { stringifyAmountValue } from '@agoric/ui-components'; +import scrollIcon from '../assets/scroll.png'; +import istIcon from '../assets/IST.svg'; +import mapIcon from '../assets/map.png'; +import potionIcon from '../assets/potionBlue.png'; + +const { entries, values } = Object; +const sum = (xs: bigint[]) => xs.reduce((acc, next) => acc + next, 0n); + +const terms = { + price: 250000n, + maxItems: 3n, +}; +const nameToIcon = { + scroll: scrollIcon, + map: mapIcon, + potion: potionIcon, +} as const; +type ItemName = keyof typeof nameToIcon; +type ItemChoices = Partial>; + +const parseValue = (numeral: string, purse: Purse): bigint => { + const { decimalPlaces } = purse.displayInfo; + const num = Number(numeral) * 10 ** decimalPlaces; + return BigInt(num); +}; + +const Item = ({ + icon, + coinIcon, + label, + value, + onChange, + inputClassName, + inputStep, +}: { + icon?: string; + coinIcon?: string; + label: string; + value: number | string; + onChange: React.ChangeEventHandler; + inputClassName: string; + inputStep?: string; +}) => ( +
+ + {icon && } + {coinIcon && } + +
+); + +type TradeProps = { + makeOffer: (giveValue: bigint, wantChoices: Record) => void; + istPurse: Purse; + walletConnected: boolean; +}; + +// TODO: IST displayInfo is available in vbankAsset or boardAux +const Trade = ({ makeOffer, istPurse, walletConnected }: TradeProps) => { + const [giveValue, setGiveValue] = useState(terms.price); + const [choices, setChoices] = useState({ map: 1n, scroll: 2n }); + const changeChoice = (ev: FormEvent) => { + if (!ev.target) return; + const elt = ev.target as HTMLInputElement; + const title = elt.title as ItemName; + if (!title) return; + const qty = BigInt(elt.value); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [title]: _old, ...rest }: ItemChoices = choices; + const newChoices = qty > 0 ? { ...rest, [title]: qty } : rest; + setChoices(newChoices); + }; + + return ( + <> +
+

Want: Choose up to 3 items

+
+ {entries(nameToIcon).map(([title, icon]) => ( + + ))} +
+
+
+

Give: Offer at least 0.25 IST

+
+ + setGiveValue(parseValue(ev?.target?.value, istPurse)) + } + inputClassName={giveValue >= terms.price ? 'ok' : 'error'} + inputStep="0.01" + /> +
+
+
+ {walletConnected && ( + + )} +
+ + ); +}; + +export { Trade }; diff --git a/tools/ui/src/index.css b/tools/ui/src/index.css new file mode 100644 index 000000000..d3f626a88 --- /dev/null +++ b/tools/ui/src/index.css @@ -0,0 +1,72 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + min-width: 320px; + min-height: 100vh; +} + +h1 { + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 4px solid transparent; + padding: 12px 16px; + margin: 8px 2px; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + background: #171717; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #04aa6d; /* Green */ + color: #fff; + } + button:hover { + background: #039962; + } +} diff --git a/tools/ui/src/index.d.ts b/tools/ui/src/index.d.ts new file mode 100644 index 000000000..d2ee4b35d --- /dev/null +++ b/tools/ui/src/index.d.ts @@ -0,0 +1,16 @@ +interface CopyBag { + payload: Array<[T, bigint]>; +} + +interface Purse { + brand: unknown; + brandPetname: string; + currentAmount: { + brand: unknown; + value: bigint | CopyBag; + }; + displayInfo: { + decimalPlaces: number; + assetKind: unknown; + }; +} diff --git a/tools/ui/src/installSesLockdown.ts b/tools/ui/src/installSesLockdown.ts new file mode 100644 index 000000000..2eb8e6e6b --- /dev/null +++ b/tools/ui/src/installSesLockdown.ts @@ -0,0 +1,13 @@ +import 'ses'; // adds lockdown, harden, and Compartment +import '@endo/eventual-send/shim.js'; // adds support needed by E + +const consoleTaming = import.meta.env.DEV ? 'unsafe' : 'safe'; + +// @ts-expect-error global +lockdown({ + errorTaming: 'unsafe', + overrideTaming: 'severe', + consoleTaming, +}); + +Error.stackTraceLimit = Infinity; diff --git a/tools/ui/src/main.tsx b/tools/ui/src/main.tsx new file mode 100644 index 000000000..bfb9f34e7 --- /dev/null +++ b/tools/ui/src/main.tsx @@ -0,0 +1,11 @@ +import './installSesLockdown.ts'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/tools/ui/src/vite-env.d.ts b/tools/ui/src/vite-env.d.ts new file mode 100644 index 000000000..11686fc78 --- /dev/null +++ b/tools/ui/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +declare module '@agoric/ui-components' { + export const stringifyAmountValue; +} + +declare module '@agoric/store' { + export const makeCopyBag; +} diff --git a/tools/ui/test/App.e2e.ts b/tools/ui/test/App.e2e.ts new file mode 100644 index 000000000..5e7ca1cd5 --- /dev/null +++ b/tools/ui/test/App.e2e.ts @@ -0,0 +1,28 @@ +import puppeteer from 'puppeteer'; +import { execSync, spawn } from 'node:child_process'; + +describe('Puppeteer E2E test', () => { + it('should load the webpage', async () => { + execSync('yarn build'); + const previewServer = spawn('yarn', ['preview'], { + detached: true, + stdio: 'ignore', + }); + // delay for preview server to start + await new Promise(resolve => setTimeout(resolve, 2000)); + + try { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + await page.goto('http://localhost:4173'); + + const buttonText = await page.$eval('button', el => el.textContent); + expect(buttonText).toBe('Connect Wallet'); + + await browser.close(); + previewServer.kill(); + } catch (_) { + previewServer.kill(); + } + }); +}); diff --git a/tools/ui/tsconfig.json b/tools/ui/tsconfig.json new file mode 100644 index 000000000..7f545954a --- /dev/null +++ b/tools/ui/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "types": ["vitest/globals"], + }, + "include": ["src", "test"], + "references": [{ "path": "./tsconfig.node.json" }], +} diff --git a/tools/ui/tsconfig.node.json b/tools/ui/tsconfig.node.json new file mode 100644 index 000000000..42872c59f --- /dev/null +++ b/tools/ui/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/tools/ui/vite.config.ts b/tools/ui/vite.config.ts new file mode 100644 index 000000000..7fa039ff7 --- /dev/null +++ b/tools/ui/vite.config.ts @@ -0,0 +1,19 @@ +/// + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'happy-dom', + testTimeout: 20000, // 20 seconds for puppeteer CI + // modified import('vitest/dist/config.js').defaultInclude + include: '**/*.{spec,e2e}.?(c|m)[jt]s?(x)', + }, + server: { + port: 3000, + }, +});