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