feat: govern using ICAs (#3381)

### Description

- Add inferICAEncodedSubmissionType for submitting transactions through
ICA routers

### Drive-by changes

- SafeUrl doesn't throw for chains we don't have safe service for. (bc
while checking for native SubmissionType.SAFE we'll still check)

### Related issues

- fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3199

### Backward compatibility

Yes

### Testing

Unit tests

---------

Signed-off-by: Paul Balaji <paul@hyperlane.xyz>
Co-authored-by: Paul Balaji <paul@hyperlane.xyz>
Co-authored-by: J M Rossy <jm.rossy@gmail.com>
Co-authored-by: Trevor Porter <trkporter@ucdavis.edu>
Co-authored-by: Daniel Savu <23065004+daniel-savu@users.noreply.github.com>
pull/3495/head
Kunal Arora 8 months ago committed by GitHub
parent 38358ececd
commit d792309b8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      typescript/infra/config/environments/mainnet3/core.ts
  2. 3
      typescript/infra/package.json
  3. 116
      typescript/infra/src/govern/HyperlaneAppGovernor.ts
  4. 7
      typescript/infra/src/utils/safe.ts
  5. 217
      typescript/infra/test/govern.hardhat-test.ts
  6. 3
      typescript/sdk/src/middleware/account/InterchainAccount.ts

@ -18,7 +18,7 @@ import {
RoutingIsmConfig,
defaultMultisigConfigs,
} from '@hyperlane-xyz/sdk';
import { objMap } from '@hyperlane-xyz/utils';
import { Address, objMap } from '@hyperlane-xyz/utils';
import { supportedChainNames } from './chains';
import { igp } from './igp';
@ -99,7 +99,7 @@ export const core: ChainMap<CoreConfig> = objMap(owners, (local, owner) => {
type: HookType.PROTOCOL_FEE,
maxProtocolFee: ethers.utils.parseUnits('1', 'gwei').toString(), // 1 gwei of native token
protocolFee: BigNumber.from(0).toString(), // 0 wei
beneficiary: owner.owner,
beneficiary: owner.owner as Address, // Owner can be AccountConfig
...owner,
};

@ -70,7 +70,8 @@
"announce": "hardhat announce --network localhost",
"node": "hardhat node",
"prettier": "prettier --write *.ts ./src ./config ./scripts ./test",
"test": "mocha --config ../sdk/.mocharc.json test/**/*.test.ts",
"test": "mocha --config ../sdk/.mocharc.json test/**/*.test.ts && yarn test:hardhat",
"test:hardhat": "hardhat test test/govern.hardhat-test.ts",
"test:ci": "yarn test"
},
"peerDependencies": {

@ -1,16 +1,24 @@
import { BigNumber } from 'ethers';
import { prompts } from 'prompts';
import { Ownable__factory } from '@hyperlane-xyz/core';
import {
AccountConfig,
ChainMap,
ChainName,
HyperlaneApp,
HyperlaneAppChecker,
InterchainAccount,
OwnableConfig,
OwnerViolation,
resolveOrDeployAccountOwner,
} from '@hyperlane-xyz/sdk';
import { Address, CallData, objMap } from '@hyperlane-xyz/utils';
import {
Address,
CallData,
bytes32ToAddress,
eqAddress,
objMap,
} from '@hyperlane-xyz/utils';
import { canProposeSafeTransactions } from '../utils/safe';
@ -37,13 +45,20 @@ export abstract class HyperlaneAppGovernor<
Config extends OwnableConfig,
> {
readonly checker: HyperlaneAppChecker<App, Config>;
private calls: ChainMap<AnnotatedCallData[]>;
protected calls: ChainMap<AnnotatedCallData[]>;
private canPropose: ChainMap<Map<string, boolean>>;
readonly interchainAccount?: InterchainAccount;
constructor(checker: HyperlaneAppChecker<App, Config>) {
constructor(
checker: HyperlaneAppChecker<App, Config>,
readonly ica?: InterchainAccount,
) {
this.checker = checker;
this.calls = objMap(this.checker.app.contractsMap, () => []);
this.canPropose = objMap(this.checker.app.contractsMap, () => new Map());
if (ica) {
this.interchainAccount = ica;
}
}
async govern(confirm = true, chain?: ChainName) {
@ -120,32 +135,97 @@ export abstract class HyperlaneAppGovernor<
SubmissionType.SIGNER,
new SignerMultiSend(this.checker.multiProvider, chain),
);
const owner = await resolveOrDeployAccountOwner(
this.checker.multiProvider,
chain,
this.checker.configMap[chain].owner,
);
let safeOwner: Address;
if (typeof this.checker.configMap[chain].owner === 'string') {
safeOwner = this.checker.configMap[chain].owner as Address;
} else {
safeOwner = (this.checker.configMap[chain].owner as AccountConfig).owner;
}
await sendCallsForType(
SubmissionType.SAFE,
new SafeMultiSend(this.checker.multiProvider, chain, owner),
new SafeMultiSend(this.checker.multiProvider, chain, safeOwner),
);
await sendCallsForType(SubmissionType.MANUAL, new ManualMultiSend(chain));
}
protected pushCall(chain: ChainName, call: AnnotatedCallData) {
this.calls[chain] = this.calls[chain] || [];
this.calls[chain].push(call);
}
protected popCall(chain: ChainName): AnnotatedCallData | undefined {
return this.calls[chain].pop();
}
protected abstract mapViolationsToCalls(): Promise<void>;
protected async inferCallSubmissionTypes() {
for (const chain of Object.keys(this.calls)) {
for (const call of this.calls[chain]) {
call.submissionType = await this.inferCallSubmissionType(chain, call);
let submissionType = await this.inferCallSubmissionType(chain, call);
if (submissionType === SubmissionType.MANUAL) {
submissionType = await this.inferICAEncodedSubmissionType(
chain,
call,
);
}
call.submissionType = submissionType;
}
}
}
protected async inferICAEncodedSubmissionType(
chain: ChainName,
call: AnnotatedCallData,
): Promise<SubmissionType> {
const multiProvider = this.checker.multiProvider;
const signer = multiProvider.getSigner(chain);
if (this.interchainAccount) {
const ownableAddress = call.to;
const ownable = Ownable__factory.connect(ownableAddress, signer);
const account = Ownable__factory.connect(await ownable.owner(), signer);
const localOwner = await account.owner();
if (eqAddress(localOwner, this.interchainAccount.routerAddress(chain))) {
const accountConfig = await this.interchainAccount.getAccountConfig(
chain,
account.address,
);
const origin = this.interchainAccount.multiProvider.getChainName(
accountConfig.origin,
);
console.log(
`Inferred call for ICA remote owner ${bytes32ToAddress(
accountConfig.owner,
)} on ${origin}`,
);
const callRemote = await this.interchainAccount.getCallRemote(
origin,
chain,
[call],
accountConfig,
);
if (!callRemote.to || !callRemote.data) {
return SubmissionType.MANUAL;
}
const encodedCall: AnnotatedCallData = {
to: callRemote.to,
data: callRemote.data,
value: callRemote.value,
description: `${call.description} - interchain account call from ${origin} to ${chain}`,
};
const subType = await this.inferCallSubmissionType(origin, encodedCall);
if (subType !== SubmissionType.MANUAL) {
this.popCall(chain);
this.pushCall(origin, encodedCall);
return subType;
}
} else {
console.log(`Account's owner ${localOwner} is not ICA router`);
}
}
return SubmissionType.MANUAL;
}
protected async inferCallSubmissionType(
chain: ChainName,
call: AnnotatedCallData,
@ -155,6 +235,7 @@ export abstract class HyperlaneAppGovernor<
const signerAddress = await signer.getAddress();
const transactionSucceedsFromSender = async (
chain: ChainName,
submitterAddress: Address,
): Promise<boolean> => {
try {
@ -164,19 +245,17 @@ export abstract class HyperlaneAppGovernor<
return false;
};
if (await transactionSucceedsFromSender(signerAddress)) {
if (await transactionSucceedsFromSender(chain, signerAddress)) {
return SubmissionType.SIGNER;
}
// 2. Check if the call will succeed via Gnosis Safe.
const safeAddress = this.checker.configMap[chain].owner;
if (!safeAddress) throw new Error(`Owner address not found for ${chain}`);
// 2a. Confirm that the signer is a Safe owner or delegate.
// This should implicitly check whether or not the owner is a gnosis
// safe.
// TODO: support for ICA governance coming soon
if (typeof safeAddress === 'string') {
// 2a. Confirm that the signer is a Safe owner or delegate.
// This should implicitly check whether or not the owner is a gnosis
// safe.
if (!this.canPropose[chain].has(safeAddress)) {
this.canPropose[chain].set(
safeAddress,
@ -191,12 +270,11 @@ export abstract class HyperlaneAppGovernor<
// 2b. Check if calling from the owner/safeAddress will succeed.
if (
this.canPropose[chain].get(safeAddress) &&
(await transactionSucceedsFromSender(safeAddress))
(await transactionSucceedsFromSender(chain, safeAddress))
) {
return SubmissionType.SAFE;
}
}
return SubmissionType.MANUAL;
}

@ -43,7 +43,12 @@ export async function canProposeSafeTransactions(
multiProvider: MultiProvider,
safeAddress: string,
): Promise<boolean> {
const safeService = getSafeService(chain, multiProvider);
let safeService;
try {
safeService = getSafeService(chain, multiProvider);
} catch (e) {
return false;
}
const safe = await getSafe(chain, multiProvider, safeAddress);
const delegates = await getSafeDelegates(safeService, safeAddress);
const owners = await safe.getOwners();

@ -0,0 +1,217 @@
import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers';
import { expect } from 'chai';
import { BigNumber } from 'ethers';
import { ethers } from 'hardhat';
import {
InterchainAccountRouter,
TestRecipient,
TestRecipient__factory,
} from '@hyperlane-xyz/core';
import {
AccountConfig,
ChainMap,
ChainName,
Chains,
HyperlaneApp,
HyperlaneAppChecker,
HyperlaneContractsMap,
HyperlaneIsmFactory,
HyperlaneProxyFactoryDeployer,
InterchainAccount,
InterchainAccountDeployer,
MultiProvider,
OwnableConfig,
RouterConfig,
TestCoreApp,
TestCoreDeployer,
randomAddress,
resolveOrDeployAccountOwner,
} from '@hyperlane-xyz/sdk';
import { InterchainAccountFactories } from '@hyperlane-xyz/sdk/dist/middleware/account/contracts';
import { Address, CallData, eqAddress } from '@hyperlane-xyz/utils';
import {
AnnotatedCallData,
HyperlaneAppGovernor,
} from '../src/govern/HyperlaneAppGovernor';
export class TestApp extends HyperlaneApp<{}> {}
export class TestChecker extends HyperlaneAppChecker<TestApp, OwnableConfig> {
async checkChain(_: string): Promise<void> {
this.addViolation({
chain: Chains.test2,
type: 'test',
expected: 0,
actual: 1,
});
}
}
export class HyperlaneTestGovernor extends HyperlaneAppGovernor<
TestApp,
OwnableConfig
> {
protected async mapViolationsToCalls() {
return;
}
mockPushCall(chain: string, call: AnnotatedCallData): void {
this.pushCall(chain, call);
}
async govern(_ = true, chain?: ChainName) {
// 2. For each call, infer how it should be submitted on-chain.
await this.inferCallSubmissionTypes();
// 3. Prompt the user to confirm that the count, description,
// and submission methods look correct before submitting.
const chains = chain ? [chain] : Object.keys(this.calls);
for (const chain of chains) {
await this.mockSendCalls(chain, this.calls[chain]);
}
}
protected async mockSendCalls(
chain: ChainName,
calls: CallData[],
): Promise<void> {
for (const call of calls) {
await this.checker.multiProvider.sendTransaction(chain, {
to: call.to,
data: call.data,
value: call.value,
});
}
}
}
describe('ICA governance', async () => {
const localChain = Chains.test1;
const remoteChain = Chains.test2;
let signer: SignerWithAddress;
let multiProvider: MultiProvider;
let accountConfig: AccountConfig;
let coreApp: TestCoreApp;
// let local: InterchainAccountRouter;
let remote: InterchainAccountRouter;
let routerConfig: ChainMap<RouterConfig>;
let contracts: HyperlaneContractsMap<InterchainAccountFactories>;
let icaApp: InterchainAccount;
let recipient: TestRecipient;
let accountOwner: Address;
let governor: HyperlaneTestGovernor;
before(async () => {
[signer] = await ethers.getSigners();
multiProvider = MultiProvider.createTestMultiProvider({ signer });
const ismFactoryDeployer = new HyperlaneProxyFactoryDeployer(multiProvider);
const ismFactory = new HyperlaneIsmFactory(
await ismFactoryDeployer.deploy(multiProvider.mapKnownChains(() => ({}))),
multiProvider,
);
coreApp = await new TestCoreDeployer(multiProvider, ismFactory).deployApp();
routerConfig = coreApp.getRouterConfig(signer.address);
});
beforeEach(async () => {
contracts = await new InterchainAccountDeployer(multiProvider).deploy(
routerConfig,
);
// local = contracts[localChain].interchainAccountRouter;
remote = contracts[remoteChain].interchainAccountRouter;
icaApp = new InterchainAccount(contracts, multiProvider);
accountConfig = {
origin: Chains.test1,
owner: signer.address,
localRouter: remote.address,
};
const recipientF = new TestRecipient__factory(signer);
recipient = await recipientF.deploy();
const contractsMap = {
[remoteChain]: {
recipient,
},
[localChain]: {
recipient,
},
};
// missing ica
const configMap = {
[localChain]: { owner: signer.address },
[remoteChain]: {
owner: { origin: Chains.test1, owner: signer.address },
},
};
const app = new TestApp(contractsMap, multiProvider);
const checker = new TestChecker(multiProvider, app, configMap);
governor = new HyperlaneTestGovernor(checker, icaApp);
accountOwner = await resolveOrDeployAccountOwner(
multiProvider,
remoteChain,
accountConfig,
);
await recipient.transferOwnership(accountOwner);
});
it('changes ISM on the remote recipient', async () => {
// precheck
const actualOwner = await recipient.owner();
expect(actualOwner).to.equal(accountOwner);
// arrange
const newIsm = randomAddress();
await governor.checker.checkChain(Chains.test2);
const call = {
to: recipient.address,
data: recipient.interface.encodeFunctionData(
'setInterchainSecurityModule',
[newIsm],
),
value: BigNumber.from(0),
description: 'Setting ISM on the test recipient',
};
governor.mockPushCall(remoteChain, call);
// act
await governor.govern(); // this is where the ICA inference happens
await coreApp.processMessages();
// assert
const actualIsm = await recipient.interchainSecurityModule();
expect(eqAddress(actualIsm, newIsm)).to.be.true;
});
it('transfer ownership back to the deployer', async () => {
// precheck
let actualOwner = await recipient.owner();
expect(actualOwner).to.equal(accountOwner);
// arrange
const call = {
to: recipient.address,
data: recipient.interface.encodeFunctionData('transferOwnership', [
signer.address,
]),
value: BigNumber.from(0),
description: 'Transfer ownership',
};
governor.mockPushCall(remoteChain, call);
// act
await governor.govern();
await coreApp.processMessages();
// assert
actualOwner = await recipient.owner();
expect(actualOwner).to.equal(signer.address);
});
});

@ -124,8 +124,7 @@ export class InterchainAccount extends RouterApp<InterchainAccountFactories> {
const remoteDomain = this.multiProvider.getDomainId(destination);
const quote = await localRouter['quoteGasPayment(uint32)'](remoteDomain);
const remoteRouter = addressToBytes32(
config.routerOverride ??
this.router(this.contractsMap[destination]).address,
config.routerOverride ?? this.routerAddress(destination),
);
const remoteIsm = addressToBytes32(
config.ismOverride ??

Loading…
Cancel
Save