From d25f0c1827d878e24ffbedf54d6f3409d0d2014f Mon Sep 17 00:00:00 2001 From: Csongor Kiss <kiss.csongor.kiss@gmail.com> Date: Wed, 22 May 2024 19:04:55 +0100 Subject: [PATCH 1/4] sdk: add missing admin functionality and queries --- sdk/__tests__/utils.ts | 4 +- sdk/definitions/src/ntt.ts | 77 ++++++++++- sdk/evm/src/ntt.ts | 227 ++++++++++++++++++++++++++++-- solana/ts/lib/ntt.ts | 39 +++++- solana/ts/sdk/ntt.ts | 277 ++++++++++++++++++++++++++++++++----- 5 files changed, 572 insertions(+), 52 deletions(-) diff --git a/sdk/__tests__/utils.ts b/sdk/__tests__/utils.ts index c8d3ad1c6..99b5fccfd 100644 --- a/sdk/__tests__/utils.ts +++ b/sdk/__tests__/utils.ts @@ -127,7 +127,7 @@ export async function link(chainInfos: Ctx[]) { chain: hubChain, emitter: Wormhole.chainAddress( hubChain, - hub.contracts!.transceiver.wormhole + hub.contracts!.transceiver.wormhole! ).address.toUniversalAddress(), sequence: 0n, }; @@ -595,7 +595,7 @@ async function setupPeer(targetCtx: Ctx, peerCtx: Ctx) { } = peerCtx.contracts!; const peerManager = Wormhole.chainAddress(peer.chain, manager); - const peerTransceiver = Wormhole.chainAddress(peer.chain, transceiver); + const peerTransceiver = Wormhole.chainAddress(peer.chain, transceiver!); const tokenDecimals = target.config.nativeTokenDecimals; const inboundLimit = amount.units(amount.parse("1000", tokenDecimals)); diff --git a/sdk/definitions/src/ntt.ts b/sdk/definitions/src/ntt.ts index d5b7b7614..e84b7baac 100644 --- a/sdk/definitions/src/ntt.ts +++ b/sdk/definitions/src/ntt.ts @@ -38,7 +38,7 @@ export namespace Ntt { token: string; manager: string; transceiver: { - wormhole: string; + wormhole?: string; }; quoter?: string; }; @@ -85,6 +85,12 @@ export namespace Ntt { payload: Uint8Array; }; + export type Peer<C extends Chain> = { + address: ChainAddress<C>; + tokenDecimals: number; + inboundLimit: bigint; + }; + // TODO: should layoutify this but couldnt immediately figure out how to // specify the length of the array as an encoded value export function encodeTransceiverInstructions(ixs: TransceiverInstruction[]) { @@ -126,12 +132,31 @@ export namespace Ntt { * @typeparam C the chain */ export interface Ntt<N extends Network, C extends Chain> { + getMode(): Promise<Ntt.Mode>; + + isPaused(): Promise<boolean>; + + pause( + payer?: AccountAddress<C> + ): AsyncGenerator<UnsignedTransaction<N, C>>; + + unpause( + payer?: AccountAddress<C> + ): AsyncGenerator<UnsignedTransaction<N, C>>; + + getOwner(): Promise<AccountAddress<C>>; + + setOwner(newOwner: AccountAddress<C>, payer?: AccountAddress<C>): AsyncGenerator<UnsignedTransaction<N, C>>; + + getThreshold(): Promise<number>; + setPeer( peer: ChainAddress, tokenDecimals: number, inboundLimit: bigint, payer?: AccountAddress<C> ): AsyncGenerator<UnsignedTransaction<N, C>>; + setWormholeTransceiverPeer( peer: ChainAddress, payer?: AccountAddress<C> @@ -182,16 +207,44 @@ export interface Ntt<N extends Network, C extends Chain> { /** Get the number of decimals associated with the token under management */ getTokenDecimals(): Promise<number>; + /** Get the peer information for the given chain if it exists */ + getPeer<C extends Chain>(chain: C): Promise<Ntt.Peer<C> | null>; + + getTransceiver(ix: number): Promise<NttTransceiver<N, C, Ntt.Attestation> | null>; + /** * getCurrentOutboundCapacity returns the current outbound capacity of the Ntt manager */ getCurrentOutboundCapacity(): Promise<bigint>; + + /** + * getOutboundLimit returns the maximum outbound capacity of the Ntt manager + */ + getOutboundLimit(): Promise<bigint>; + + /** + * setOutboundLimit sets the maximum outbound capacity of the Ntt manager + */ + setOutboundLimit(limit: bigint, payer?: AccountAddress<C>): AsyncGenerator<UnsignedTransaction<N, C>>; + /** * getCurrentInboundCapacity returns the current inbound capacity of the Ntt manager * @param fromChain the chain to check the inbound capacity for */ getCurrentInboundCapacity(fromChain: Chain): Promise<bigint>; + /** + * getInboundLimit returns the maximum inbound capacity of the Ntt manager + * @param fromChain the chain to check the inbound limit for + */ + getInboundLimit(fromChain: Chain): Promise<bigint>; + + setInboundLimit( + fromChain: Chain, + limit: bigint, + payer?: AccountAddress<C> + ): AsyncGenerator<UnsignedTransaction<N, C>>; + /** * getIsApproved returns whether an attestation is approved * an attestation is approved when it has been validated but has not necessarily @@ -231,6 +284,21 @@ export interface Ntt<N extends Network, C extends Chain> { token: TokenAddress<C>, payer?: AccountAddress<C> ): AsyncGenerator<UnsignedTransaction<N, C>>; + + /** + * Given a manager address, the rest of the addresses (token address and + * transceiver addresses) can be queried from the manager contract directly. + * This method verifies that the addresses that were used to construct the Ntt + * instance match the addresses that are stored in the manager contract. + * + * TODO: perhaps a better way to do this would be by allowing async protocol + * initializers so this can be done when constructing the Ntt instance. + * That would be a larger change (in the connect sdk) so we do this for now. + * + * @returns the addresses that don't match the expected addresses, or null if + * they all match + */ + verifyAddresses(): Promise<Partial<Ntt.Contracts> | null>; } export interface NttTransceiver< @@ -238,10 +306,15 @@ export interface NttTransceiver< C extends Chain, A extends Ntt.Attestation > { + + getAddress(): ChainAddress<C>; + /** setPeer sets a peer address for a given chain * Note: Admin only */ - setPeer(peer: ChainAddress<Chain>): AsyncGenerator<UnsignedTransaction<N, C>>; + setPeer(peer: ChainAddress<Chain>, payer?: AccountAddress<C>): AsyncGenerator<UnsignedTransaction<N, C>>; + + getPeer<C extends Chain>(chain: C): Promise<ChainAddress<C> | null>; /** * receive calls the `receive*` method on the transceiver diff --git a/sdk/evm/src/ntt.ts b/sdk/evm/src/ntt.ts index 184bae4dc..895971ee9 100644 --- a/sdk/evm/src/ntt.ts +++ b/sdk/evm/src/ntt.ts @@ -7,9 +7,11 @@ import { Network, TokenAddress, VAA, + canonicalAddress, nativeChainIds, serialize, toChainId, + toUniversal, universalAddress, } from "@wormhole-foundation/sdk-connect"; import type { EvmChains, EvmPlatformType } from "@wormhole-foundation/sdk-evm"; @@ -36,8 +38,7 @@ import { } from "./bindings.js"; export class EvmNttWormholeTranceiver<N extends Network, C extends EvmChains> - implements NttTransceiver<N, C, WormholeNttTransceiver.VAA> -{ + implements NttTransceiver<N, C, WormholeNttTransceiver.VAA> { transceiver: NttTransceiverBindings.NttTransceiver; constructor( readonly manager: EvmNtt<N, C>, @@ -50,17 +51,48 @@ export class EvmNttWormholeTranceiver<N extends Network, C extends EvmChains> ); } + getAddress(): ChainAddress<C> { + return { chain: this.manager.chain, address: toUniversal(this.manager.chain, this.address) }; + } + encodeFlags(flags: { skipRelay: boolean }): Uint8Array { return new Uint8Array([flags.skipRelay ? 1 : 0]); } - async *setPeer(peer: ChainAddress<C>) { + async *setPeer<P extends Chain>(peer: ChainAddress<P>): AsyncGenerator<EvmUnsignedTransaction<N, C>> { const tx = await this.transceiver.setWormholePeer.populateTransaction( toChainId(peer.chain), universalAddress(peer) ); yield this.manager.createUnsignedTx(tx, "WormholeTransceiver.registerPeer"); } + + async getPeer<C extends Chain>(chain: C): Promise<ChainAddress<C> | null> { + const peer = await this.transceiver.getWormholePeer(toChainId(chain)); + const peerAddress = Buffer.from(peer.substring(2), 'hex'); + const zeroAddress = Buffer.alloc(32); + if (peerAddress.equals(zeroAddress)) { + return null; + } + + return { + chain: chain, + address: toUniversal(chain, peerAddress), + }; + } + + async isEvmChain(chain: Chain): Promise<boolean> { + return await this.transceiver.isWormholeEvmChain(toChainId(chain)); + } + + async *setIsEvmChain(chain: Chain, isEvm: boolean) { + const tx = await this.transceiver.setIsWormholeEvmChain.populateTransaction( + toChainId(chain), + isEvm + ); + yield this.manager.createUnsignedTx(tx, "WormholeTransceiver.setIsEvmChain"); + } + async *receive(attestation: WormholeNttTransceiver.VAA) { const tx = await this.transceiver.receiveMessage.populateTransaction( serialize(attestation) @@ -77,6 +109,17 @@ export class EvmNttWormholeTranceiver<N extends Network, C extends EvmChains> ); } + async *setIsWormholeRelayingEnabled(destChain: Chain, enabled: boolean) { + const tx = await this.transceiver.setIsWormholeRelayingEnabled.populateTransaction( + toChainId(destChain), + enabled + ); + yield this.manager.createUnsignedTx( + tx, + "WormholeTransceiver.setWormholeRelayingEnabled" + ); + } + async isSpecialRelayingEnabled(destChain: Chain): Promise<boolean> { return await this.transceiver.isSpecialRelayingEnabled( toChainId(destChain) @@ -85,8 +128,7 @@ export class EvmNttWormholeTranceiver<N extends Network, C extends EvmChains> } export class EvmNtt<N extends Network, C extends EvmChains> - implements Ntt<N, C> -{ + implements Ntt<N, C> { tokenAddress: string; readonly chainId: bigint; manager: NttManagerBindings.NttManager; @@ -117,14 +159,62 @@ export class EvmNtt<N extends Network, C extends EvmChains> this.provider ); - this.xcvrs = [ - // Enable more Transceivers here - new EvmNttWormholeTranceiver( - this, - contracts.ntt.transceiver.wormhole!, - abiBindings! - ), - ]; + if (contracts.ntt.transceiver.wormhole != null) { + this.xcvrs = [ + // Enable more Transceivers here + new EvmNttWormholeTranceiver( + this, + contracts.ntt.transceiver.wormhole, + abiBindings! + ), + ]; + } else { + this.xcvrs = []; + } + } + + async getTransceiver(ix: number): Promise<NttTransceiver<N, C, any> | null> { + // TODO: should we make an RPC call here, or just trust that the xcvrs are set up correctly? + return this.xcvrs[ix] || null; + } + + async getMode(): Promise<Ntt.Mode> { + const mode: bigint = await this.manager.getMode(); + return mode === 0n ? "locking" : "burning"; + } + + async isPaused(): Promise<boolean> { + return await this.manager.isPaused(); + } + + async *pause() { + const tx = await this.manager.pause.populateTransaction() + yield this.createUnsignedTx(tx, "Ntt.pause"); + } + + async *unpause() { + const tx = await this.manager.unpause.populateTransaction() + yield this.createUnsignedTx(tx, "Ntt.unpause"); + } + + async getOwner(): Promise<AccountAddress<C>> { + return new EvmAddress(await this.manager.owner()) as AccountAddress<C>; + } + + async *setOwner(owner: AccountAddress<C>) { + const canonicalOwner = canonicalAddress({chain: this.chain, address: owner}); + const tx = await this.manager.transferOwnership.populateTransaction(canonicalOwner); + yield this.createUnsignedTx(tx, "Ntt.setOwner"); + } + + async *setPauser(pauser: AccountAddress<C>) { + const canonicalPauser = canonicalAddress({chain: this.chain, address: pauser}); + const tx = await this.manager.transferPauserCapability.populateTransaction(canonicalPauser); + yield this.createUnsignedTx(tx, "Ntt.setPauser"); + } + + async getThreshold(): Promise<number> { + return Number(await this.manager.getThreshold()); } async isRelayingAvailable(destination: Chain): Promise<boolean> { @@ -165,6 +255,21 @@ export class EvmNtt<N extends Network, C extends EvmChains> ); } + async getPeer<C extends Chain>(chain: C): Promise<Ntt.Peer<C> | null> { + const peer = await this.manager.getPeer(toChainId(chain)); + const peerAddress = Buffer.from(peer.peerAddress.substring(2), 'hex'); + const zeroAddress = Buffer.alloc(32); + if (peerAddress.equals(zeroAddress)) { + return null; + } + + return { + address: { chain: chain, address: toUniversal(chain, peerAddress) }, + tokenDecimals: Number(peer.tokenDecimals), + inboundLimit: await this.getInboundLimit(chain), + }; + } + static async fromRpc<N extends Network>( provider: Provider, config: ChainsConfig<N, EvmPlatformType> @@ -317,10 +422,39 @@ export class EvmNtt<N extends Network, C extends EvmChains> return await this.manager.getCurrentOutboundCapacity(); } + async getOutboundLimit(): Promise<bigint> { + const encoded: EncodedTrimmedAmount = (await this.manager.getOutboundLimitParams()).limit; + const trimmedAmount: TrimmedAmount = decodeTrimmedAmount(encoded); + const tokenDecimals = await this.getTokenDecimals(); + + return untrim(trimmedAmount, tokenDecimals); + } + + async *setOutboundLimit(limit: bigint) { + const tx = await this.manager.setOutboundLimit.populateTransaction(limit); + yield this.createUnsignedTx(tx, "Ntt.setOutboundLimit"); + } + async getCurrentInboundCapacity(fromChain: Chain): Promise<bigint> { return await this.manager.getCurrentInboundCapacity(toChainId(fromChain)); } + async getInboundLimit(fromChain: Chain): Promise<bigint> { + const encoded: EncodedTrimmedAmount = (await this.manager.getInboundLimitParams(toChainId(fromChain))).limit; + const trimmedAmount: TrimmedAmount = decodeTrimmedAmount(encoded); + const tokenDecimals = await this.getTokenDecimals(); + + return untrim(trimmedAmount, tokenDecimals); + } + + async *setInboundLimit(fromChain: Chain, limit: bigint) { + const tx = await this.manager.setInboundLimit.populateTransaction( + limit, + toChainId(fromChain) + ); + yield this.createUnsignedTx(tx, "Ntt.setInboundLimit"); + } + async getRateLimitDuration(): Promise<bigint> { return await this.manager.rateLimitDuration(); } @@ -356,6 +490,40 @@ export class EvmNtt<N extends Network, C extends EvmChains> yield this.createUnsignedTx(tx, "Ntt.completeInboundQueuedTransfer"); } + async verifyAddresses(): Promise<Partial<Ntt.Contracts> | null> { + const local: Partial<Ntt.Contracts> = { + manager: this.managerAddress, + token: this.tokenAddress, + transceiver: { + wormhole: this.xcvrs[0]?.address, + }, + // TODO: what about the quoter? + }; + + const remote: Partial<Ntt.Contracts> = { + manager: this.managerAddress, + token: await this.manager.token(), + transceiver: { + wormhole: (await this.manager.getTransceivers())[0]! // TODO: make this more generic + }, + }; + + const deleteMatching = (a: any, b: any) => { + for (const k in a) { + if (typeof a[k] === "object") { + deleteMatching(a[k], b[k]); + if (Object.keys(a[k]).length === 0) delete a[k]; + } else if (a[k] === b[k]) { + delete a[k]; + } + } + } + + deleteMatching(remote, local); + + return Object.keys(remote).length > 0 ? remote : null; + } + createUnsignedTx( txReq: TransactionRequest, description: string, @@ -370,3 +538,36 @@ export class EvmNtt<N extends Network, C extends EvmChains> ); } } + +type EncodedTrimmedAmount = bigint; // uint72 + +type TrimmedAmount = { + amount: bigint; + decimals: number; +}; + +function decodeTrimmedAmount(encoded: EncodedTrimmedAmount): TrimmedAmount { + const decimals = Number(encoded & 0xffn); + const amount = encoded >> 8n; + return { + amount, + decimals, + }; +} + +function untrim(trimmed: TrimmedAmount, toDecimals: number): bigint { + const { amount, decimals: fromDecimals } = trimmed; + return scale(amount, fromDecimals, toDecimals); +} + +function scale(amount: bigint, fromDecimals: number, toDecimals: number): bigint { + if (fromDecimals == toDecimals) { + return amount; + } + + if (fromDecimals > toDecimals) { + return amount / (10n ** BigInt(fromDecimals - toDecimals)); + } else { + return amount * (10n ** BigInt(toDecimals - fromDecimals)); + } +} diff --git a/solana/ts/lib/ntt.ts b/solana/ts/lib/ntt.ts index c4740df7f..c2c6e5b9d 100644 --- a/solana/ts/lib/ntt.ts +++ b/solana/ts/lib/ntt.ts @@ -714,6 +714,23 @@ export namespace NTT { return transferIx; } + export async function createTransferOwnershipInstruction( + program: Program<NttBindings.NativeTokenTransfer<IdlVersion>>, + args: { + newOwner: PublicKey; + }, + pdas?: Pdas + ) { + pdas = pdas ?? NTT.pdas(program.programId); + return await program.methods + .transferOwnership() + .accounts({ + config: pdas.configAccount(), + newOwner: args.newOwner, + }) + .instruction(); + } + export async function createSetPeerInstruction( program: Program<NttBindings.NativeTokenTransfer<IdlVersion>>, args: { @@ -744,6 +761,25 @@ export namespace NTT { .instruction(); } + // TODO: untested + export async function createSetPausedInstruction( + program: Program<NttBindings.NativeTokenTransfer<IdlVersion>>, + args: { + owner: PublicKey; + paused: boolean; + }, + pdas?: Pdas + ) { + pdas = pdas ?? NTT.pdas(program.programId); + return await program.methods + .setPaused(args.paused) + .accountsStrict({ + owner: args.owner, + config: pdas.configAccount(), + }) + .instruction(); + } + export async function setWormholeTransceiverPeer( program: Program<NttBindings.NativeTokenTransfer<IdlVersion>>, args: { @@ -853,11 +889,10 @@ export namespace NTT { }; } - export async function createSetOuboundLimitInstruction( + export async function createSetOutboundLimitInstruction( program: Program<NttBindings.NativeTokenTransfer<IdlVersion>>, args: { owner: PublicKey; - chain: Chain; limit: BN; }, pdas?: Pdas diff --git a/solana/ts/sdk/ntt.ts b/solana/ts/sdk/ntt.ts index 524531c2f..fe4cdf797 100644 --- a/solana/ts/sdk/ntt.ts +++ b/solana/ts/sdk/ntt.ts @@ -21,9 +21,11 @@ import { Network, TokenAddress, UnsignedTransaction, + toUniversal, } from "@wormhole-foundation/sdk-connect"; import { Ntt, + NttTransceiver, WormholeNttTransceiver, } from "@wormhole-foundation/sdk-definitions-ntt"; import { @@ -43,9 +45,47 @@ import { NTT, NttQuoter, WEI_PER_GWEI } from "../lib/index.js"; import { IdlVersion, NttBindings, getNttProgram } from "../lib/bindings.js"; +export class SolanaNttWormholeTransceiver<N extends Network, C extends SolanaChains> + implements NttTransceiver<N, C, WormholeNttTransceiver.VAA> { + + constructor( + readonly manager: SolanaNtt<N, C>, + readonly address: PublicKey + ) {} + + async *receive(_attestation: WormholeNttTransceiver.VAA) { + // TODO: this is implemented below (in the transceiver code). it could get + // tricky in general with multiple transceivers, as they might return an + // instruction, or multiple instructions, etc. + // in any case, we should implement this here. + throw new Error("Method not implemented."); + } + + getAddress(): ChainAddress<C> { + return { chain: this.manager.chain, address: toUniversal(this.manager.chain, this.address.toBase58()) }; + } + + async *setPeer(peer: ChainAddress<C>, payer: AccountAddress<C>) { + yield* this.manager.setWormholeTransceiverPeer(peer, payer); + } + + async getPeer<C extends Chain>(chain: C): Promise<ChainAddress<C> | null> { + const peer = + await this.manager.program.account.transceiverPeer.fetchNullable( + this.manager.pdas.transceiverPeerAccount(chain) + ); + + if (!peer) return null; + + return { + chain, + address: toUniversal(chain, new Uint8Array(peer.address)), + }; + } +} + export class SolanaNtt<N extends Network, C extends SolanaChains> - implements Ntt<N, C> -{ + implements Ntt<N, C> { core: SolanaWormholeCore<N, C>; pdas: NTT.Pdas; @@ -55,6 +95,12 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> quoter?: NttQuoter; addressLookupTable?: AddressLookupTableAccount; + // NOTE: these are stored from the constructor, but are not used directly + // (only in verifyAddresses) + private managerAddress: string; + private tokenAddress: string; + private whTransceiverAddress?: string; + constructor( readonly network: N, readonly chain: C, @@ -70,6 +116,10 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> version as IdlVersion ); + this.managerAddress = contracts.ntt.manager; + this.tokenAddress = contracts.ntt.token; + this.whTransceiverAddress = contracts.ntt.transceiver.wormhole; + if (this.contracts.ntt?.quoter) this.quoter = new NttQuoter( connection, @@ -86,6 +136,71 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> this.pdas = NTT.pdas(this.program.programId); } + async getTransceiver(ix: number): Promise<NttTransceiver<N, C, any> | null> { + if (ix !== 0) return null; + if (this.whTransceiverAddress === undefined) return null; + + return new SolanaNttWormholeTransceiver(this, new PublicKey(this.whTransceiverAddress)); + } + + async getMode(): Promise<Ntt.Mode> { + const config = await this.getConfig(); + return config.mode.locking != null ? "locking" : "burning"; + } + + async isPaused(): Promise<boolean> { + const config = await this.getConfig(); + return config.paused; + } + + async *pause(payer: AccountAddress<C>) { + const sender = new SolanaAddress(payer).unwrap(); + const ix = await NTT.createSetPausedInstruction(this.program, { + owner: sender, + paused: true, + }); + + const tx = new Transaction(); + tx.feePayer = sender; + tx.add(ix); + yield this.createUnsignedTx({ transaction: tx }, "Ntt.Pause"); + } + + async *unpause(payer: AccountAddress<C>) { + const sender = new SolanaAddress(payer).unwrap(); + const ix = await NTT.createSetPausedInstruction(this.program, { + owner: sender, + paused: false, + }); + + const tx = new Transaction(); + tx.feePayer = sender; + tx.add(ix); + yield this.createUnsignedTx({ transaction: tx }, "Ntt.Unpause"); + } + + async getThreshold(): Promise<number> { + const config = await this.getConfig(); + return config.threshold + } + + async getOwner(): Promise<AccountAddress<C>> { + const config = await this.getConfig(); + return new SolanaAddress(config.owner) as AccountAddress<C>; + } + + async *setOwner(newOwner: AccountAddress<C>, payer: AccountAddress<C>) { + const sender = new SolanaAddress(payer).unwrap(); + const ix = await NTT.createTransferOwnershipInstruction(this.program, { + newOwner: new SolanaAddress(newOwner).unwrap(), + }); + + const tx = new Transaction(); + tx.feePayer = sender; + tx.add(ix); + yield this.createUnsignedTx({ transaction: tx }, "Ntt.SetOwner"); + } + async isRelayingAvailable(destination: Chain): Promise<boolean> { if (!this.quoter) return false; return await this.quoter.isRelayEnabled(destination); @@ -147,6 +262,18 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> ); } + async getPeer<C extends Chain>(chain: C): Promise<Ntt.Peer<C> | null> { + const peer = await this.program.account.nttManagerPeer.fetchNullable(this.pdas.peerAccount(chain)); + + if (!peer) return null; + + return { + address: { chain: chain, address: toUniversal(chain, new Uint8Array(peer.address)) }, + tokenDecimals: peer.tokenDecimals, + inboundLimit: await this.getInboundLimit(chain), + }; + } + async getCustodyAddress(): Promise<string> { return (await this.getConfig()).custody.toBase58(); } @@ -156,11 +283,18 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> contracts: Contracts & { ntt: Ntt.Contracts }, sender?: AccountAddress<SolanaChains> ): Promise<IdlVersion> { - return NTT.getVersion( - connection, - new PublicKey(contracts.ntt.manager!), - sender ? new SolanaAddress(sender).unwrap() : undefined - ); + // TODO: what? the try catch doesn't seem to work. it's not catching the error + try { + return NTT.getVersion( + connection, + new PublicKey(contracts.ntt.manager!), + sender ? new SolanaAddress(sender).unwrap() : undefined + ); + } catch (e) { + const version = "2.0.0" + console.error(`Failed to get NTT version. Defaulting to ${version}`); + return version; + } } async *initialize( @@ -352,17 +486,17 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> const transferIx = config.mode.locking != null ? NTT.createTransferLockInstruction( - this.program, - config, - txArgs, - this.pdas - ) + this.program, + config, + txArgs, + this.pdas + ) : NTT.createTransferBurnInstruction( - this.program, - config, - txArgs, - this.pdas - ); + this.program, + config, + txArgs, + this.pdas + ); const releaseIx = NTT.createReleaseOutboundInstruction( this.program, @@ -490,6 +624,7 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> revertOnDelay: false, }; + // TODO: loop through transceivers etc. const redeemIx = NTT.createRedeemInstruction(this.program, config, { payer: senderAddress, vaa: wormholeNTT, @@ -498,15 +633,15 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> const releaseIx = config.mode.locking != null ? NTT.createReleaseInboundUnlockInstruction( - this.program, - config, - releaseArgs - ) + this.program, + config, + releaseArgs + ) : NTT.createReleaseInboundMintInstruction( - this.program, - config, - releaseArgs - ); + this.program, + config, + releaseArgs + ); const tx = new Transaction(); tx.feePayer = senderAddress; @@ -535,6 +670,26 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> return BigInt(rl.rateLimit.capacityAtLastTx.toString()); } + async getOutboundLimit(): Promise<bigint> { + const rl = await this.program.account.outboxRateLimit.fetch( + this.pdas.outboxRateLimitAccount() + ); + return BigInt(rl.rateLimit.limit.toString()); + } + + async *setOutboundLimit(limit: bigint, payer: AccountAddress<C>) { + const sender = new SolanaAddress(payer).unwrap(); + const ix = await NTT.createSetOutboundLimitInstruction(this.program, { + owner: sender, + limit: new BN(limit.toString()), + }); + + const tx = new Transaction(); + tx.feePayer = sender; + tx.add(ix); + yield this.createUnsignedTx({ transaction: tx }, "Ntt.SetOutboundLimit"); + } + async getCurrentInboundCapacity(fromChain: Chain): Promise<bigint> { const rl = await this.program.account.inboxRateLimit.fetch( this.pdas.inboxRateLimitAccount(fromChain) @@ -542,6 +697,31 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> return BigInt(rl.rateLimit.capacityAtLastTx.toString()); } + async getInboundLimit(fromChain: Chain): Promise<bigint> { + const rl = await this.program.account.inboxRateLimit.fetch( + this.pdas.inboxRateLimitAccount(fromChain) + ); + return BigInt(rl.rateLimit.limit.toString()); + } + + async *setInboundLimit( + fromChain: Chain, + limit: bigint, + payer: AccountAddress<C> + ) { + const sender = new SolanaAddress(payer).unwrap(); + const ix = await NTT.setInboundLimit(this.program, { + owner: sender, + chain: fromChain, + limit: new BN(limit.toString()), + }); + + const tx = new Transaction(); + tx.feePayer = sender; + tx.add(ix); + yield this.createUnsignedTx({ transaction: tx }, "Ntt.SetInboundLimit"); + } + async getIsExecuted(attestation: Ntt.Attestation): Promise<boolean> { if (!this.getIsApproved(attestation)) return false; @@ -595,15 +775,15 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> tx.add( await (config.mode.locking != null ? NTT.createReleaseInboundUnlockInstruction( - this.program, - config, - releaseArgs - ) + this.program, + config, + releaseArgs + ) : NTT.createReleaseInboundMintInstruction( - this.program, - config, - releaseArgs - )) + this.program, + config, + releaseArgs + )) ); yield this.createUnsignedTx( @@ -635,6 +815,37 @@ export class SolanaNtt<N extends Network, C extends SolanaChains> return xfer; } + async verifyAddresses(): Promise<Partial<Ntt.Contracts> | null> { + const local: Partial<Ntt.Contracts> = { + manager: this.managerAddress, + token: this.tokenAddress, + transceiver: { + wormhole: this.whTransceiverAddress, + }, + }; + + const remote: Partial<Ntt.Contracts> = { + manager: this.program.programId.toBase58(), + token: (await this.getConfig()).mint.toBase58(), + transceiver: { wormhole: this.pdas.emitterAccount().toBase58() }, + }; + + const deleteMatching = (a: any, b: any) => { + for (const k in a) { + if (typeof a[k] === "object") { + deleteMatching(a[k], b[k]); + if (Object.keys(a[k]).length === 0) delete a[k]; + } else if (a[k] === b[k]) { + delete a[k]; + } + } + } + + deleteMatching(remote, local); + + return Object.keys(remote).length > 0 ? remote : null; + } + async getAddressLookupTable( useCache = true ): Promise<AddressLookupTableAccount> { From bd5b8e2186b633048eae9d489e69931ec51951b6 Mon Sep 17 00:00:00 2001 From: Csongor Kiss <kiss.csongor.kiss@gmail.com> Date: Thu, 27 Jun 2024 13:55:06 +0100 Subject: [PATCH 2/4] evm/script: add upgrade support to deploy script --- evm/script/DeployWormholeNtt.s.sol | 64 ++++++++++++++++++-- evm/script/helpers/DeployWormholeNttBase.sol | 6 +- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/evm/script/DeployWormholeNtt.s.sol b/evm/script/DeployWormholeNtt.s.sol index a2b2018ea..a6f244692 100644 --- a/evm/script/DeployWormholeNtt.s.sol +++ b/evm/script/DeployWormholeNtt.s.sol @@ -1,15 +1,48 @@ // SPDX-License-Identifier: Apache 2 pragma solidity >=0.8.8 <0.9.0; -import {Script} from "forge-std/Script.sol"; +import {Script, console} from "forge-std/Script.sol"; import {DeployWormholeNttBase} from "./helpers/DeployWormholeNttBase.sol"; +import {INttManager} from "../src/interfaces/INttManager.sol"; +import {IWormholeTransceiver} from "../src/interfaces/IWormholeTransceiver.sol"; +import "../src/interfaces/IManagerBase.sol"; +import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {NttManager} from "../src/NttManager/NttManager.sol"; + +interface IWormhole { + function chainId() external view returns (uint16); +} contract DeployWormholeNtt is Script, DeployWormholeNttBase { - function run() public { + function run( + address wormhole, + address token, + address wormholeRelayer, + address specialRelayer, + IManagerBase.Mode mode + ) public { vm.startBroadcast(); - // Sanity check deployment parameters. - DeploymentParams memory params = _readEnvVariables(); + console.log("Deploying Wormhole Ntt..."); + IWormhole wh = IWormhole(wormhole); + + uint16 chainId = wh.chainId(); + + console.log("Chain ID: ", chainId); + + DeploymentParams memory params = DeploymentParams({ + token: token, + mode: mode, + wormholeChainId: chainId, + rateLimitDuration: 86400, + shouldSkipRatelimiter: false, + wormholeCoreBridge: wormhole, + wormholeRelayerAddr: wormholeRelayer, + specialRelayerAddr: specialRelayer, + consistencyLevel: 202, + gasLimit: 500000, + outboundLimit: uint256(type(uint64).max) * 10 ** 10 + }); // Deploy NttManager. address manager = deployNttManager(params); @@ -24,4 +57,27 @@ contract DeployWormholeNtt is Script, DeployWormholeNttBase { vm.stopBroadcast(); } + + function upgrade(address manager) public { + vm.startBroadcast(); + + NttManager nttManager = NttManager(manager); + + console.log("Upgrading manager..."); + + uint64 rateLimitDuration = nttManager.rateLimitDuration(); + bool shouldSkipRatelimiter = rateLimitDuration == 0; + + NttManager implementation = new NttManager( + nttManager.token(), + nttManager.mode(), + nttManager.chainId(), + nttManager.rateLimitDuration(), + shouldSkipRatelimiter + ); + + nttManager.upgrade(address(implementation)); + + vm.stopBroadcast(); + } } diff --git a/evm/script/helpers/DeployWormholeNttBase.sol b/evm/script/helpers/DeployWormholeNttBase.sol index f8e95a8ce..64c653e22 100644 --- a/evm/script/helpers/DeployWormholeNttBase.sol +++ b/evm/script/helpers/DeployWormholeNttBase.sol @@ -47,8 +47,7 @@ contract DeployWormholeNttBase is ParseNttConfig { nttManagerProxy.initialize(); - console2.log("NttManager deployed at: "); - console2.logBytes32(toUniversalAddress(address(nttManagerProxy))); + console2.log("NttManager:", address(nttManagerProxy)); return address(nttManagerProxy); } @@ -72,8 +71,7 @@ contract DeployWormholeNttBase is ParseNttConfig { transceiverProxy.initialize(); - console2.log("Wormhole Transceiver deployed at: "); - console2.logBytes32(toUniversalAddress(address(transceiverProxy))); + console2.log("WormholeTransceiver:", address(transceiverProxy)); return address(transceiverProxy); } From af3b6f5bfebde10170f23e4078de4f4bf88b51ab Mon Sep 17 00:00:00 2001 From: Csongor Kiss <kiss.csongor.kiss@gmail.com> Date: Thu, 27 Jun 2024 13:54:57 +0100 Subject: [PATCH 3/4] cli: implement ntt cli --- Dockerfile.cli | 50 ++ cli/example-overrides.json | 13 + cli/install.sh | 110 +++ cli/package.json | 9 +- cli/src/configuration.ts | 200 +++++ cli/src/diff.ts | 88 ++ cli/src/evmsigner.ts | 205 +++++ cli/src/getSigner.ts | 94 +++ cli/src/index.ts | 1623 ++++++++++++++++++++++++++++++++++-- cli/src/side-effects.ts | 38 + cli/src/tag.ts | 16 + cli/test/sepolia-bsc.sh | 91 ++ package-lock.json | 21 +- 13 files changed, 2494 insertions(+), 64 deletions(-) create mode 100644 Dockerfile.cli create mode 100644 cli/example-overrides.json create mode 100755 cli/install.sh create mode 100644 cli/src/configuration.ts create mode 100644 cli/src/diff.ts create mode 100644 cli/src/evmsigner.ts create mode 100644 cli/src/getSigner.ts create mode 100644 cli/src/side-effects.ts create mode 100644 cli/src/tag.ts create mode 100755 cli/test/sepolia-bsc.sh diff --git a/Dockerfile.cli b/Dockerfile.cli new file mode 100644 index 000000000..b29a332ee --- /dev/null +++ b/Dockerfile.cli @@ -0,0 +1,50 @@ +FROM ubuntu:latest as base + +RUN apt update + +RUN apt install -y python3 +RUN apt install -y build-essential +RUN apt install -y git +RUN apt install -y curl +RUN apt install -y unzip + +RUN curl -fsSL https://bun.sh/install | bash + +RUN curl -L https://foundry.paradigm.xyz | bash +RUN bash -ci "foundryup" + +RUN apt install -y jq + +FROM base as cli-remote +# NOTE: when invoking the installer outside of the source tree, it clones the +# repo and installs that way. +# This build stage tests that path. +COPY cli/install.sh cli/install.sh +RUN bash -ci "./cli/install.sh" +RUN bash -ci "which ntt" + +FROM base as cli-local +# NOTE: when invoking the installer inside of the source tree, it installs from +# the local source tree. +# This build stage tests that path. +WORKDIR /app +COPY tsconfig.json tsconfig.json +COPY package.json package.json +COPY package-lock.json package-lock.json +COPY sdk sdk +COPY solana/package.json solana/package.json +COPY solana/ts solana/ts +COPY solana/tsconfig.*.json solana/ +COPY cli/package.json cli/package.json +COPY cli/package-lock.json cli/package-lock.json +COPY cli/src cli/src +COPY cli/install.sh cli/install.sh +# COPY package.json . +# COPY cli/package.json . +RUN bash -ci "./cli/install.sh" +RUN bash -ci "which ntt" + +FROM cli-local as cli-local-test +COPY cli/test cli/test +COPY evm evm +RUN bash -ci "./cli/test/sepolia-bsc.sh" diff --git a/cli/example-overrides.json b/cli/example-overrides.json new file mode 100644 index 000000000..1c53ed49c --- /dev/null +++ b/cli/example-overrides.json @@ -0,0 +1,13 @@ +{ + "chains": { + "Bsc": { + "rpc": "http://127.0.0.1:8545" + }, + "Sepolia": { + "rpc": "http://127.0.0.1:8546" + }, + "Solana": { + "rpc": "http://127.0.0.1:8899" + } + } +} diff --git a/cli/install.sh b/cli/install.sh new file mode 100755 index 000000000..6e1b17f7f --- /dev/null +++ b/cli/install.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# check that 'bun' is installed + +if ! command -v bun > /dev/null; then + echo "bun is not installed. Follow the instructions at https://bun.sh/docs/installation" + exit 1 +fi + +function main { + path="" + + # check if there's a package.json in the parent directory, with "name": "@wormhole-foundation/ntt-cli" + if [ -f "$(dirname $0)/package.json" ] && grep -q '"name": "@wormhole-foundation/ntt-cli"' "$(dirname $0)/package.json"; then + path="$(dirname $0)/.." + else + # clone to $HOME/.ntt-cli if it doesn't exist, otherwise update it + repo_ref="$(select_repo)" + repo="$(echo "$repo_ref" | awk '{print $1}')" + ref="$(echo "$repo_ref" | awk '{print $2}')" + echo "Cloning $repo $ref" + + mkdir -p "$HOME/.ntt-cli" + path="$HOME/.ntt-cli/.checkout" + + if [ ! -d "$path" ]; then + git clone --branch "$ref" "$repo" "$path" + else + pushd "$path" + git fetch origin + # reset hard + git reset --hard "origin/$ref" + popd + fi + + fi + + install_cli "$path" +} + +# function that determines which repo to clone +function select_repo { + foundation_repo="https://github.com/wormhole-foundation/example-native-token-transfers.git" + labs_repo="https://github.com/wormholelabs-xyz/example-native-token-transfers.git" + # if the foundation repo has a tag of the form "vX.Y.Z+cli", use that (the latest one) + # otherwise we'll use the 'cli' branch from the labs repo + ref="" + repo="" + regex="refs/tags/v[0-9]*\.[0-9]*\.[0-9]*+cli" + if git ls-remote --tags "$foundation_repo" | grep -q "$regex"; then + repo="$foundation_repo" + ref="$(git ls-remote --tags "$foundation_repo" | grep "$regex" | sort -V | tail -n 1 | awk '{print $2}')" + else + repo="$labs_repo" + ref="cli" + fi + + echo "$repo $ref" +} + +# the above but as a function. takes a single argument: the path to the package.json file +# TODO: should just take the path to the repo root as an argument... +function install_cli { + cd "$1" + + # if 'ntt' is already installed, uninstall it + # just check with 'which' + if which ntt > /dev/null; then + echo "Removing existing ntt CLI" + rm $(which ntt) + fi + + # swallow the output of the first install + # TODO: figure out why it fails the first time. + bun install > /dev/null 2>&1 || true + bun install + + # make a temporary directory + + tmpdir="$(mktemp -d)" + + # create a temporary symlink 'npm' to 'bun' + + ln -s "$(command -v bun)" "$tmpdir/npm" + + # add the temporary directory to the PATH + + export PATH="$tmpdir:$PATH" + + # swallow the output of the first build + # TODO: figure out why it fails the first time. + bun --bun run --filter '*' build > /dev/null 2>&1 || true + bun --bun run --filter '*' build + + # remove the temporary directory + + rm -r "$tmpdir" + + # now link the CLI + + cd cli + + bun link + + bun link @wormhole-foundation/ntt-cli +} + +main diff --git a/cli/package.json b/cli/package.json index 1159597dc..a1926fc5a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,5 +1,6 @@ { - "name": "cli", + "name": "@wormhole-foundation/ntt-cli", + "version": "1.0.0-beta", "module": "src/index.ts", "type": "module", "devDependencies": { @@ -13,7 +14,7 @@ "ntt": "src/index.ts" }, "dependencies": { + "chalk": "^5.3.0", "yargs": "^17.7.2" - }, - "version": "0.1.0-beta.0" -} \ No newline at end of file + } +} diff --git a/cli/src/configuration.ts b/cli/src/configuration.ts new file mode 100644 index 000000000..77ea49783 --- /dev/null +++ b/cli/src/configuration.ts @@ -0,0 +1,200 @@ +import { assertChain, chains, type Chain } from "@wormhole-foundation/sdk"; +import * as yargs from "yargs"; +import fs from "fs"; +import { ensureNttRoot } from "."; +import chalk from "chalk"; + +// We support project-local and global configuration. +// The configuration is stored in JSON files in $HOME/.ntt-cli/config.json (global) and .ntt-cli/config.json (local). +// These can further be overridden by environment variables of the form CHAIN_KEY=value. +type Scope = "global" | "local"; + +type Config = { + chains: Partial<{ + [C in Chain]: ChainConfig; + }> +} + +type ChainConfig = Partial<typeof configTemplate>; + +// TODO: per-network configuration? (i.e. mainnet, testnet, etc) +const configTemplate = { + scan_api_key: "", +}; + +function assertChainConfigKey(key: string): asserts key is keyof ChainConfig { + const validKeys = Object.keys(configTemplate); + if (!validKeys.includes(key)) { + throw new Error(`Invalid key: ${key}`); + } +} + +const options = { + chain: { + describe: "Chain", + type: "string", + choices: chains, + demandOption: true, + }, + key: { + describe: "Key", + type: "string", + choices: Object.keys(configTemplate), + demandOption: true, + }, + value: { + describe: "Value", + type: "string", + demandOption: true, + }, + local: { + describe: "Use local configuration", + type: "boolean", + default: false, + }, + global: { + describe: "Use global configuration", + type: "boolean", + default: true, + } +} as const; +export const command = (args: yargs.Argv<{}>) => args + .command("set-chain <chain> <key> <value>", + "set a configuration value for a chain", + (yargs) => yargs + .positional("chain", options.chain) + .positional("key", options.key) + .positional("value", options.value) + .option("local", options.local) + .option("global", options.global), + (argv) => { + const scope = resolveScope(argv.local, argv.global); + assertChain(argv.chain); + assertChainConfigKey(argv.key); + setChainConfig(scope, argv.chain, argv.key, argv.value); + }) + .command("unset-chain <chain> <key>", + "unset a configuration value for a chain", + (yargs) => yargs + .positional("chain", options.chain) + .positional("key", options.key) + .option("local", options.local) + .option("global", options.global), + (argv) => { + const scope = resolveScope(argv.local, argv.global); + assertChainConfigKey(argv.key); + assertChain(argv.chain); + setChainConfig(scope, argv.chain, argv.key, undefined); + }) + .command("get-chain <chain> <key>", + "get a configuration value", + (yargs) => yargs + .positional("chain", options.chain) + .positional("key", options.key) + .option("local", options.local) + .option("global", options.global), + (argv) => { + const scope = resolveScope(argv.local, argv.global); + assertChainConfigKey(argv.key); + assertChain(argv.chain); + const val = getChainConfig(argv.scope as Scope, argv.chain, argv.key); + if (!val) { + console.error("undefined"); + } else { + console.log(val); + } + }) + .demandCommand() + +function findOrCreateConfigFile(scope: Scope): string { + // if scope is global, touch $HOME/.ntt-cli/config.json + // if scope is local, touch .ntt-cli/config.json. In the latter case, make sure we're in an ntt project (call ensureNttRoot()) + + // if the file doesn't exist, write an empty object + let configDir; + + switch (scope) { + case "global": + if (!process.env.HOME) { + throw new Error("Could not determine home directory"); + } + configDir = `${process.env.HOME}/.ntt-cli`; + break; + case "local": + ensureNttRoot(); + configDir = ".ntt-cli"; + break; + } + + const emptyConfig: Config = { + chains: {}, + }; + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir); + } + const configFile = `${configDir}/config.json`; + if (!fs.existsSync(configFile)) { + fs.writeFileSync(configFile, JSON.stringify(emptyConfig, null, 2)); + } + return configFile; +} + +function setChainConfig(scope: Scope, chain: Chain, key: keyof ChainConfig, value: string | undefined) { + const configFile = findOrCreateConfigFile(scope); + const config = JSON.parse(fs.readFileSync(configFile, "utf-8")) as Config; + if (!config.chains[chain]) { + config.chains[chain] = {}; + } + config.chains[chain]![key] = value; + fs.writeFileSync(configFile, JSON.stringify(config, null, 2)); +} + +function getChainConfig(scope: Scope, chain: Chain, key: keyof ChainConfig): string | undefined { + const configFile = findOrCreateConfigFile(scope); + const config = JSON.parse(fs.readFileSync(configFile, "utf-8")) as Config; + return config.chains[chain]?.[key]; +} + +function envVarName(chain: Chain, key: keyof ChainConfig): string { + return `${chain.toUpperCase()}_${key.toUpperCase()}`; +} + +export function get( + chain: Chain, + key: keyof ChainConfig, + { reportError = false } +): string | undefined { + const varName = envVarName(chain, key); + const env = process.env[varName]; + if (env) { + console.info(chalk.yellow(`Using ${varName} for ${chain} ${key}`)); + return env; + } + const local = getChainConfig("local", chain, key); + if (local) { + console.info(chalk.yellow(`Using local configuration for ${chain} ${key} (in .ntt-cli/config.json)`)); + return local; + } + const global = getChainConfig("global", chain, key); + if (global) { + console.info(chalk.yellow(`Using global configuration for ${chain} ${key} (in $HOME/.ntt-cli/config.json)`)); + return global; + } + if (reportError) { + console.error(`Could not find configuration for ${chain} ${key}`); + console.error(`Please set it using 'ntt config set-chain ${chain} ${key} <value>' or by setting the environment variable ${varName}`); + } +} +function resolveScope(local: boolean, global: boolean) { + if (local && global) { + throw new Error("Cannot specify both --local and --global"); + } + if (local) { + return "local"; + } + if (global) { + return "global"; + } + throw new Error("Must specify either --local or --global"); +} diff --git a/cli/src/diff.ts b/cli/src/diff.ts new file mode 100644 index 000000000..f0ba286f4 --- /dev/null +++ b/cli/src/diff.ts @@ -0,0 +1,88 @@ +import chalk from "chalk"; + +export type Diff<T> = { + push?: T; + pull?: T; +}; + + +// type that maps over the keys of an object (recursively), mapping each leaf type to Diff<T> +type DiffMap<T> = { + [K in keyof T]: T[K] extends object ? Partial<DiffMap<T[K]>> : Diff<T[K]> +} + +function isObject(obj: any): obj is Record<string, any> { + return obj && typeof obj === 'object' && !Array.isArray(obj); +} + +export function diffObjects<T extends Record<string, any>>(obj1: T, obj2: T): Partial<DiffMap<T>> { + const result: Partial<DiffMap<T>> = {}; + + for (const key in obj1) { + if (obj1.hasOwnProperty(key)) { + if (obj2.hasOwnProperty(key)) { + if (isObject(obj1[key]) && isObject(obj2[key])) { + result[key] = diffObjects(obj1[key], obj2[key]); + } else if (obj1[key] === obj2[key]) { + // result[key] = obj1[key] as any; + } else { + result[key] = { pull: obj2[key] , push: obj1[key]} as any; + } + } else { + result[key] = { push: obj1[key] } as any; + } + } + } + + for (const key in obj2) { + if (obj2.hasOwnProperty(key) && !obj1.hasOwnProperty(key)) { + result[key] = { pull: obj2[key] } as any; + } + } + + // prune empty objects + for (const key in result) { + if (isObject(result[key])) { + if (Object.keys(result[key]).length === 0) { + delete result[key]; + } + } + } + + return result; +} + +export function colorizeDiff(diff: any, indent = 2): string { + if (!isObject(diff)) return JSON.stringify(diff, null, indent); + + const jsonString = JSON.stringify(diff, null, indent); + let result = ''; + const lines = jsonString.split('\n'); + + for (const line of lines) { + const trimmedLine = line.trim(); + if (trimmedLine.startsWith('"') && trimmedLine.endsWith(': {')) { + const key = trimmedLine.slice(1, trimmedLine.indexOf('": {')); + if (isObject(diff[key]) && ('push' in diff[key] || 'pull' in diff[key])) { + const push = diff[key].push; + const pull = diff[key].pull; + if (push !== undefined && pull !== undefined) { + result += `${line}\n`; + } else if (push !== undefined) { + result += line.replace(trimmedLine, chalk.red(trimmedLine)) + '\n'; + } else if (pull !== undefined) { + result += line.replace(trimmedLine, chalk.green(trimmedLine)) + '\n'; + } + } else { + result += line + '\n'; + } + } else if (trimmedLine.startsWith('"push"') || trimmedLine.startsWith('"pull"')) { + const color = trimmedLine.startsWith('"push"') ? chalk.green : chalk.red; + result += line.replace(trimmedLine, color(trimmedLine)) + '\n'; + } else { + result += line + '\n'; + } + } + + return result; +} diff --git a/cli/src/evmsigner.ts b/cli/src/evmsigner.ts new file mode 100644 index 000000000..a476f9ab9 --- /dev/null +++ b/cli/src/evmsigner.ts @@ -0,0 +1,205 @@ +// NOTE: This file is a copy of the file from the wormhole-sdk package. The only +// change is messing with the gas parameters, because the original hardcoded +// values underpriced BSC testnet transactions, and they would get stuck in the mempool. +// +// Obviously this is a very short term stopgap. At the least, the sdk should +// probably support overriding the default gas parameters, but ideally it should +// be able to estimate the gas price and set it dynamically. (is that possible? idk) +// +// NOTE: we should now be able to use https://github.com/wormhole-foundation/wormhole-sdk-ts/pull/583 (thanks @ben) +import type { + Network, + SignOnlySigner, + SignedTx, + Signer, + UnsignedTransaction, +} from '@wormhole-foundation/sdk-connect'; +import { + PlatformNativeSigner, + chainToPlatform, + isNativeSigner, +} from '@wormhole-foundation/sdk-connect'; +import { + EvmPlatform, + type EvmChains, + _platform +} from '@wormhole-foundation/sdk-evm'; +import type { + Signer as EthersSigner, + Provider, + TransactionRequest, +} from 'ethers'; +import { NonceManager, Wallet } from 'ethers'; + +export async function getEvmSigner( + rpc: Provider, + key: string | EthersSigner, + opts?: { + maxGasLimit?: bigint; + chain?: EvmChains; + debug?: boolean; + }, +): Promise<Signer> { + const signer: EthersSigner = + typeof key === 'string' ? new Wallet(key, rpc) : key; + + const chain = opts?.chain ?? (await EvmPlatform.chainFromRpc(rpc))[1]; + const managedSigner = new NonceManager(signer); + + if (managedSigner.provider === null) { + try { + managedSigner.connect(rpc); + } catch (e) { + console.error('Cannot connect to network for signer', e); + } + } + + return new EvmNativeSigner( + chain, + await signer.getAddress(), + managedSigner, + opts, + ); +} + +// Get a SignOnlySigner for the EVM platform +export async function getEvmSignerForKey( + rpc: Provider, + privateKey: string, +): Promise<Signer> { + return getEvmSigner(rpc, privateKey); +} + +// Get a SignOnlySigner for the EVM platform +export async function getEvmSignerForSigner( + signer: EthersSigner, +): Promise<Signer> { + if (!signer.provider) throw new Error('Signer must have a provider'); + return getEvmSigner(signer.provider!, signer, {}); +} + +export class EvmNativeSigner<N extends Network, C extends EvmChains = EvmChains> + extends PlatformNativeSigner<EthersSigner, N, C> + implements SignOnlySigner<N, C> +{ + constructor( + _chain: C, + _address: string, + _signer: EthersSigner, + readonly opts?: { maxGasLimit?: bigint; debug?: boolean }, + ) { + super(_chain, _address, _signer); + } + + chain(): C { + return this._chain; + } + + address(): string { + return this._address; + } + + async sign(tx: UnsignedTransaction<N, C>[]): Promise<SignedTx[]> { + const chain = this.chain(); + + const signed = []; + + // default gas limit + const gasLimit = chain === 'ArbitrumSepolia' + ? 4_000_000n + : this.opts?.maxGasLimit ?? 500_000n; + + + // TODO: DIFF STARTS HERE + + let gasPrice = 200_000_000_000n; // 200gwei + let maxFeePerGas = 6_000_000_000n; // 6gwei + let maxPriorityFeePerGas = 1000_000_000n; // 1gwei + + // Celo does not support this call + if (chain !== 'Celo') { + const feeData = await this._signer.provider!.getFeeData(); + gasPrice = feeData.gasPrice ?? gasPrice; + maxFeePerGas = feeData.maxFeePerGas ?? maxFeePerGas; + maxPriorityFeePerGas = + feeData.maxPriorityFeePerGas ?? maxPriorityFeePerGas; + } + + // Oasis throws malformed errors unless we + // set it to use legacy transaction parameters + const gasOpts = + chain === 'Oasis' + ? { + gasLimit, + gasPrice: gasPrice, + // Hardcode type + type: 0, + } + : { + gasPrice, + maxFeePerGas, + maxPriorityFeePerGas, + gasLimit, + }; + + // TODO: DIFF ENDS HERE + + for (const txn of tx) { + const { transaction, description } = txn; + if (this.opts?.debug) + console.log(`Signing: ${description} for ${this.address()} (tx: ${transaction.to}})`); + + const t: TransactionRequest = { + ...transaction, + ...gasOpts, + from: this.address(), + nonce: await this._signer.getNonce(), + }; + + // try { + // const estimate = await this._signer.provider!.estimateGas(t); + // t.gasLimit = estimate + estimate / 10n; // Add 10% buffer + // if (this.opts?.maxGasLimit && t.gasLimit > this.opts?.maxGasLimit) { + // throw new Error( + // `Gas limit ${t.gasLimit} exceeds maxGasLimit ${this.opts?.maxGasLimit}`, + // ); + // } + // } catch (e) { + // console.info('Failed to estimate gas for transaction: ', e); + // console.info('Using gas limit: ', t.gasLimit); + // } + + signed.push(await this._signer.signTransaction(t)); + } + return signed; + } +} + +export function isEvmNativeSigner<N extends Network>( + signer: Signer<N>, +): signer is EvmNativeSigner<N> { + return ( + isNativeSigner(signer) && + chainToPlatform(signer.chain()) === _platform && + isEthersSigner(signer.unwrap()) + ); +} + +// No type guard provided by ethers, instanceof checks will fail on even slightly different versions of ethers +function isEthersSigner(thing: any): thing is EthersSigner { + return ( + 'provider' in thing && + typeof thing.connect === 'function' && + typeof thing.getAddress === 'function' && + typeof thing.getNonce === 'function' && + typeof thing.populateCall === 'function' && + typeof thing.populateTransaction === 'function' && + typeof thing.estimateGas === 'function' && + typeof thing.call === 'function' && + typeof thing.resolveName === 'function' && + typeof thing.signTransaction === 'function' && + typeof thing.sendTransaction === 'function' && + typeof thing.signMessage === 'function' && + typeof thing.signTypedData === 'function' + ); +} diff --git a/cli/src/getSigner.ts b/cli/src/getSigner.ts new file mode 100644 index 000000000..845f773ad --- /dev/null +++ b/cli/src/getSigner.ts @@ -0,0 +1,94 @@ +import solana from "@wormhole-foundation/sdk/platforms/solana"; +import * as myEvmSigner from "./evmsigner.js"; +import { ChainContext, Wormhole, chainToPlatform, type Chain, type ChainAddress, type Network, type Signer } from "@wormhole-foundation/sdk"; + +export type SignerType = "privateKey" | "ledger"; + +export type SignerSource = { + type: SignerType; + source: string; +}; + +// TODO: copied these from the examples. do they exist in the sdk? +export interface SignerStuff<N extends Network, C extends Chain> { + chain: ChainContext<N, C>; + signer: Signer<N, C>; + address: ChainAddress<C>; + source: SignerSource; +} + +// arguments to pass to `forge` +export function forgeSignerArgs( + source: SignerSource, +): string { + let signerArgs + switch (source.type) { + case "privateKey": + signerArgs = `--private-key ${source.source}`; + break; + case "ledger": + signerArgs = `--ledger --mnemonic-derivation-paths "${source.source}"`; + break; + default: + throw new Error("Unsupported signer type"); + } + return signerArgs; +} + +export async function getSigner<N extends Network, C extends Chain>( + chain: ChainContext<N, C>, + type: SignerType, + source?: string +): Promise<SignerStuff<N, C>> { + let signer: Signer; + const platform = chainToPlatform(chain.chain); + switch (platform) { + case "Solana": + switch (type) { + case "privateKey": + source = source ?? process.env.SOLANA_PRIVATE_KEY; + if (source === undefined) { + throw new Error("SOLANA_PRIVATE_KEY env var not set"); + } + signer = await solana.getSigner( + await chain.getRpc(), + source, + { debug: false } + ); + break; + case "ledger": + throw new Error("Ledger not yet supported on Solana"); + default: + throw new Error("Unsupported signer type"); + } + break; + case "Evm": + switch (type) { + case "privateKey": + source = source ?? process.env.ETH_PRIVATE_KEY; + if (source === undefined) { + throw new Error("ETH_PRIVATE_KEY env var not set"); + } + signer = await myEvmSigner.getEvmSigner( + await chain.getRpc(), + source, + { debug: true } + ); + break; + case "ledger": + throw new Error("Ledger not yet supported on Evm"); + default: + throw new Error("Unsupported signer type"); + } + break; + default: + throw new Error("Unrecognized platform: " + platform); + } + + return { + chain, + signer: signer as Signer<N, C>, + address: Wormhole.chainAddress(chain.chain, signer.address()), + source: { type, source } + }; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index dbd95ec8e..f42c52247 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -1,68 +1,708 @@ #!/usr/bin/env bun +import "./side-effects"; // doesn't quite work for silencing the bigint error message. why? +import evm from "@wormhole-foundation/sdk/platforms/evm"; +import solana from "@wormhole-foundation/sdk/platforms/solana"; +import { encoding } from '@wormhole-foundation/sdk-connect'; +import { execSync } from "child_process"; + +import evmDeployFile from "../../evm/script/DeployWormholeNtt.s.sol" with { type: "file" }; +import evmDeployFileHelper from "../../evm/script/helpers/DeployWormholeNttBase.sol" with { type: "file" }; + +import chalk from "chalk"; import yargs from "yargs"; import { $ } from "bun"; import { hideBin } from "yargs/helpers"; +import { Keypair, PublicKey } from "@solana/web3.js"; +import fs from "fs"; +import readline from "readline"; +import { ChainContext, UniversalAddress, Wormhole, assertChain, canonicalAddress, chainToPlatform, chains, isNetwork, networks, platforms, signSendWait, toUniversal, type AccountAddress, type Chain, type ChainAddress, type ConfigOverrides, type Network, type Platform } from "@wormhole-foundation/sdk"; +import "@wormhole-foundation/sdk-evm-ntt"; +import "@wormhole-foundation/sdk-solana-ntt"; +import "@wormhole-foundation/sdk-definitions-ntt"; +import type { Ntt, NttTransceiver } from "@wormhole-foundation/sdk-definitions-ntt"; + +import { type SolanaChains } from "@wormhole-foundation/sdk-solana"; -type Network = "mainnet" | "testnet" | "devnet"; +import { colorizeDiff, diffObjects } from "./diff"; +import { forgeSignerArgs, getSigner, type SignerType } from "./getSigner"; +import { NTT, SolanaNtt } from "@wormhole-foundation/sdk-solana-ntt"; +import type { EvmNtt, EvmNttWormholeTranceiver } from "@wormhole-foundation/sdk-evm-ntt"; +import type { EvmChains } from "@wormhole-foundation/sdk-evm"; +import { getAvailableVersions, getGitTagName } from "./tag"; +import * as configuration from "./configuration"; -// TODO: grab this from sdkv2 -export function assertNetwork(n: string): asserts n is Network { - if (n !== "mainnet" && n !== "testnet" && n !== "devnet") { - throw Error(`Unknown network: ${n}`); +// TODO: pauser (evm) +// TODO: contract upgrades on solana +// TODO: set special relaying? +// TODO: currently, we just default all evm chains to standard relaying. should we not do that? what's a good way to configure this? + +// TODO: check if manager can mint the token in burning mode (on solana it's +// simple. on evm we need to simulate with prank) +const overrides: ConfigOverrides<Network> = (function () { + // read overrides.json file if exists + if (fs.existsSync("overrides.json")) { + console.log(chalk.yellow("Using overrides.json")); + return JSON.parse(fs.readFileSync("overrides.json").toString()); + } else { + return {}; } +})(); + +export type Deployment<C extends Chain> = { + ctx: ChainContext<Network, C>, + ntt: Ntt<Network, C>, + whTransceiver: NttTransceiver<Network, C, Ntt.Attestation>, + decimals: number, + manager: ChainAddress<C>, + config: { + remote?: ChainConfig, + local?: ChainConfig, + }, } -export const NETWORK_OPTIONS = { - alias: "n", - describe: "Network", - choices: ["mainnet", "testnet", "devnet"], - demandOption: true, +// TODO: rename +export type ChainConfig = { + version: string, + mode: Ntt.Mode, + paused: boolean, + owner: string, + manager: string, + token: string, + transceivers: { + threshold: number, + wormhole: string, + }, + limits: { + outbound: string, + inbound: Partial<{ [C in Chain]: string }>, + } +} + +export type Config = { + network: Network, + chains: Partial<{ + [C in Chain]: ChainConfig + }>, + defaultLimits?: { + outbound: string, + } +} + +const options = { + network: { + alias: "n", + describe: "Network", + choices: networks, + demandOption: true, + }, + deploymentPath: { + alias: "p", + describe: "Path to the deployment file", + default: "deployment.json", + type: "string", + }, + yes: { + alias: "y", + describe: "Skip confirmation", + type: "boolean", + default: false, + }, + signerType: { + alias: "s", + describe: "Signer type", + type: "string", + choices: ["privateKey", "ledger"], + default: "privateKey", + }, + verbose: { + alias: "v", + describe: "Verbose output", + type: "boolean", + default: false, + }, + chain: { + describe: "Chain", + type: "string", + choices: chains, + demandOption: true, + }, + address: { + describe: "Address", + type: "string", + demandOption: true, + }, + local: { + describe: "Use the current local version for deployment (advanced).", + type: "boolean", + default: false, + }, + version: { + describe: "Version of NTT to deploy", + type: "string", + demandOption: false, + }, + latest: { + describe: "Use the latest version", + type: "boolean", + default: false, + }, + platform: { + describe: "Platform", + type: "string", + choices: platforms, + demandOption: true, + }, + skipVerify: + { + describe: "Skip contract verification", + type: "boolean", + default: false, + } } as const; + +// TODO: this is a temporary hack to allow deploying from main (as we only need +// the changes to the evm script) +async function withCustomEvmDeployerScript<A>(pwd: string, then: () => Promise<A>): Promise<A> { + ensureNttRoot(pwd); + const overrides = [ + { path: `${pwd}/evm/script/DeployWormholeNtt.s.sol`, with: evmDeployFile }, + { path: `${pwd}/evm/script/helpers/DeployWormholeNttBase.sol`, with: evmDeployFileHelper }, + ] + for (const { path, with: withFile } of overrides) { + const old = `${path}.old`; + if (fs.existsSync(path)) { + fs.copyFileSync(path, old); + } + fs.copyFileSync(withFile, path); + } + try { + return await then() + } finally { + // restore old files + for (const { path } of overrides) { + const old = `${path}.old`; + if (fs.existsSync(old)) { + fs.copyFileSync(old, path); + fs.unlinkSync(old); + } + } + } +} + yargs(hideBin(process.argv)) + .wrap(Math.min(process.stdout.columns || 120, 160)) // Use terminal width, but no more than 160 characters .scriptName("ntt") - .command( - "solana", + // config group of commands + .command("config", + "configuration commands", + configuration.command + ) + // new + .command("new <path>", + "create a new NTT project", + (yargs) => yargs + .positional("path", { + describe: "Path to the project", + type: "string", + demandOption: true, + }) + .example("$0 new my-ntt-project", "Create a new NTT project in the 'my-ntt-project' directory"), + async (argv) => { + const git = execSync("git rev-parse --is-inside-work-tree || echo false", { + stdio: ["inherit", null, null] + }); + if (git.toString().trim() === "true") { + console.error("Already in a git repository"); + process.exit(1); + } + const path = argv["path"]; + await $`git clone -b main https://github.com/wormhole-foundation/example-native-token-transfers.git ${path}`; + }) + .command("add-chain <chain>", + "add a chain to the deployment file", + (yargs) => yargs + .positional("chain", options.chain) + // TODO: add ability to specify manager address (then just pull the config) + // .option("manager", { + // describe: "Manager address", + // type: "string", + // }) + .option("program-key", { + describe: "Path to program key json (Solana)", + type: "string", + }) + .option("payer", { + describe: "Path to payer key json (Solana)", + type: "string", + }) + .option("binary", { + describe: "Path to program binary (.so file -- Solana)", + type: "string", + }) + .option("token", { + describe: "Token address", + type: "string", + }) + .option("mode", { + alias: "m", + describe: "Mode", + type: "string", + choices: ["locking", "burning"], + }) + .option("signer-type", options.signerType) + .option("skip-verify", options.skipVerify) + .option("ver", options.version) + .option("latest", options.latest) + .option("local", options.local) + .option("path", options.deploymentPath) + .option("yes", options.yes) + .example("$0 add-chain Ethereum --token 0x1234... --mode burning --latest", "Add Ethereum chain with the latest contract version in burning mode") + .example("$0 add-chain Solana --token Sol1234... --mode locking --ver 1.0.0", "Add Solana chain with a specific contract version in locking mode") + .example("$0 add-chain Avalanche --token 0xabcd... --mode burning --local", "Add Avalanche chain using the local contract version"), + async (argv) => { + const path = argv["path"]; + const deployments: Config = loadConfig(path); + const chain: Chain = argv["chain"]; + const version = resolveVersion(argv["latest"], argv["ver"], argv["local"], chainToPlatform(chain)); + let mode = argv["mode"] as Ntt.Mode | undefined; + const signerType = argv["signer-type"] as SignerType; + const token = argv["token"]; + const network = deployments.network as Network; + + if (chain in deployments.chains) { + console.error(`Chain ${chain} already exists in ${path}`); + process.exit(1); + } + + validateChain(network, chain); + + const existsLocking = Object.values(deployments.chains).some((c) => c.mode === "locking"); + + if (existsLocking) { + if (mode && mode === "locking") { + console.error("Only one locking chain is allowed"); + process.exit(1); + } + mode = "burning"; + } + + if (!mode) { + console.error("Mode is required (use --mode)"); + process.exit(1); + } + + if (!token) { + console.error("Token is required (use --token)"); + process.exit(1); + } + + // let's deploy + + // TODO: factor out to function to get chain context + const wh = new Wormhole(network, [solana.Platform, evm.Platform], overrides); + const ch = wh.getChain(chain); + + // TODO: make manager configurable + const deployedManager = await deploy(version, mode, ch, token, signerType, !argv["skip-verify"], argv["yes"], argv["payer"], argv["program-key"], argv["binary"]); + + const [config, _ctx, _ntt, decimals] = + await pullChainConfig(network, deployedManager, overrides); + + console.log("token decimals:", chalk.yellow(decimals)); + + deployments.chains[chain] = config; + fs.writeFileSync(path, JSON.stringify(deployments, null, 2)); + console.log(`Added ${chain} to ${path}`); + }) + .command("upgrade <chain>", + "upgrade the contract on a specific chain", + (yargs) => yargs + .positional("chain", options.chain) + .option("ver", options.version) + .option("latest", { + describe: "Use the latest version", + type: "boolean", + default: false, + }) + .option("local", options.local) + .option("signer-type", options.signerType) + .option("skip-verify", options.skipVerify) + .option("path", options.deploymentPath) + .option("yes", options.yes) + .option("payer", { + describe: "Path to payer key json (Solana)", + type: "string", + }) + .option("program-key", { + describe: "Path to program key json (Solana)", + type: "string", + }) + .option("binary", { + describe: "Path to program binary (.so file -- Solana)", + type: "string", + }) + .example("$0 upgrade Ethereum --latest", "Upgrade the Ethereum contract to the latest version") + .example("$0 upgrade Solana --ver 1.1.0", "Upgrade the Solana contract to version 1.1.0") + .example("$0 upgrade Polygon --local --skip-verify", "Upgrade the Polygon contract using the local version, skipping explorer bytecode verification"), + async (argv) => { + const path = argv["path"]; + const deployments: Config = loadConfig(path); + const chain: Chain = argv["chain"]; + const signerType = argv["signer-type"] as SignerType; + const network = deployments.network as Network; + + if (!(chain in deployments.chains)) { + console.error(`Chain ${chain} not found in ${path}`); + process.exit(1); + } + + const chainConfig = deployments.chains[chain]!; + const currentVersion = chainConfig.version; + const platform = chainToPlatform(chain); + + const toVersion = resolveVersion(argv["latest"], argv["ver"], argv["local"], platform); + + if (argv["local"]) { + await warnLocalDeployment(argv["yes"]); + } + + if (toVersion === currentVersion && !argv["local"]) { + console.log(`Chain ${chain} is already at version ${currentVersion}`); + process.exit(0); + } + + console.log(`Upgrading ${chain} from version ${currentVersion} to ${toVersion || 'local version'}`); + + if (!argv["yes"]) { + await askForConfirmation(); + } + + const wh = new Wormhole(network, [solana.Platform, evm.Platform], overrides); + const ch = wh.getChain(chain); + + const [_, ctx, ntt] = await pullChainConfig( + network, + { chain, address: toUniversal(chain, chainConfig.manager) }, + overrides + ); + + await upgrade( + currentVersion, + toVersion, + ntt, + ctx, + signerType, + !argv["skip-verify"], + argv["payer"], + argv["program-key"], + argv["binary"] + ); + + // reinit the ntt object to get the new version + // TODO: is there an easier way to do this? + const { ntt: upgraded } = await nttFromManager(ch, chainConfig.manager); + + chainConfig.version = getVersion(chain, upgraded) + fs.writeFileSync(path, JSON.stringify(deployments, null, 2)); + + console.log(`Successfully upgraded ${chain} to version ${toVersion || 'local version'}`); + } + ) + .command("clone <network> <chain> <address>", + "initialize a deployment file from an existing contract", + (yargs) => yargs + .positional("network", options.network) + .positional("chain", options.chain) + .positional("address", options.address) + .option("path", options.deploymentPath) + .option("verbose", options.verbose) + .example("$0 clone Testnet Ethereum 0x5678...", "Clone an existing Ethereum deployment on Testnet") + .example("$0 clone Mainnet Solana Sol5678... --path custom-clone.json", "Clone an existing Solana deployment on Mainnet to a custom file"), + async (argv) => { + if (!isNetwork(argv["network"])) { + console.error("Invalid network"); + process.exit(1); + } + + const path = argv["path"]; + const verbose = argv["verbose"]; + // check if the file exists + if (fs.existsSync(path)) { + console.error(`Deployment file already exists at ${path}`); + process.exit(1); + } + + // step 1. grab the config + // step 2. discover registrations + // step 3. grab registered peer configs + // + // NOTE: we don't recursively grab peer configs. This means the + // discovered peers will be the ones that are directly registered with + // the starting manager (the one we're cloning). + // For example, if we're cloning manager A, and it's registered with + // B, and B is registered with C, but C is not registered with A, then + // C will not be included in the cloned deployment. + // We could do peer discovery recursively but that would be a lot + // slower, since peer discovery is already O(n) in the number of + // supported chains (50+), because there is no way to enumerate the peers, so we + // need to query all possible chains to see if they're registered. + + const chain = argv["chain"]; + assertChain(chain) + + const manager = argv["address"]; + const network = argv["network"]; + + const universalManager = toUniversal(chain, manager); + + const ntts: Partial<{ [C in Chain]: Ntt<Network, C> }> = {}; + + const [config, _ctx, ntt, _decimals] = + await pullChainConfig(network, { chain, address: universalManager }, overrides); + + ntts[chain] = ntt as any; + + const configs: Partial<{ [C in Chain]: ChainConfig }> = { + [chain]: config, + } + + // discover peers + let count = 0; + for (const c of chains) { + process.stdout.write(`[${count}/${chains.length - 1}] Fetching peer config for ${c}`); + await new Promise((resolve) => setTimeout(resolve, 100)); + count++; + + const peer = await retryWithExponentialBackoff(() => ntt.getPeer(c), 5, 5000); + + process.stdout.write(`\r`); + if (peer === null) { + continue; + } + const address: UniversalAddress = peer.address.address.toUniversalAddress() + const [peerConfig, _ctx, peerNtt] = await pullChainConfig(network, { chain: c, address }, overrides); + ntts[c] = peerNtt as any; + configs[c] = peerConfig; + } + + // sort chains by name + const sorted = Object.fromEntries(Object.entries(configs).sort(([a], [b]) => a.localeCompare(b))); + + // sleep for a bit to avoid rate limiting when making the getDecimals call + // this can happen when the last we hit the rate limit just in the last iteration of the loop above. + // (happens more often than you'd think, because the rate limiter + // gets more aggressive after each hit) + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // now loop through the chains, and query their peer information to get the inbound limits + await pullInboundLimits(ntts, sorted, verbose) + + const deployment: Config = { + network: argv["network"], + chains: sorted, + }; + fs.writeFileSync(path, JSON.stringify(deployment, null, 2)); + }) + .command("init <network>", + "initialize a deployment file", + (yargs) => yargs + .positional("network", options.network) + .option("path", options.deploymentPath) + .example("$0 init Testnet", "Initialize a new deployment file for the Testnet network") + .example("$0 init Mainnet --path custom.json", "Initialize a new deployment file for Mainnet with a custom file name"), + async (argv) => { + if (!isNetwork(argv["network"])) { + console.error("Invalid network"); + process.exit(1); + } + const deployment = { + network: argv["network"], + chains: {}, + }; + const path = argv["path"]; + // check if the file exists + if (fs.existsSync(path)) { + console.error(`Deployment file already exists at ${path}. Specify a different path with --path`); + process.exit(1); + } + fs.writeFileSync(path, JSON.stringify(deployment, null, 2)); + }) + .command("pull", + "pull the remote configuration", + (yargs) => yargs + .option("path", options.deploymentPath) + .option("yes", options.yes) + .option("verbose", options.verbose) + .example("$0 pull", "Pull the latest configuration from the blockchain for all chains") + .example("$0 pull --yes", "Pull the latest configuration and apply changes without confirmation"), + async (argv) => { + const deployments: Config = loadConfig(argv["path"]); + const verbose = argv["verbose"]; + const network = deployments.network as Network; + const path = argv["path"]; + const deps: Partial<{ [C in Chain]: Deployment<Chain> }> = await pullDeployments(deployments, network, verbose); + + let changed = false; + for (const [chain, deployment] of Object.entries(deps)) { + assertChain(chain); + const diff = diffObjects(deployments.chains[chain]!, deployment.config.remote!); + if (Object.keys(diff).length !== 0) { + console.error(chalk.reset(colorizeDiff({ [chain]: diff }))); + changed = true; + deployments.chains[chain] = deployment.config.remote! + } + } + if (!changed) { + console.log(`${path} is already up to date`); + process.exit(0); + } + + if (!argv["yes"]) { + await askForConfirmation(); + } + fs.writeFileSync(path, JSON.stringify(deployments, null, 2)); + console.log(`Updated ${path}`); + }) + .command("push", + "push the local configuration", + (yargs) => yargs + .option("path", options.deploymentPath) + .option("yes", options.yes) + .option("signer-type", options.signerType) + .option("verbose", options.verbose) + .option("skip-verify", options.skipVerify) + .example("$0 push", "Push local configuration changes to the blockchain") + .example("$0 push --signer-type ledger", "Push changes using a Ledger hardware wallet for signing") + .example("$0 push --skip-verify", "Push changes without verifying contracts on EVM chains"), + async (argv) => { + const deployments: Config = loadConfig(argv["path"]); + const verbose = argv["verbose"]; + const network = deployments.network as Network; + const deps: Partial<{ [C in Chain]: Deployment<Chain> }> = await pullDeployments(deployments, network, verbose); + const signerType = argv["signer-type"] as SignerType; + + const missing = await missingConfigs(deps, verbose); + + for (const [chain, missingConfig] of Object.entries(missing)) { + assertChain(chain); + const ntt = deps[chain]!.ntt; + const ctx = deps[chain]!.ctx; + const signer = await getSigner(ctx, signerType) + for (const manager of missingConfig.managerPeers) { + const tx = ntt.setPeer(manager.address, manager.tokenDecimals, manager.inboundLimit, signer.address.address) + await signSendWait(ctx, tx, signer.signer) + } + for (const transceiver of missingConfig.transceiverPeers) { + const tx = ntt.setWormholeTransceiverPeer(transceiver, signer.address.address) + await signSendWait(ctx, tx, signer.signer) + } + for (const evmChain of missingConfig.evmChains) { + const tx = (await ntt.getTransceiver(0) as EvmNttWormholeTranceiver<Network, EvmChains>).setIsEvmChain(evmChain, true) + await signSendWait(ctx, tx, signer.signer) + } + for (const relaying of missingConfig.standardRelaying) { + const tx = (await ntt.getTransceiver(0) as EvmNttWormholeTranceiver<Network, EvmChains>).setIsWormholeRelayingEnabled(relaying, true) + await signSendWait(ctx, tx, signer.signer) + } + } + + // pull deps again + const depsAfterRegistrations: Partial<{ [C in Chain]: Deployment<Chain> }> = await pullDeployments(deployments, network, verbose); + + for (const [chain, deployment] of Object.entries(depsAfterRegistrations)) { + assertChain(chain); + await pushDeployment(deployment as any, signerType, !argv["skip-verify"], argv["yes"]); + } + }) + .command("status", + "check the status of the deployment", + (yargs) => yargs + .option("path", options.deploymentPath) + .option("verbose", options.verbose) + .example("$0 status", "Check the status of the deployment across all chains") + .example("$0 status --verbose", "Check the status with detailed output"), + async (argv) => { + const path = argv["path"]; + const verbose = argv["verbose"]; + // TODO: I don't like the variable names here + const deployments: Config = loadConfig(path); + + const network = deployments.network as Network; + + let deps: Partial<{ [C in Chain]: Deployment<Chain> }> = await pullDeployments(deployments, network, verbose); + + let errors = 0; + + // diff remote and local configs + for (const [chain, deployment] of Object.entries(deps)) { + assertChain(chain); + const local = deployment.config.local; + const remote = deployment.config.remote; + const a = { [chain]: local! }; + const b = { [chain]: remote! }; + + const diff = diffObjects(a, b); + if (Object.keys(diff).length !== 0) { + console.error(chalk.reset(colorizeDiff(diff))); + errors++; + } + + if (verbose) { + const immutables = await getImmutables(chain, deployment.ntt); + if (immutables) { + console.log(JSON.stringify({ [chain]: immutables }, null, 2)) + } + } + } + + // verify peers + const missing = await missingConfigs(deps, verbose); + + if (Object.keys(missing).length > 0) { + errors++; + } + + for (const [chain, peers] of Object.entries(missing)) { + console.error(`Peer errors for ${chain}:`); + for (const manager of peers.managerPeers) { + console.error(` Missing manager peer: ${manager.address.chain}`); + } + for (const transceiver of peers.transceiverPeers) { + console.error(` Missing transceiver peer: ${transceiver.chain}`); + } + for (const evmChain of peers.evmChains) { + console.error(` Missing EVM chain: ${evmChain}`); + } + for (const relaying of peers.standardRelaying) { + console.warn(` No standard relaying: ${relaying}`); + } + } + + if (errors > 0) { + console.error("Run `ntt pull` to pull the remote configuration (overwriting the local one)"); + console.error("Run `ntt push` to push the local configuration (overwriting the remote one) by executing the necessary transactions"); + process.exit(1); + } else { + console.log(`${path} is up to date with the on-chain configuration.`); + process.exit(0); + } + }) + .command("solana", "Solana commands", (yargs) => { yargs - .command( - "deploy", - "deploy the solana program", - (yargs) => yargs.option("network", NETWORK_OPTIONS), - (argv) => { - throw new Error("Not implemented"); - }) - .command( - "upgrade", - "upgrade the solana program", + .command("key-base58 <keypair>", + "print private key in base58", (yargs) => yargs - .option("network", NETWORK_OPTIONS) - .option("dir", { - alias: "d", - describe: "Path to the solana workspace", - default: ".", - demandOption: false, + .positional("keypair", { + describe: "Path to keypair.json", type: "string", - }) - .option("keypair", { - alias: "k", - describe: "Path to the keypair", demandOption: true, - type: "string", }), - async (argv) => { - // TODO: the hardcoded stuff should be factored out once - // we support other networks and programs - // TODO: currently the keypair is the upgrade authority. we should support governance program too - const network = argv.network; - const keypair = argv.keypair; - const dir = argv.dir; - const objectFile = "example_native_token_transfers.so"; - const programId = "nttiK1SepaQt6sZ4WGW5whvc9tEnGXGxuKeptcQPCcS"; - assertNetwork(network); - await $`cargo build-sbf --manifest-path=${dir}/Cargo.toml --no-default-features --features "${cargoNetworkFeature(network)}"` - await $`solana program deploy --program-id ${programId} ${dir}/target/deploy/${objectFile} --keypair ${keypair} -u ${solanaMoniker(network)}` + (argv) => { + const keypair = Keypair.fromSecretKey(new Uint8Array(JSON.parse(fs.readFileSync(argv["keypair"]).toString()))); + console.log(encoding.b58.encode(keypair.secretKey)); }) .demandCommand() } @@ -72,25 +712,896 @@ yargs(hideBin(process.argv)) .demandCommand() .parse(); +// Implicit configuration that's missing from a contract deployment. These are +// implicit in the sense that they don't need to be explicitly set in the +// deployment file. +// For example, all managers and transceivers need to be registered with each other. +// Additionally, the EVM chains need to be registered as such, and the standard relaying +// needs to be enabled for all chains where this is supported. +type MissingImplicitConfig = { + managerPeers: Ntt.Peer<Chain>[]; + transceiverPeers: ChainAddress<Chain>[]; + evmChains: Chain[]; + standardRelaying: Chain[]; +} + +function createWorkTree(platform: Platform, version: string): string { + const tag = getGitTagName(platform, version); + if (!tag) { + console.error(`No tag found matching ${version} for ${platform}`); + process.exit(1); + } + + const worktreeName = `.deployments/${platform}-${version}`; + + if (fs.existsSync(worktreeName)) { + console.log(chalk.yellow(`Worktree already exists at ${worktreeName}. Resetting to ${tag}`)); + execSync(`git -C ${worktreeName} reset --hard ${tag}`, { + stdio: "inherit" + }); + } else { + // create worktree + execSync(`git worktree add ${worktreeName} ${tag}`, { + stdio: "inherit" + }); + } + + // NOTE: we create this symlink whether or not the file exists. + // this way, if it's created later, the symlink will be correct + execSync(`ln -fs $(pwd)/overrides.json $(pwd)/${worktreeName}/overrides.json`, { + stdio: "inherit" + }); + + console.log(chalk.green(`Created worktree at ${worktreeName} from tag ${tag}`)); + return worktreeName; +} + +async function upgrade<N extends Network, C extends Chain>( + _fromVersion: string, + toVersion: string | null, + ntt: Ntt<N, C>, + ctx: ChainContext<N, C>, + signerType: SignerType, + evmVerify: boolean, + solanaPayer?: string, + solanaProgramKeyPath?: string, + solanaBinaryPath?: string +): Promise<void> { + // TODO: check that fromVersion is safe to upgrade to toVersion from + const platform = chainToPlatform(ctx.chain); + const worktree = toVersion ? createWorkTree(platform, toVersion) : "."; + switch (platform) { + case "Evm": + const evmNtt = ntt as EvmNtt<N, EvmChains>; + const evmCtx = ctx as ChainContext<N, EvmChains>; + return upgradeEvm(worktree, evmNtt, evmCtx, signerType, evmVerify); + case "Solana": + if (solanaPayer === undefined || !fs.existsSync(solanaPayer)) { + console.error("Payer not found. Specify with --payer"); + process.exit(1); + } + const solanaNtt = ntt as SolanaNtt<N, SolanaChains>; + return upgradeSolana(solanaNtt, solanaPayer, solanaProgramKeyPath, solanaBinaryPath); + default: + throw new Error("Unsupported platform"); + } +} + +async function upgradeEvm<N extends Network, C extends EvmChains>( + pwd: string, + ntt: EvmNtt<N, C>, + ctx: ChainContext<N, C>, + signerType: SignerType, + evmVerify: boolean +): Promise<void> { + ensureNttRoot(pwd); + + console.log("Upgrading EVM chain", ctx.chain); + + const signer = await getSigner(ctx, signerType); + const signerArgs = forgeSignerArgs(signer.source); + + console.log("Installing forge dependencies...") + execSync("forge install", { + cwd: `${pwd}/evm`, + stdio: "pipe" + }); + + let verifyArgs: string = ""; + if (evmVerify) { + // TODO: verify etherscan api key? + const etherscanApiKey = configuration.get(ctx.chain, "scan_api_key", { reportError: true }) + if (!etherscanApiKey) { + process.exit(1); + } + verifyArgs = `--verify --etherscan-api-key ${etherscanApiKey}`; + } + + console.log("Upgrading manager..."); + await withCustomEvmDeployerScript(pwd, async () => { + execSync( + `forge script --via-ir script/DeployWormholeNtt.s.sol \ +--rpc-url ${ctx.config.rpc} \ +--sig "upgrade(address)" \ +${ntt.managerAddress} \ +${signerArgs} \ +--broadcast \ +${verifyArgs} | tee last-run.stdout`, { + cwd: `${pwd}/evm`, + stdio: "inherit" + }); + }); + +} + +async function upgradeSolana<N extends Network, C extends SolanaChains>( + _ntt: Ntt<N, C>, + _payer: string, + _programKeyPath?: string, + _binaryPath?: string +): Promise<void> { + throw new Error("Not implemented"); +} + +async function deploy<N extends Network, C extends Chain>( + version: string | null, + mode: Ntt.Mode, + ch: ChainContext<N, C>, + token: string, + signerType: SignerType, + evmVerify: boolean, + yes: boolean, + solanaPayer?: string, + solanaProgramKeyPath?: string, + solanaBinaryPath?: string, +): Promise<ChainAddress<C>> { + if (version === null) { + await warnLocalDeployment(yes); + } + const platform = chainToPlatform(ch.chain); + const worktree = version ? createWorkTree(platform, version) : "."; + switch (platform) { + case "Evm": + return await deployEvm(worktree, mode, ch, token, signerType, evmVerify); + case "Solana": + if (solanaPayer === undefined || !fs.existsSync(solanaPayer)) { + console.error("Payer not found. Specify with --payer"); + process.exit(1); + } + const solanaCtx = ch as ChainContext<N, SolanaChains>; + return await deploySolana(worktree, mode, solanaCtx, token, solanaPayer, solanaProgramKeyPath, solanaBinaryPath) as ChainAddress<C>; + default: + throw new Error("Unsupported platform"); + } +} + +async function deployEvm<N extends Network, C extends Chain>( + pwd: string, + mode: Ntt.Mode, + ch: ChainContext<N, C>, + token: string, + signerType: SignerType, + verify: boolean, +): Promise<ChainAddress<C>> { + ensureNttRoot(pwd); + + const wormhole = ch.config.contracts.coreBridge; + if (!wormhole) { + console.error("Core bridge not found"); + process.exit(1); + } + const relayer = ch.config.contracts.relayer; + if (!relayer) { + console.error("Relayer not found"); + process.exit(1); + } + + const rpc = ch.config.rpc; + const specialRelayer = "0x63BE47835c7D66c4aA5B2C688Dc6ed9771c94C74"; // TODO: how to configure this? + + // TODO: should actually make these ENV variables. + const sig = "run(address,address,address,address,uint8)"; + const modeUint = mode === "locking" ? 0 : 1; + const signer = await getSigner(ch, signerType); + const signerArgs = forgeSignerArgs(signer.source); + + // TODO: verify etherscan api key? + let verifyArgs: string[] = []; + if (verify) { + const etherscanApiKey = configuration.get(ch.chain, "scan_api_key", { reportError: true }) + if (!etherscanApiKey) { + process.exit(1); + } + verifyArgs = ["--verify", "--etherscan-api-key", etherscanApiKey] + } + + console.log("Installing forge dependencies...") + execSync("forge install", { + cwd: `${pwd}/evm`, + stdio: "pipe" + }); + + console.log("Deploying manager..."); + await withCustomEvmDeployerScript(pwd, async () => { + try { + execSync(` +forge script --via-ir script/DeployWormholeNtt.s.sol \ +--rpc-url ${rpc} \ +--sig "${sig}" ${wormhole} ${token} ${relayer} ${specialRelayer} ${modeUint} \ +--broadcast ${verifyArgs.join(' ')} ${signerArgs} | tee last-run.stdout`, { + cwd: `${pwd}/evm`, + encoding: 'utf8', + stdio: 'inherit' + }); + } catch (error) { + console.error("Failed to deploy manager"); + // NOTE: we don't exit here. instead, we check if the manager was + // deployed successfully (below) and proceed if it was. + // process.exit(1); + } + }); + const out = fs.readFileSync(`${pwd}/evm/last-run.stdout`).toString(); + if (!out) { + console.error("Failed to deploy manager"); + process.exit(1); + } + const logs = out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0); + const manager = logs.find((l) => l.includes("NttManager: 0x"))?.split(" ")[1]; + if (!manager) { + console.error("Manager not found"); + process.exit(1); + } + const universalManager = toUniversal(ch.chain, manager); + return { chain: ch.chain, address: universalManager }; +} + +async function deploySolana<N extends Network, C extends SolanaChains>( + pwd: string, + mode: Ntt.Mode, + ch: ChainContext<N, C>, + token: string, + payer: string, + managerKeyPath?: string, + binaryPath?: string +): Promise<ChainAddress<C>> { + ensureNttRoot(pwd); + + const wormhole = ch.config.contracts.coreBridge; + if (!wormhole) { + console.error("Core bridge not found"); + process.exit(1); + } + + // grep example_native_token_transfers = ".*" + // in solana/Anchor.toml + // TODO: what if they rename the program? + const existingProgramId = fs.readFileSync("solana/Anchor.toml").toString().match(/example_native_token_transfers = "(.*)"/)?.[1]; + if (!existingProgramId) { + console.error("Program ID not found in Anchor.toml (looked for example_native_token_transfers = \"(.*)\")"); + process.exit(1); + } + + let programKeypairPath; + let programKeypair; + + if (managerKeyPath) { + if (!fs.existsSync(managerKeyPath)) { + console.error(`Program keypair not found: ${managerKeyPath}`); + process.exit(1); + } + programKeypairPath = managerKeyPath; + programKeypair = Keypair.fromSecretKey(new Uint8Array(JSON.parse(fs.readFileSync(managerKeyPath).toString()))); + } else { + const programKeyJson = `${existingProgramId}.json`; + if (!fs.existsSync(programKeyJson)) { + console.error(`Program keypair not found: ${programKeyJson}`); + console.error("Run `solana-keygen` to create a new keypair (either with 'new', or with 'grind'), and pass it to this command with --program-key"); + console.error("For example: solana-keygen grind --starts-with ntt:1 --ignore-case") + process.exit(1); + } + programKeypairPath = programKeyJson; + programKeypair = Keypair.fromSecretKey(new Uint8Array(JSON.parse(fs.readFileSync(programKeyJson).toString()))); + if (existingProgramId !== programKeypair.publicKey.toBase58()) { + console.error(`The private key in ${programKeyJson} does not match the existing program ID: ${existingProgramId}`); + process.exit(1); + } + } + + // see if the program key matches the existing program ID. if not, we need + // to update the latter in the Anchor.toml file and the lib.rs file(s) + const providedProgramId = programKeypair.publicKey.toBase58(); + if (providedProgramId !== existingProgramId) { + console.error(`Program keypair does not match the existing program ID: ${existingProgramId}`); + await askForConfirmation(`Do you want to update the program ID in the Anchor.toml file and the lib.rs file to ${providedProgramId}?`); + + const anchorTomlPath = "solana/Anchor.toml"; + const libRsPath = "solana/programs/example-native-token-transfers/src/lib.rs"; + + const anchorToml = fs.readFileSync(anchorTomlPath).toString(); + const newAnchorToml = anchorToml.replace(existingProgramId, providedProgramId); + fs.writeFileSync(anchorTomlPath, newAnchorToml); + const libRs = fs.readFileSync(libRsPath).toString(); + const newLibRs = libRs.replace(existingProgramId, providedProgramId); + fs.writeFileSync(libRsPath, newLibRs); + } + + let binary: string; + + const skipDeploy = false; + + if (!skipDeploy) { + if (binaryPath) { + binary = binaryPath; + } else { + // build the program + // TODO: build with docker + checkAnchorVersion(); + const proc = Bun.spawn( + ["anchor", + "build", + "--", "--no-default-features", "--features", cargoNetworkFeature(ch.network) + ], { + cwd: `${pwd}/solana` + }); + + // const _out = await new Response(proc.stdout).text(); + + await proc.exited; + if (proc.exitCode !== 0) { + process.exit(proc.exitCode ?? 1); + } + + binary = `${pwd}/solana/target/deploy/example_native_token_transfers.so`; + } + + await checkSolanaBinary(binary, wormhole, providedProgramId) + + // do the actual deployment + const deployProc = Bun.spawn( + ["solana", + "program", + "deploy", + "--program-id", programKeypairPath, + binary, + "--keypair", payer, + "-u", ch.config.rpc + ]); + + const out = await new Response(deployProc.stdout).text(); + + await deployProc.exited; + + if (deployProc.exitCode !== 0) { + process.exit(deployProc.exitCode ?? 1); + } + + console.log(out); + } + + // wait 3 seconds + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const emitter = NTT.pdas(providedProgramId).emitterAccount().toBase58(); + + const payerKeypair = Keypair.fromSecretKey(new Uint8Array(JSON.parse(fs.readFileSync(payer).toString()))); + + // can't do this yet.. need to init first. + // const {ntt, addresses} = await nttFromManager(ch, providedProgramId); + const ntt: SolanaNtt<N, C> = await ch.getProtocol("Ntt", { + ntt: { + manager: providedProgramId, + token: token, + transceiver: { wormhole: emitter }, + } + }) as SolanaNtt<N, C>; + + const tx = ntt.initialize( + toUniversal(ch.chain, payerKeypair.publicKey.toBase58()), + { + mint: new PublicKey(token), + mode, + outboundLimit: 100000000n, + }); + + const signer = await getSigner(ch, "privateKey", encoding.b58.encode(payerKeypair.secretKey)); + + try { + await signSendWait(ch, tx, signer.signer); + } catch (e: any) { + console.error(e.logs); + } + + return { chain: ch.chain, address: toUniversal(ch.chain, providedProgramId) }; +} + +async function missingConfigs( + deps: Partial<{ [C in Chain]: Deployment<Chain> }>, + verbose: boolean, +): Promise<Partial<{ [C in Chain]: MissingImplicitConfig }>> { + const missingConfigs: Partial<{ [C in Chain]: MissingImplicitConfig }> = {}; + + for (const [fromChain, from] of Object.entries(deps)) { + let count = 0; + assertChain(fromChain); + + let missing: MissingImplicitConfig = { + managerPeers: [], + transceiverPeers: [], + evmChains: [], + standardRelaying: [], + }; + + for (const [toChain, to] of Object.entries(deps)) { + assertChain(toChain); + if (fromChain === toChain) { + continue; + } + if (verbose) { + process.stdout.write(`Verifying registration for ${fromChain} -> ${toChain}\r`); + } + const peer = await from.ntt.getPeer(toChain); + if (peer === null) { + const configLimit = from.config.local?.limits?.inbound?.[toChain]?.replace(".", ""); + count++; + missing.managerPeers.push({ + address: to.manager, + tokenDecimals: to.decimals, + inboundLimit: BigInt(configLimit ?? 0), + }); + } else { + // @ts-ignore TODO + if (!Buffer.from(peer.address.address.address).equals(Buffer.from(to.manager.address.address))) { + console.error(`Peer address mismatch for ${fromChain} -> ${toChain}`); + } + if (peer.tokenDecimals !== to.decimals) { + console.error(`Peer decimals mismatch for ${fromChain} -> ${toChain}`); + } + } + + if (chainToPlatform(fromChain) === "Evm") { + const toIsEvm = chainToPlatform(toChain) === "Evm"; + + const remoteToEvm = await (await from.ntt.getTransceiver(0) as EvmNttWormholeTranceiver<Network, EvmChains>).isEvmChain(toChain); + if (toIsEvm && !remoteToEvm) { + count++; + missing.evmChains.push(toChain); + } + + const standardRelaying = await (await from.ntt.getTransceiver(0) as EvmNttWormholeTranceiver<Network, EvmChains>).isWormholeRelayingEnabled(toChain); + if (toIsEvm && !standardRelaying) { + count++; + missing.standardRelaying.push(toChain); + } + } + + const transceiverPeer = await from.whTransceiver.getPeer(toChain); + if (transceiverPeer === null) { + count++; + missing.transceiverPeers.push(to.whTransceiver.getAddress()); + } else { + // @ts-ignore TODO + if (!Buffer.from(transceiverPeer.address.address).equals(Buffer.from(to.whTransceiver.getAddress().address.address))) { + console.error(`Transceiver peer address mismatch for ${fromChain} -> ${toChain}`); + } + } + + if (count > 0) { + missingConfigs[fromChain] = missing; + } + } + } + return missingConfigs; +} + +async function pushDeployment<C extends Chain>(deployment: Deployment<C>, signerType: SignerType, evmVerify: boolean, yes: boolean): Promise<void> { + const diff = diffObjects(deployment.config.local!, deployment.config.remote!); + if (Object.keys(diff).length === 0) { + return; + } + + const canonical = canonicalAddress(deployment.manager); + console.log(`Pushing changes to ${deployment.manager.chain} (${canonical})`) + + console.log(chalk.reset(colorizeDiff(diff))); + if (!yes) { + await askForConfirmation(); + } + + const ctx = deployment.ctx; + + const signer = await getSigner(ctx, signerType); + + let txs = []; + let managerUpgrade: { from: string, to: string } | undefined; + for (const k of Object.keys(diff)) { + if (k === "version") { + // TODO: check against existing version, and make sure no major version changes + managerUpgrade = { from: diff[k]!.pull!, to: diff[k]!.push! }; + } else if (k === "owner") { + const address: AccountAddress<C> = toUniversal(deployment.manager.chain, diff[k]?.push!); + txs.push(deployment.ntt.setOwner(address, signer.address.address)); + } else if (k === "paused") { + if (diff[k]?.push === true) { + txs.push(deployment.ntt.pause(signer.address.address)); + } else { + txs.push(deployment.ntt.unpause(signer.address.address)); + } + } else if (k === "limits") { + const newOutbound = diff[k]?.outbound?.push; + if (newOutbound) { + // TODO: verify amount has correct number of decimals? + // remove "." from string and convert to bigint + const newOutboundBigint = BigInt(newOutbound.replace(".", "")); + txs.push(deployment.ntt.setOutboundLimit(newOutboundBigint, signer.address.address)); + } + const inbound = diff[k]?.inbound; + if (inbound) { + for (const chain of Object.keys(inbound)) { + assertChain(chain); + const newInbound = inbound[chain]?.push; + if (newInbound) { + // TODO: verify amount has correct number of decimals? + const newInboundBigint = BigInt(newInbound.replace(".", "")); + txs.push(deployment.ntt.setInboundLimit(chain, newInboundBigint, signer.address.address)); + } + } + } + } else { + console.error(`Unsupported field: ${k}`); + process.exit(1); + } + } + if (managerUpgrade) { + await upgrade(managerUpgrade.from, managerUpgrade.to, deployment.ntt, ctx, signerType, evmVerify); + } + for (const tx of txs) { + await signSendWait(ctx, tx, signer.signer) + } +} + +async function pullDeployments(deployments: Config, network: Network, verbose: boolean): Promise<Partial<{ [C in Chain]: Deployment<Chain> }>> { + let deps: Partial<{ [C in Chain]: Deployment<Chain> }> = {}; + + for (const [chain, deployment] of Object.entries(deployments.chains)) { + if (verbose) { + process.stdout.write(`Fetching config for ${chain}\r`); + } + assertChain(chain); + const managerAddress: string | undefined = deployment.manager; + if (managerAddress === undefined) { + console.error(`manager field not found for chain ${chain}`); + // process.exit(1); + continue; + } + const [remote, ctx, ntt, decimals] = await pullChainConfig( + network, + { chain, address: toUniversal(chain, managerAddress) }, + overrides + ); + const local = deployments.chains[chain]; + + // TODO: what if it's not index 0... + // we should check that the address of this transceiver matches the + // address in the config. currently we just assume that ix 0 is the wormhole one + const whTransceiver = await ntt.getTransceiver(0); + if (whTransceiver === null) { + console.error(`Wormhole transceiver not found for ${chain}`); + process.exit(1); + } + + deps[chain] = { + ctx, + ntt, + decimals, + manager: { chain, address: toUniversal(chain, managerAddress) }, + whTransceiver, + config: { + remote, + local, + } + }; + } + + const config = Object.fromEntries(Object.entries(deps).map(([k, v]) => [k, v.config.remote])); + const ntts = Object.fromEntries(Object.entries(deps).map(([k, v]) => [k, v.ntt])); + await pullInboundLimits(ntts, config, verbose); + return deps; +} + +async function pullChainConfig<N extends Network, C extends Chain>( + network: N, + manager: ChainAddress<C>, + overrides?: ConfigOverrides<N> +): Promise<[ChainConfig, ChainContext<typeof network, C>, Ntt<typeof network, C>, number]> { + const wh = new Wormhole(network, [solana.Platform, evm.Platform], overrides); + const ch = wh.getChain(manager.chain); + + const nativeManagerAddress = canonicalAddress(manager); + + const { ntt, addresses }: { ntt: Ntt<N, C>; addresses: Partial<Ntt.Contracts>; } = + await nttFromManager<N, C>(ch, nativeManagerAddress); + + const mode = await ntt.getMode(); + const outboundLimit = await ntt.getOutboundLimit(); + const threshold = await ntt.getThreshold(); + + const decimals = await ntt.getTokenDecimals(); + // insert decimal point into number + const outboundLimitDecimals = formatNumber(outboundLimit, decimals); + + const paused = await ntt.isPaused(); + const owner = await ntt.getOwner(); + + const version = getVersion(manager.chain, ntt); + + const config: ChainConfig = { + version, + mode, + paused, + owner: owner.toString(), + manager: nativeManagerAddress, + token: addresses.token!, + transceivers: { + threshold, + wormhole: addresses.transceiver!.wormhole!, + }, + limits: { + outbound: outboundLimitDecimals, + inbound: {}, + }, + }; + return [config, ch, ntt, decimals]; +} + +async function getImmutables<N extends Network, C extends Chain>(chain: C, ntt: Ntt<N, C>) { + const platform = chainToPlatform(chain); + if (platform !== "Evm") { + return null; + } + const evmNtt = ntt as EvmNtt<N, EvmChains>; + const transceiver = await evmNtt.getTransceiver(0) as EvmNttWormholeTranceiver<N, EvmChains>; + const consistencyLevel = await transceiver.transceiver.consistencyLevel(); + const wormholeRelayer = await transceiver.transceiver.wormholeRelayer(); + const specialRelayer = await transceiver.transceiver.specialRelayer(); + const gasLimit = await transceiver.transceiver.gasLimit(); + + const token = await evmNtt.manager.token(); + const tokenDecimals = await evmNtt.manager.tokenDecimals(); + + const whTransceiverImmutables = { + consistencyLevel, + wormholeRelayer, + specialRelayer, + gasLimit, + }; + return { + manager: { + token, + tokenDecimals, + }, + wormholeTransceiver: whTransceiverImmutables, + }; +} + +function getVersion<N extends Network, C extends Chain>(chain: C, ntt: Ntt<N, C>): string { + const platform = chainToPlatform(chain); + switch (platform) { + case "Evm": + return (ntt as EvmNtt<N, EvmChains>).version + case "Solana": + return (ntt as SolanaNtt<N, SolanaChains>).version + default: + throw new Error("Unsupported platform"); + } +} + +// TODO: there should be a more elegant way to do this, than creating a +// "dummy" NTT, then calling verifyAddresses to get the contract diff, then +// finally reconstructing the "real" NTT object from that +async function nttFromManager<N extends Network, C extends Chain>( + ch: ChainContext<N, C>, + nativeManagerAddress: string +): Promise<{ ntt: Ntt<N, C>; addresses: Partial<Ntt.Contracts> }> { + const onlyManager = await ch.getProtocol("Ntt", { + ntt: { + manager: nativeManagerAddress, + token: null, + transceiver: { wormhole: null }, + } + }); + const diff = await onlyManager.verifyAddresses(); + + const addresses: Partial<Ntt.Contracts> = { manager: nativeManagerAddress, ...diff }; + + const ntt = await ch.getProtocol("Ntt", { + ntt: addresses + }); + return { ntt, addresses }; +} + +function formatNumber(num: bigint, decimals: number) { + if (num === 0n) { + return "0." + "0".repeat(decimals); + } + const str = num.toString(); + const formatted = str.slice(0, -decimals) + "." + str.slice(-decimals); + if (formatted.startsWith(".")) { + return "0" + formatted; + } + return formatted; +} + function cargoNetworkFeature(network: Network): string { switch (network) { - case "mainnet": + case "Mainnet": return "mainnet"; - case "testnet": + case "Testnet": return "solana-devnet"; - case "devnet": + case "Devnet": return "tilt-devnet"; + default: + throw new Error("Unsupported network"); } } -function solanaMoniker(network: Network): string { - switch (network) { - case "mainnet": - return "m"; - case "testnet": - return "d"; - case "devnet": - return "l"; +async function askForConfirmation(prompt: string = "Do you want to continue?"): Promise<void> { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const answer = await new Promise<string>((resolve) => { + rl.question(`${prompt} [y/n]`, resolve); + }); + rl.close(); + + if (answer !== "y") { + console.log("Aborting"); + process.exit(0); } } + +// NOTE: modifies the config object in place +// TODO: maybe introduce typestate for having pulled inbound limits? +async function pullInboundLimits(ntts: Partial<{ [C in Chain]: Ntt<Network, C> }>, config: Config["chains"], verbose: boolean) { + for (const [c1, ntt1] of Object.entries(ntts)) { + assertChain(c1); + const chainConf = config[c1]; + if (!chainConf) { + console.error(`Chain ${c1} not found in deployment`); + process.exit(1); + } + const decimals = await ntt1.getTokenDecimals(); + for (const [c2, ntt2] of Object.entries(ntts)) { + assertChain(c2); + if (ntt1 === ntt2) { + continue; + } + if (verbose) { + process.stdout.write(`Fetching inbound limit for ${c1} -> ${c2}\r`); + } + const peer = await retryWithExponentialBackoff(() => ntt1.getPeer(c2), 5, 5000); + if (chainConf.limits?.inbound === undefined) { + chainConf.limits.inbound = {}; + } + + const limit = peer?.inboundLimit ?? 0n; + + chainConf.limits.inbound[c2] = formatNumber(limit, decimals) + + } + } +} + +async function checkSolanaBinary(binary: string, wormhole: string, providedProgramId: string) { + // ensure binary path exists + if (!fs.existsSync(binary)) { + console.error(`.so file not found: ${binary}`); + process.exit(1); + } + // console.log(`Checking binary ${binary} for wormhole and provided program ID`); + + // convert wormhole and providedProgramId from base58 to hex + const wormholeHex = new PublicKey(wormhole).toBuffer().toString("hex"); + const providedProgramIdHex = new PublicKey(providedProgramId).toBuffer().toString("hex"); + + execSync(`xxd -p ${binary} | tr -d '\\n' | grep ${wormholeHex}`); + execSync(`xxd -p ${binary} | tr -d '\\n' | grep ${providedProgramIdHex}`); + +} + +export function ensureNttRoot(pwd: string = ".") { + if (!fs.existsSync(`${pwd}/evm/foundry.toml`) || !fs.existsSync(`${pwd}/solana/Anchor.toml`)) { + console.error("Run this command from the root of an NTT project."); + process.exit(1); + } +} + +function checkAnchorVersion() { + const expected = "0.29.0"; + try { + execSync("which anchor"); + } catch { + console.error("Anchor CLI is not installed.\nSee https://www.anchor-lang.com/docs/installation") + process.exit(1); + } + const version = execSync("anchor --version").toString().trim(); + // version looks like "anchor-cli 0.14.0" + const [_, v] = version.split(" "); + if (v !== expected) { + console.error(`Anchor CLI version must be ${expected} but is ${v}`); + process.exit(1); + } +} +function loadConfig(path: string): Config { + if (!fs.existsSync(path)) { + console.error(`File not found: ${path}`); + console.error(`Create with 'ntt init' or specify another file with --path`); + process.exit(1); + } + const deployments: Config = JSON.parse(fs.readFileSync(path).toString()); + return deployments; +} + +function resolveVersion(latest: boolean, ver: string | undefined, local: boolean, platform: Platform): string | null { + if ((latest ? 1 : 0) + (ver ? 1 : 0) + (local ? 1 : 0) !== 1) { + console.error("Specify exactly one of --latest, --ver, or --local"); + const available = getAvailableVersions(platform); + console.error(`Available versions for ${platform}:\n${available.join("\n")}`); + process.exit(1); + } + if (latest) { + const available = getAvailableVersions(platform); + return available.sort().reverse()[0]; + } else if (ver) { + return ver; + } else { + // local version + return null; + } +} + +function warnLocalDeployment(yes: boolean): Promise<void> { + if (!yes) { + console.warn(chalk.yellow("WARNING: You are deploying from your local working directory.")); + console.warn(chalk.yellow("This bypasses version control and may deploy untested changes.")); + console.warn(chalk.yellow("Ensure your local changes are thoroughly tested and compatible.")); + return askForConfirmation("Are you sure you want to continue with the local deployment?"); + } + return Promise.resolve(); +} + +function validateChain<N extends Network, C extends Chain>(network: N, chain: C) { + if (network === "Testnet") { + if (chain === "Ethereum") { + console.error("Ethereum is deprecated on Testnet. Use EthereumSepolia instead."); + process.exit(1); + } + // if on testnet, and the chain has a *Sepolia counterpart, use that instead + if (chains.find((c) => c === `${c}Sepolia`)) { + console.error(`Chain ${chain} is deprecated. Use ${chain}Sepolia instead.`); + process.exit(1); + } + } +} + +function retryWithExponentialBackoff<T>( + fn: () => Promise<T>, + maxRetries: number, + delay: number, +): Promise<T> { + const backoff = (retry: number) => Math.min(2 ** retry * delay, 10000) + Math.random() * 1000; + const attempt = async (retry: number): Promise<T> => { + try { + return await fn(); + } catch (e) { + if (retry >= maxRetries) { + throw e; + } + const time = backoff(retry); + await new Promise((resolve) => setTimeout(resolve, backoff(time))); + return await attempt(retry + 1); + } + }; + return attempt(0); +} diff --git a/cli/src/side-effects.ts b/cli/src/side-effects.ts new file mode 100644 index 000000000..f4f91b0ee --- /dev/null +++ b/cli/src/side-effects.ts @@ -0,0 +1,38 @@ +// <sigh> +// when the native secp256k1 is missing, the eccrypto library decides TO PRINT A MESSAGE TO STDOUT: +// https://github.com/bitchan/eccrypto/blob/a4f4a5f85ef5aa1776dfa1b7801cad808264a19c/index.js#L23 +// +// do you use a CLI tool that depends on that library and try to pipe the output +// of the tool into another? tough luck +// +// for lack of a better way to stop this, we patch the console.info function to +// drop that particular message... +// </sigh> +const info = console.info; +console.info = function (x: string) { + if (x !== "secp256k1 unavailable, reverting to browser version") { + info(x); + } +}; + +const warn = console.warn; +globalThis.console.warn = function (x: string) { + if ( + x !== + "bigint: Failed to load bindings, pure JS will be used (try npm run rebuild?)" + ) { + warn(x); + } +}; + +// Ensure BigInt can be serialized to json +// +// eslint-disable-next-line @typescript-eslint/no-redeclare +interface BigInt { + /** Convert to BigInt to string form in JSON.stringify */ + toJSON: () => string; +} +// Without this JSON.stringify() blows up +(BigInt.prototype as any).toJSON = function () { + return this.toString(); +}; diff --git a/cli/src/tag.ts b/cli/src/tag.ts new file mode 100644 index 000000000..ba3201452 --- /dev/null +++ b/cli/src/tag.ts @@ -0,0 +1,16 @@ +import type { Platform } from "@wormhole-foundation/sdk" +import { execSync } from "child_process" + +export function getAvailableVersions<P extends Platform>(platform: P): string[] { + const tags = execSync(`git tag --list 'v*+${platform.toLowerCase()}'`, { + stdio: ["ignore", null, null] + }).toString().trim().split("\n") + return tags.map(tag => tag.split("+")[0].slice(1)) +} + +export function getGitTagName<P extends Platform>(platform: P, version: string): string | undefined { + const found = execSync(`git tag --list 'v${version}+${platform.toLowerCase()}'`, { + stdio: ["ignore", null, null] + }).toString().trim() + return found +} diff --git a/cli/test/sepolia-bsc.sh b/cli/test/sepolia-bsc.sh new file mode 100755 index 000000000..d35524af4 --- /dev/null +++ b/cli/test/sepolia-bsc.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# This script creates two forks (Bsc and Sepolia) and creates an NTT deployment +# on both of them. +# It's safe to run these tests outside of docker, as we create an isolated temporary +# directory for the tests. + +set -euox pipefail + +BSC_PORT=8545 +SEPOLIA_PORT=8546 + +anvil --silent --rpc-url https://bsc-testnet-rpc.publicnode.com -p "$BSC_PORT" & +pid1=$! +anvil --silent --rpc-url wss://ethereum-sepolia-rpc.publicnode.com -p "$SEPOLIA_PORT" & +pid2=$! + +# check both processes are running +if ! kill -0 $pid1 || ! kill -0 $pid2; then + echo "Failed to start the servers" + exit 1 +fi + +# create tmp directory +dir=$(mktemp -d) + +cleanup() { + kill $pid1 $pid2 + rm -rf $dir +} + +trap "cleanup" INT TERM EXIT + +# devnet private key +export ETH_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +echo "Running tests..." +cd $dir +ntt new test-ntt +cd test-ntt +ntt init Testnet + +# write overrides.json +cat <<EOF > overrides.json +{ + "chains": { + "Bsc": { + "rpc": "http://127.0.0.1:$BSC_PORT" + }, + "Sepolia": { + "rpc": "http://127.0.0.1:$SEPOLIA_PORT" + } + } +} +EOF + +ntt add-chain Bsc --token 0x0B15635FCF5316EdFD2a9A0b0dC3700aeA4D09E6 --mode locking --skip-verify --latest +ntt add-chain Sepolia --token 0xB82381A3fBD3FaFA77B3a7bE693342618240067b --skip-verify --ver 1.0.0 + +ntt pull --yes +ntt push --yes + +# ugprade Sepolia to 1.1.0 +ntt upgrade Sepolia --ver 1.1.0 --skip-verify --yes +# now upgrade to the local version. +ntt upgrade Sepolia --local --skip-verify --yes + +ntt pull --yes + +# transfer ownership to +NEW_OWNER=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 +NEW_OWNER_SECRET=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d + +jq '.chains.Bsc.owner = "'$NEW_OWNER'"' deployment.json > deployment.json.tmp && mv deployment.json.tmp deployment.json +jq '.chains.Sepolia.owner = "'$NEW_OWNER'"' deployment.json > deployment.json.tmp && mv deployment.json.tmp deployment.json +ntt push --yes + +# check the owner has been updated +jq '.chains.Bsc.owner == "'$NEW_OWNER'"' deployment.json +jq '.chains.Sepolia.owner == "'$NEW_OWNER'"' deployment.json + +export ETH_PRIVATE_KEY=$NEW_OWNER_SECRET + +set the deployment to be paused, by setting the 'chains.Bsc.paused' field to true using jq +jq '.chains.Bsc.paused = true' deployment.json > deployment.json.tmp && mv deployment.json.tmp deployment.json + +ntt push --yes +jq '.chains.Bsc.paused == true' deployment.json + +ntt status + +cat deployment.json diff --git a/package-lock.json b/package-lock.json index dbd684f33..7d92a0565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,8 +30,10 @@ } }, "cli": { + "name": "@wormhole-foundation/ntt-cli", "version": "0.1.0-beta.0", "dependencies": { + "chalk": "^5.3.0", "yargs": "^17.7.2" }, "bin": { @@ -45,6 +47,17 @@ "typescript": "^5.0.0" } }, + "cli/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "cli/node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3784,6 +3797,10 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.26.tgz", "integrity": "sha512-Fg4zwR0GNnjzodMt3KRy2AWGMKQXByl56+4HjN87soxLNU9P5xcJkstAlIeEF3cU6UYOzmJl1tV0dVPGIljCnQ==" }, + "node_modules/@wormhole-foundation/ntt-cli": { + "resolved": "cli", + "link": true + }, "node_modules/@wormhole-foundation/sdk": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/@wormhole-foundation/sdk/-/sdk-0.6.5.tgz", @@ -6568,10 +6585,6 @@ "dev": true, "peer": true }, - "node_modules/cli": { - "resolved": "cli", - "link": true - }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", From 273b55daaaaadfd4803554739d9e2bd741333bcc Mon Sep 17 00:00:00 2001 From: Csongor Kiss <kiss.csongor.kiss@gmail.com> Date: Fri, 28 Jun 2024 13:36:13 +0100 Subject: [PATCH 4/4] CI: add cli action --- .github/workflows/cli.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/workflows/cli.yml diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml new file mode 100644 index 000000000..74bd72f36 --- /dev/null +++ b/.github/workflows/cli.yml @@ -0,0 +1,15 @@ +name: CLI + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: docker build -f Dockerfile.cli --target cli-local-test . --progress=plain