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: use eth_sendTransactionBatch for swaps requiring allowance bump #122

Merged
merged 21 commits into from
Jan 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
783f42e
feat: support eth_sendTransactionBatch on the backend
meeh0w Jan 14, 2025
4ec9dfa
feat: support batch transactions in fee customizer hook
meeh0w Jan 14, 2025
7ff2f2d
refactor: extract some common utils for SwapProvider
meeh0w Jan 14, 2025
ac7b88d
feat: implement one-click swaps in SwapProvider
meeh0w Jan 14, 2025
2b8853c
feat: implement the batch approval screen
meeh0w Jan 14, 2025
1313ec9
chore: use canary VMMs and fix types, tests, translations
meeh0w Jan 14, 2025
e42bab5
fix: ui improvements, some cleanup
meeh0w Jan 15, 2025
82d787d
test: missing suite for WalletService.signTransactionBatch()
meeh0w Jan 15, 2025
4241421
test: missing suites for ActionsService & ApprovalController
meeh0w Jan 15, 2025
6f6792c
fix: validate wallet type before engaging one-click-swap mode
meeh0w Jan 15, 2025
e7da81b
test: fix SwapProvider tests
meeh0w Jan 15, 2025
fa2cf5f
fix: allow changing fees for each tx individually
meeh0w Jan 17, 2025
54f79f4
Merge remote-tracking branch 'origin/main' into feat/batch-approvals
meeh0w Jan 17, 2025
a365ce9
chore: typings
meeh0w Jan 17, 2025
a4d1a47
chore: typing fixes
meeh0w Jan 17, 2025
ede9bbf
test: fix failing updateTxData() suite
meeh0w Jan 17, 2025
0c67340
refactor: address review comments
meeh0w Jan 27, 2025
f14aa49
Merge remote-tracking branch 'origin/main' into feat/batch-approvals
meeh0w Jan 27, 2025
4dac362
chore: update dependencies
meeh0w Jan 29, 2025
5a9d3b5
Merge remote-tracking branch 'origin/main' into feat/batch-approvals
meeh0w Jan 29, 2025
05f330b
Merge remote-tracking branch 'origin/main' into feat/batch-approvals
meeh0w Jan 29, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export function DAppRequestHandlerMiddleware(
}, new Map<string, DAppRequestHandler>());

