Skip to content

Commit

Permalink
feat: support offer signing with keplr
Browse files Browse the repository at this point in the history
  • Loading branch information
samsiegart committed Aug 15, 2023
1 parent 4dd5c43 commit cdad864
Show file tree
Hide file tree
Showing 9 changed files with 392 additions and 12 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"type": "module",
"packageManager": "yarn@1.22.19",
"scripts": {
"postinstall": "patch-package",
"format": "yarn prettier --write packages",
"lint": "yarn workspaces run lint",
"test": "yarn workspaces run test"
Expand Down Expand Up @@ -37,6 +38,8 @@
"eslint-plugin-react-hooks": "^4.6.0",
"lerna": "^7.0.1",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.8.8",
"rollup": "^2.0.0"
},
Expand Down
1 change: 1 addition & 0 deletions packages/rpc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"vite-tsconfig-paths": "^4.2.0"
},
"devDependencies": {
"@agoric/smart-wallet": "0.5.3",
"@typescript-eslint/eslint-plugin": "^5.35.1",
"@typescript-eslint/parser": "^5.35.1",
"@vitest/coverage-c8": "^0.25.3",
Expand Down
16 changes: 11 additions & 5 deletions packages/rpc/src/chainStorageWatcher.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/* eslint-disable no-use-before-define */
/* eslint-disable import/extensions */
import type { FromCapData } from '@endo/marshal';
import type { UpdateHandler } from './types';
/* eslint-disable import/no-extraneous-dependencies */
import { makeImportContext } from '@agoric/smart-wallet/src/marshal-contexts';
import { AgoricChainStoragePathKind } from './types';
import { batchVstorageQuery, keyToPath, pathToKey } from './batchQuery';
import type { UpdateHandler } from './types';

type Subscriber<T> = {
onUpdate: UpdateHandler<T>;
Expand Down Expand Up @@ -40,8 +41,8 @@ export type ChainStorageWatcher = ReturnType<
* requests for efficiency.
* @param rpcAddr RPC server URL
* @param chainId the chain id to use
* @param unmarshal CapData unserializer to use
* @param onError
* @param marshal CapData marshal to use
* @param newPathQueryDelayMs
* @param refreshLowerBoundMs
* @param refreshUpperBoundMs
Expand All @@ -50,8 +51,8 @@ export type ChainStorageWatcher = ReturnType<
export const makeAgoricChainStorageWatcher = (
rpcAddr: string,
chainId: string,
unmarshal: FromCapData<string>,
onError?: (e: Error) => void,
marshal = makeImportContext().fromBoard,
newPathQueryDelayMs = defaults.newPathQueryDelayMs,
refreshLowerBoundMs = defaults.refreshLowerBoundMs,
refreshUpperBoundMs = defaults.refreshUpperBoundMs,
Expand Down Expand Up @@ -105,7 +106,11 @@ export const makeAgoricChainStorageWatcher = (
}

try {
const data = await batchVstorageQuery(rpcAddr, unmarshal, paths);
const data = await batchVstorageQuery(
rpcAddr,
marshal.fromCapData,
paths,
);
watchedPathsToSubscribers.forEach((subscribers, path) => {
// Path was watched after query fired, wait until next round.
if (!data[path]) return;
Expand Down Expand Up @@ -201,5 +206,6 @@ export const makeAgoricChainStorageWatcher = (
watchLatest,
chainId,
rpcAddr,
marshal,
};
};
38 changes: 38 additions & 0 deletions packages/web-components/src/wallet-connection/chainInfo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/** @typedef {import('@keplr-wallet/types').Bech32Config} Bech32Config */
/** @typedef {import('@keplr-wallet/types').FeeCurrency} FeeCurrency */

/** @type {FeeCurrency} */
export const stakeCurrency = {
coinDenom: 'BLD',
coinMinimalDenom: 'ubld',
coinDecimals: 6,
coinGeckoId: undefined,
gasPriceStep: {
low: 0,
average: 0,
high: 0,
},
};

/** @type {FeeCurrency} */
export const stableCurrency = {
coinDenom: 'IST',
coinMinimalDenom: 'uist',
coinDecimals: 6,
coinGeckoId: undefined,
gasPriceStep: {
low: 0,
average: 0,
high: 0,
},
};

/** @type {Bech32Config} */
export const bech32Config = {
bech32PrefixAccAddr: 'agoric',
bech32PrefixAccPub: 'agoricpub',
bech32PrefixValAddr: 'agoricvaloper',
bech32PrefixValPub: 'agoricvaloperpub',
bech32PrefixConsAddr: 'agoricvalcons',
bech32PrefixConsPub: 'agoricvalconspub',
};
156 changes: 156 additions & 0 deletions packages/web-components/src/wallet-connection/makeInteractiveSigner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { fromBech32, toBech32, fromBase64, toBase64 } from '@cosmjs/encoding';
import { Registry } from '@cosmjs/proto-signing';
import {
AminoTypes,
defaultRegistryTypes,
assertIsDeliverTxSuccess,
createBankAminoConverters,
createAuthzAminoConverters,
} from '@cosmjs/stargate';
import { MsgWalletSpendAction } from '@agoric/cosmic-proto/swingset/msgs.js';
import { stableCurrency, bech32Config } from './chainInfo.js';
import { Errors } from '../errors.js';

/** @typedef {import("@cosmjs/proto-signing").EncodeObject} EncodeObject */
/** @typedef {import("@cosmjs/stargate").AminoConverters} AminoConverters */
/** @typedef {import("@cosmjs/stargate").StdFee} StdFee */
/** @typedef {import('@keplr-wallet/types').ChainInfo} ChainInfo */
/** @typedef {import('@keplr-wallet/types').Keplr} Keplr */

/**
* @param {string} address
* @returns {Uint8Array}
*/
const toAccAddress = address => {
return fromBech32(address).data;
};

/**
* `/agoric.swingset.XXX` matches package agoric.swingset in swingset/msgs.proto
* aminoType taken from Type() in golang/cosmos/x/swingset/types/msgs.go
*/
const SwingsetMsgs = {
MsgWalletSpendAction: {
typeUrl: '/agoric.swingset.MsgWalletSpendAction',
aminoType: 'swingset/WalletSpendAction',
},
};

/** @typedef {{owner: string, spendAction: string}} WalletSpendAction */

const SwingsetRegistry = new Registry([
...defaultRegistryTypes,
// XXX should this list be "upstreamed" to @agoric/cosmic-proto?
[SwingsetMsgs.MsgWalletSpendAction.typeUrl, MsgWalletSpendAction],
]);

/**
* @returns {StdFee}
*/
const zeroFee = () => {
const { coinMinimalDenom: denom } = stableCurrency;
const fee = {
amount: [{ amount: '0', denom }],
gas: '300000', // TODO: estimate gas?
};
return fee;
};

/**
* @type {AminoConverters}
*/
const SwingsetConverters = {
[SwingsetMsgs.MsgWalletSpendAction.typeUrl]: {
aminoType: SwingsetMsgs.MsgWalletSpendAction.aminoType,
toAmino: ({ spendAction, owner }) => ({
spend_action: spendAction,
owner: toBech32(bech32Config.bech32PrefixAccAddr, fromBase64(owner)),
}),
fromAmino: ({ spend_action: spendAction, owner }) => ({
spendAction,
owner: toBase64(toAccAddress(owner)),
}),
},
};

/**
* Use Keplr to sign offers and delegate object messaging to local storage key.
* @param {string} chainId
* @param {string} rpc
* @param {Keplr} keplr
* @param {typeof import('@cosmjs/stargate').SigningStargateClient.connectWithSigner} connectWithSigner
* Ref: https://docs.keplr.app/api/
*/
export const makeInteractiveSigner = async (
chainId,
rpc,
keplr,
connectWithSigner,
) => {
try {
// eslint-disable-next-line @jessie.js/safe-await-separator
await keplr.enable(chainId);
} catch {
throw Error(Errors.enableKeplr);
}

const key = await keplr.getKey(chainId);

// Until we have SIGN_MODE_TEXTUAL,
// Use Amino because Direct results in ugly protobuf in the keplr UI.
const offlineSigner = await keplr.getOfflineSignerOnlyAmino(chainId);
console.debug('InteractiveSigner', { offlineSigner });

// Currently, Keplr extension manages only one address/public key pair.
const [account] = await offlineSigner.getAccounts();
const { address } = account;

const converters = {
...SwingsetConverters,
...createBankAminoConverters(),
...createAuthzAminoConverters(),
};
const signingClient = await connectWithSigner(rpc, offlineSigner, {
aminoTypes: new AminoTypes(converters),
registry: SwingsetRegistry,
});
console.debug('InteractiveSigner', { signingClient });

const fee = zeroFee();

return harden({
address, // TODO: address can change
isNanoLedger: key.isNanoLedger,
/**
* Sign and broadcast WalletSpendAction
*
* @param {string} spendAction marshaled offer
* @throws if account does not exist on chain, user cancels,
* RPC connection fails, RPC service fails to broadcast (
* for example, if signature verification fails)
*/
submitSpendAction: async spendAction => {
const { accountNumber, sequence } = await signingClient.getSequence(
address,
);
console.debug({ accountNumber, sequence });

const act1 = {
typeUrl: SwingsetMsgs.MsgWalletSpendAction.typeUrl,
value: {
owner: toBase64(toAccAddress(address)),
spendAction,
},
};

const msgs = [act1];
console.debug('sign spend action', { address, msgs, fee });

const tx = await signingClient.signAndBroadcast(address, msgs, fee);
console.debug('spend action result tx', tx);
assertIsDeliverTxSuccess(tx);

return tx;
},
});
};
69 changes: 67 additions & 2 deletions packages/web-components/src/wallet-connection/walletConnection.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,78 @@
// @ts-check
import { getKeplrAddress } from '../getKeplrAddress.js';
import { SigningStargateClient } from '@cosmjs/stargate';
import { subscribeLatest } from '@agoric/notifier';
import { makeInteractiveSigner } from './makeInteractiveSigner.js';
import { watchWallet } from './watchWallet.js';
import { Errors } from '../errors.js';

export const makeAgoricWalletConnection = async chainStorageWatcher => {
const address = await getKeplrAddress(chainStorageWatcher.chainId);
if (!('keplr' in window)) {
throw Error(Errors.noKeplr);
}
/** @type {import('@keplr-wallet/types').Keplr} */
// @ts-expect-error cast (checked above)
const keplr = window.keplr;

const { address, submitSpendAction } = await makeInteractiveSigner(
chainStorageWatcher.chainId,
chainStorageWatcher.rpcAddr,
keplr,
SigningStargateClient.connectWithSigner,
);

const walletNotifiers = await watchWallet(chainStorageWatcher, address);

const makeOffer = async (
invitationSpec,
proposal,
offerArgs,
onStatusChange,
) => {
const { marshal } = chainStorageWatcher;
const id = new Date().getTime();
const spendAction = marshal.serialize(
harden({
method: 'executeOffer',
offer: {
id,
invitationSpec,
proposal,
offerArgs,
},
}),
);

try {
// eslint-disable-next-line @jessie.js/safe-await-separator
const txn = await submitSpendAction(JSON.stringify(spendAction));
onStatusChange({ status: 'seated', data: { txn, offerId: id } });
} catch (e) {
onStatusChange({ status: 'error', data: e });
return;
}

const iterator = subscribeLatest(walletNotifiers.walletUpdatesNotifier);
for await (const update of iterator) {
if (update.updated !== 'offerStatus' || update.status.id !== id) {
continue;
}
if (update.status.error !== undefined) {
onStatusChange({ status: 'error', data: update.status.error });
return;
}
if (update.status.numWantsSatisfied === 0) {
onStatusChange({ status: 'refunded' });
return;
}
if (update.status.numWantsSatisfied === 1) {
onStatusChange({ status: 'accepted' });
return;
}
}
};

return {
makeOffer,
address,
...walletNotifiers,
};
Expand Down

0 comments on commit cdad864

Please sign in to comment.