Skip to content

Commit

Permalink
Extract common transaction tasks to a planBuildBroadcast helper (#725)
Browse files Browse the repository at this point in the history
* Extract common transaction tasks to a planBuildBroadcast helper

* Remove unused import

* Fix how we handle various errors from the extension (#742)

* Handle unauthenticated state

* Fix how we detect that the user has denied a transaction

* Fix comment

* Send a negative response before closing the window

* Remove unused import

* Remove listener at end

* Pass the function into build()

* Fix imports
  • Loading branch information
jessepinho authored Mar 13, 2024
1 parent 0a0b63e commit ad272d2
Show file tree
Hide file tree
Showing 10 changed files with 141 additions and 202 deletions.
3 changes: 2 additions & 1 deletion apps/extension/src/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
InternalRequest,
InternalResponse,
} from '@penumbra-zone/types/src/internal-msg/shared';
import { Code, ConnectError } from '@connectrpc/connect';

export const popup = async <M extends PopupMessage>(
req: PopupRequest<M>,
Expand Down Expand Up @@ -58,7 +59,7 @@ const spawnPopup = async (pop: PopupType) => {
if (!loggedIn) {
popUrl.hash = PopupPath.LOGIN;
void spawnExtensionPopup(popUrl.href);
throw Error('User must login to extension');
throw new ConnectError('User must login to extension', Code.Unauthenticated);
}

switch (pop) {
Expand Down
13 changes: 13 additions & 0 deletions apps/extension/src/state/tx-approval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface TxApprovalSlice {
req: InternalRequest<TxApproval>,
responder: (m: InternalResponse<TxApproval>) => void,
) => Promise<void>;
handleCloseWindow: () => void;

setChoice: (choice: UserChoice) => void;

Expand Down Expand Up @@ -83,6 +84,15 @@ export const createTxApprovalSlice = (): SliceCreator<TxApprovalSlice> => (set,

state.txApproval.choice = undefined;
});

window.onbeforeunload = get().txApproval.handleCloseWindow;
},

handleCloseWindow: () => {
set(state => {
state.txApproval.choice = UserChoice.Ignored;
});
get().txApproval.sendResponse();
},

setChoice: choice => {
Expand All @@ -97,6 +107,7 @@ export const createTxApprovalSlice = (): SliceCreator<TxApprovalSlice> => (set,
choice,
transactionView: transactionViewString,
authorizeRequest: authorizeRequestString,
handleCloseWindow,
} = get().txApproval;

if (!responder) throw new Error('No responder');
Expand Down Expand Up @@ -138,6 +149,8 @@ export const createTxApprovalSlice = (): SliceCreator<TxApprovalSlice> => (set,
state.txApproval.asPublic = undefined;
state.txApproval.transactionClassification = undefined;
});

if (window.onbeforeunload === handleCloseWindow) window.onbeforeunload = null;
}
},
});
Expand Down
114 changes: 76 additions & 38 deletions apps/minifront/src/state/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,66 @@ import {
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb';
import { TransactionId } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/txhash/v1/txhash_pb';
import { PartialMessage } from '@bufbuild/protobuf';
import { ConnectError } from '@connectrpc/connect';
import { TransactionToast } from '@penumbra-zone/ui';
import { TransactionClassification } from '@penumbra-zone/types/src/transaction';
import { uint8ArrayToHex } from '@penumbra-zone/types/src/hex';

/**
* Handles the common use case of planning, building, and broadcasting a
* transaction, along with the appropriate toasts. Throws if there is an
* unhandled error (i.e., any error other than the user denying authorization
* for the transaction) so that consuming code can take different actions based
* on whether the transaction succeeded or failed.
*/
export const planBuildBroadcast = async (
transactionClassification: TransactionClassification,
req: PartialMessage<TransactionPlannerRequest>,
options?: {
/**
* If set to `true`, the `ViewService#witnessAndBuild` method will be used,
* which does not prompt the user to authorize the transaction. If `false`,
* the `ViewService#authorizeAndBuild` method will be used, which _does_
* prompt the user to authorize the transaction. (This is required in the
* case of most transactions.) Default: `false`
*/
skipAuth?: boolean;
},
): Promise<Transaction | undefined> => {
const toast = new TransactionToast(transactionClassification);
toast.onStart();

const rpcMethod = options?.skipAuth ? viewClient.witnessAndBuild : viewClient.authorizeAndBuild;

try {
const transactionPlan = await plan(req);

const transaction = await build({ transactionPlan }, rpcMethod, status =>
toast.onBuildStatus(status),
);

const txHash = await getTxHash(transaction);
toast.txHash(txHash);

const { detectionHeight } = await broadcast({ transaction, awaitDetection: true }, status =>
toast.onBroadcastStatus(status),
);
toast.onSuccess(detectionHeight);

return transaction;
} catch (e) {
if (userDeniedTransaction(e)) {
toast.onDenied();
} else if (unauthenticated(e)) {
toast.onUnauthenticated();
} else {
toast.onFailure(e);
throw e;
}
}

return undefined;
};

export const plan = async (
req: PartialMessage<TransactionPlannerRequest>,
): Promise<TransactionPlan> => {
Expand All @@ -26,49 +83,30 @@ export const plan = async (
return plan;
};

export const authWitnessBuild = async (
req: PartialMessage<AuthorizeAndBuildRequest>,
onStatusUpdate?: (
status?: (AuthorizeAndBuildResponse | WitnessAndBuildResponse)['status'],
) => void,
) => {
for await (const { status } of viewClient.authorizeAndBuild(req)) {
if (onStatusUpdate) onStatusUpdate(status);
switch (status.case) {
case undefined:
case 'buildProgress':
break;
case 'complete':
return status.value.transaction!;
default:
console.warn('unknown authorizeAndBuild status', status);
}
}
throw new Error('did not build transaction');
};

export const witnessBuild = async (
req: PartialMessage<WitnessAndBuildRequest>,
const build = async (
req: PartialMessage<AuthorizeAndBuildRequest> | PartialMessage<WitnessAndBuildRequest>,
buildFn: (typeof viewClient)['authorizeAndBuild' | 'witnessAndBuild'],
onStatusUpdate: (
status?: (AuthorizeAndBuildResponse | WitnessAndBuildResponse)['status'],
) => void,
) => {
for await (const { status } of viewClient.witnessAndBuild(req)) {
for await (const { status } of buildFn(req)) {
onStatusUpdate(status);

switch (status.case) {
case undefined:
case 'buildProgress':
break;
case 'complete':
return status.value.transaction!;
default:
console.warn('unknown witnessAndBuild status', status);
console.warn(`unknown ${buildFn.name} status`, status);
}
}
throw new Error('did not build transaction');
};

export const broadcast = async (
const broadcast = async (
req: PartialMessage<BroadcastTransactionRequest>,
onStatusUpdate: (status?: BroadcastTransactionResponse['status']) => void,
): Promise<{ txHash: string; detectionHeight?: bigint }> => {
Expand All @@ -94,9 +132,7 @@ export const broadcast = async (
throw new Error('did not broadcast transaction');
};

export const getTxHash = <
T extends Required<PartialMessage<TransactionId>> | PartialMessage<Transaction>,
>(
const getTxHash = <T extends Required<PartialMessage<TransactionId>> | PartialMessage<Transaction>>(
t: T,
): T extends Required<PartialMessage<TransactionId>> ? string : Promise<string> =>
'inner' in t && t.inner instanceof Uint8Array
Expand All @@ -107,7 +143,7 @@ export const getTxHash = <
uint8ArrayToHex(inner),
) as T extends Required<PartialMessage<TransactionId>> ? never : Promise<string>);

export const getTxId = (tx: Transaction | PartialMessage<Transaction>) =>
const getTxId = (tx: Transaction | PartialMessage<Transaction>) =>
sha256Hash(tx instanceof Transaction ? tx.toBinary() : new Transaction(tx).toBinary()).then(
inner => new TransactionId({ inner }),
);
Expand All @@ -116,12 +152,14 @@ export const getTxId = (tx: Transaction | PartialMessage<Transaction>) =>
* @todo: The error flow between extension <-> webapp needs to be refactored a
* bit. Right now, if we throw a `ConnectError` with `Code.PermissionDenied` (as
* we do in the approver), it gets swallowed by ConnectRPC's internals and
* rethrown via `ConnectError.from()`. This means that the original code is
* lost, although the stringified error message still contains
* `[permission_denied]`. So we'll (somewhat hackily) check the stringified
* error message for now; but in the future, we need ot get the error flow
* working properly so that we can actually check `e.code ===
* Code.PermissionDenied`.
* rethrown as a string. This means that the original code is lost, although
* the stringified error message still contains `[permission_denied]`. So we'll
* (somewhat hackily) check the stringified error message for now; but in the
* future, we need ot get the error flow working properly so that we can
* actually check `e.code === Code.PermissionDenied`.
*/
export const userDeniedTransaction = (e: unknown): boolean =>
e instanceof ConnectError && e.message.includes('[permission_denied]');
typeof e === 'string' && e.includes('[permission_denied]');

export const unauthenticated = (e: unknown): boolean =>
typeof e === 'string' && e.includes('[unauthenticated]');
25 changes: 3 additions & 22 deletions apps/minifront/src/state/ibc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { BigNumber } from 'bignumber.js';
import { ClientState } from '@buf/cosmos_ibc.bufbuild_es/ibc/lightclients/tendermint/v1/tendermint_pb';
import { Height } from '@buf/cosmos_ibc.bufbuild_es/ibc/core/client/v1/client_pb';
import { ibcClient, viewClient } from '../clients';
import { authWitnessBuild, broadcast, getTxHash, plan, userDeniedTransaction } from './helpers';
import { TransactionToast } from '@penumbra-zone/ui';
import { Chain } from '@penumbra-zone/constants/src/chains';
import {
getDisplayDenomExponentFromValueView,
Expand All @@ -17,6 +15,7 @@ import {
import { getAddressIndex } from '@penumbra-zone/getters/src/address-view';
import { typeRegistry } from '@penumbra-zone/types/src/registry';
import { toBaseUnit } from '@penumbra-zone/types/src/lo-hi';
import { planBuildBroadcast } from './helpers';

export interface IbcSendSlice {
selection: BalancesResponse | undefined;
Expand Down Expand Up @@ -63,32 +62,14 @@ export const createIbcSendSlice = (): SliceCreator<IbcSendSlice> => (set, get) =
state.send.txInProgress = true;
});

const toast = new TransactionToast('unknown');
toast.onStart();

try {
const transactionPlan = await plan(await getPlanRequest(get().ibc));
const transaction = await authWitnessBuild({ transactionPlan }, status =>
toast.onBuildStatus(status),
);
const txHash = await getTxHash(transaction);
toast.txHash(txHash);
const { detectionHeight } = await broadcast({ transaction, awaitDetection: true }, status =>
toast.onBroadcastStatus(status),
);

toast.onSuccess(detectionHeight);
const req = await getPlanRequest(get().ibc);
await planBuildBroadcast('unknown', req);

// Reset form
set(state => {
state.ibc.amount = '';
});
} catch (e) {
if (userDeniedTransaction(e)) {
toast.onDenied();
} else {
toast.onFailure(e);
}
} finally {
set(state => {
state.ibc.txInProgress = false;
Expand Down
27 changes: 4 additions & 23 deletions apps/minifront/src/state/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ import {
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { BigNumber } from 'bignumber.js';
import { MemoPlaintext } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb';
import { authWitnessBuild, broadcast, getTxHash, plan, userDeniedTransaction } from './helpers';
import { plan, planBuildBroadcast } from './helpers';

import {
Fee,
FeeTier_Tier,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/fee/v1/fee_pb';
import { TransactionToast } from '@penumbra-zone/ui';
import {
getAssetIdFromValueView,
getDisplayDenomExponentFromValueView,
Expand Down Expand Up @@ -96,31 +95,13 @@ export const createSendSlice = (): SliceCreator<SendSlice> => (set, get) => {
state.send.txInProgress = true;
});

const toast = new TransactionToast('send');
toast.onStart();

try {
const transactionPlan = await plan(assembleRequest(get().send));
const transaction = await authWitnessBuild({ transactionPlan }, status =>
toast.onBuildStatus(status),
);
const txHash = await getTxHash(transaction);
toast.txHash(txHash);
const { detectionHeight } = await broadcast({ transaction, awaitDetection: true }, status =>
toast.onBroadcastStatus(status),
);
toast.onSuccess(detectionHeight);

// Reset form
const req = assembleRequest(get().send);
await planBuildBroadcast('send', req);

set(state => {
state.send.amount = '';
});
} catch (e) {
if (userDeniedTransaction(e)) {
toast.onDenied();
} else {
toast.onFailure(e);
}
} finally {
set(state => {
state.send.txInProgress = false;
Expand Down
Loading

0 comments on commit ad272d2

Please sign in to comment.