return async (context, next) => {
const handler = handlerMap.get(context.request.params.request.method);
const method = context.request.params.request.method;
const handler = handlerMap.get(method);
// Call correct handler method based on authentication status
let promise: Promise<JsonRpcResponse<unknown>>;

Expand All @@ -49,43 +50,43 @@ export function DAppRequestHandlerMiddleware(
: handler.handleUnauthenticated(params);
} else {
const [module] = await resolve(
moduleManager.loadModule(
context.request.params.scope,
context.request.params.request.method,
),
moduleManager.loadModule(context.request.params.scope, method),
);

if (!context.network) {
promise = Promise.reject(ethErrors.provider.disconnected());
} else if (!module) {
promise = engine(context.network).then((e) =>
e.handle<unknown, unknown>({
...context.request.params.request,
id: crypto.randomUUID(),
jsonrpc: '2.0',
}),
);
} else if (
!context.authenticated &&
!moduleManager.isNonRestrictedMethod(module, method)
) {
promise = Promise.reject(ethErrors.provider.unauthorized());
} else {
if (module) {
promise = module.onRpcRequest(
{
chainId: context.network.caipId,
dappInfo: {
icon: context.domainMetadata.icon ?? '',
name: context.domainMetadata.name ?? '',
url: context.domainMetadata.url ?? '',
},
requestId: context.request.id,
sessionId: context.request.params.sessionId,
method: context.request.params.request.method,
params: context.request.params.request.params,
// Do not pass context from unknown sources.
// This field is for our internal use only (only used with extension's direct connection)
context: undefined,
promise = module.onRpcRequest(
{
chainId: context.network.caipId,
dappInfo: {
icon: context.domainMetadata.icon ?? '',
name: context.domainMetadata.name ?? '',
url: context.domainMetadata.url ?? '',
},
context.network,
);
} else {
promise = engine(context.network).then((e) =>
e.handle<unknown, unknown>({
...context.request.params.request,
id: crypto.randomUUID(),
jsonrpc: '2.0',
}),
);
}
requestId: context.request.id,
sessionId: context.request.params.sessionId,
method: context.request.params.request.method,
params: context.request.params.request.params,
// Do not pass context from unknown sources.
// This field is for our internal use only (only used with extension's direct connection)
context: undefined,
},
context.network,
);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/background/connections/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type ExtensionConnectionMessage<
Params = any,
> = JsonRpcRequest<Method, Params>;

export type HandlerParameters<Type> = ExtractHandlerTypes<Type>['Params'];

export type ExtensionConnectionMessageResponse<
Method extends ExtensionRequest | DAppProviderRequest | RpcMethod = any,
Result = any,
Expand Down
13 changes: 13 additions & 0 deletions src/background/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,17 @@ export type Never<T> = {

export type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;

export type FirstParameter<T extends (...args: any) => any> = T extends (
...args: infer P
) => any
? P[0]
: never;

export const ACTION_HANDLED_BY_MODULE = '__handled.via.vm.modules__';

export const hasDefined = <T extends object, K extends keyof T>(
obj: T,
key: K,
): obj is EnsureDefined<T, K> => {
return obj[key] !== undefined;
};
7 changes: 5 additions & 2 deletions src/background/runtime/openApprovalWindow.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { container } from 'tsyringe';

import { Action } from '../services/actions/models';
import { Action, MultiTxAction } from '../services/actions/models';
import { ApprovalService } from '../services/approvals/ApprovalService';

export const openApprovalWindow = async (action: Action, url: string) => {
export const openApprovalWindow = async (
action: Action | MultiTxAction,
url: string,
) => {
const actionId = crypto.randomUUID();
// using direct injection instead of the constructor to prevent circular dependencies
const approvalService = container.resolve(ApprovalService);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { DEFERRED_RESPONSE } from '@src/background/connections/middlewares/model
import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow';
import { canSkipApproval } from '@src/utils/canSkipApproval';

import type { Action } from '../../actions/models';
import { type Action, buildActionForRequest } from '../../actions/models';
import { SecretsService } from '../../secrets/SecretsService';
import { AccountsService } from '../AccountsService';
import type { ImportedAccount, PrimaryAccount } from '../models';
Expand Down Expand Up @@ -162,10 +162,7 @@ export class AvalancheDeleteAccountsHandler extends DAppRequestHandler<
}
}

const actionData: Action<{
accounts: DeleteAccountsDisplayData;
}> = {
...request,
const actionData = buildActionForRequest(request, {
scope,
displayData: {
accounts: {
Expand All @@ -174,7 +171,7 @@ export class AvalancheDeleteAccountsHandler extends DAppRequestHandler<
wallet: walletNames,
},
},
};
});

await openApprovalWindow(actionData, 'deleteAccounts');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow';
import { canSkipApproval } from '@src/utils/canSkipApproval';

import { AccountsService } from '../AccountsService';
import { Action } from '../../actions/models';
import { Action, buildActionForRequest } from '../../actions/models';
import { Account } from '../models';

type Params = [accountId: string, newName: string];
Expand Down Expand Up @@ -79,14 +79,13 @@ export class AvalancheRenameAccountHandler extends DAppRequestHandler<
}
}

const actionData: Action<{ account: Account; newName: string }> = {
...request,
const actionData = buildActionForRequest(request, {
scope,
displayData: {
account,
newName,
},
};
});

await openApprovalWindow(actionData, 'renameAccount');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { DEFERRED_RESPONSE } from '@src/background/connections/middlewares/model
import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow';
import { AccountsService } from '../AccountsService';
import { Account } from '../models';
import { Action } from '../../actions/models';
import { Action, buildActionForRequest } from '../../actions/models';
import { PermissionsService } from '../../permissions/PermissionsService';
import { isPrimaryAccount } from '../utils/typeGuards';
import { canSkipApproval } from '@src/utils/canSkipApproval';
Expand Down Expand Up @@ -82,13 +82,12 @@ export class AvalancheSelectAccountHandler extends DAppRequestHandler<
return { ...request, result: null };
}

const actionData: Action<{ selectedAccount: Account }> = {
...request,
const actionData = buildActionForRequest(request, {
scope,
displayData: {
selectedAccount,
},
};
});

await openApprovalWindow(actionData, `switchAccount`);

Expand Down
55 changes: 55 additions & 0 deletions src/background/services/actions/ActionsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ActionsEvent,
ActionStatus,
ACTIONS_STORAGE_KEY,
ActionType,
} from './models';
import { filterStaleActions } from './utils';
import { ApprovalController } from '@src/background/vmModules/ApprovalController';
Expand Down Expand Up @@ -64,6 +65,7 @@ describe('background/services/actions/ActionsService.ts', () => {
onApproved: jest.fn(),
onRejected: jest.fn(),
updateTx: jest.fn(),
updateTxInBatch: jest.fn(),
} as unknown as jest.Mocked<ApprovalController>;

actionsService = new ActionsService(
Expand All @@ -82,6 +84,59 @@ describe('background/services/actions/ActionsService.ts', () => {
);
});

describe('when dealing with a batch action', () => {
const signingRequests = [
{ from: '0x1', to: '0x2', value: '0x3' },
{ from: '0x1', to: '0x2', value: '0x4' },
];
const pendingActions = {
'id-0': {
type: ActionType.Single,
actionId: 'id-0',
},
'id-1': {
signingRequests,
type: ActionType.Batch,
actionId: 'id-1',
},
};

beforeEach(() => {
jest
.spyOn(actionsService, 'getActions')
.mockResolvedValueOnce(pendingActions as any);
});

it('uses the ApprovalController.updateTxInBatch() to fetch the new action data & saves it', async () => {
const newDisplayData = { ...displayData };
const updatedActionData = {
signingRequests,
displayData: newDisplayData,
} as any;

approvalController.updateTxInBatch.mockReturnValueOnce(
updatedActionData,
);

await actionsService.updateTx(
'id-1',
{
maxFeeRate: 5n,
maxTipRate: 1n,
},
0,
);

expect(storageService.save).toHaveBeenCalledWith(ACTIONS_STORAGE_KEY, {
...pendingActions,
'id-1': {
...pendingActions['id-1'],
...updatedActionData,
},
});
});
});

it('uses the ApprovalController.updateTx() to fetch the new action data & saves it', async () => {
const pendingActions = {
'id-0': {
Expand Down
Loading
Loading