Skip to content

Commit d1d6fb0

Browse files
committed
minimize wallet interface in Chains, move to CLI
ccip-sdk doesn't have static or cached `getWallet` anymore; instead, it receives a `opts.wallet` directly in the `sendMessage` and `executeReport` methods; users are then expected to provide compatible wallets, like done by the CLI without overriding any method in the SDK.
1 parent 0a8738b commit d1d6fb0

File tree

15 files changed

+163
-197
lines changed

15 files changed

+163
-197
lines changed

ccip-cli/src/commands/manual-exec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
selectRequest,
2323
withDateTimestamp,
2424
} from './utils.ts'
25-
import { fetchChainsFromRpcs } from '../providers/index.ts'
25+
import { fetchChainsFromRpcs, loadChainWallet } from '../providers/index.ts'
2626

2727
// const MAX_QUEUE = 1000
2828
// const MAX_EXECS_IN_BATCH = 1
@@ -92,7 +92,6 @@ export const builder = (yargs: Argv) =>
9292
})
9393

9494
export async function handler(argv: Awaited<ReturnType<typeof builder>['argv']> & GlobalOpts) {
95-
if (!argv.wallet) argv.wallet = process.env['USER_KEY'] || process.env['OWNER_KEY']
9695
let destroy
9796
const destroy$ = new Promise((resolve) => {
9897
destroy = resolve
@@ -193,7 +192,8 @@ async function manualExec(
193192
}
194193
}
195194

196-
const manualExecTx = await dest.executeReport(offRamp, execReport, argv)
195+
const [, wallet] = await loadChainWallet(dest, argv)
196+
const manualExecTx = await dest.executeReport(offRamp, execReport, { ...argv, wallet })
197197

198198
console.log('🚀 manualExec tx =', manualExecTx.hash, 'to offRamp =', offRamp)
199199

ccip-cli/src/commands/send.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type { Argv } from 'yargs'
1717
import type { GlobalOpts } from '../index.ts'
1818
import { Format } from './types.ts'
1919
import { logParsedError, parseTokenAmounts, prettyRequest, withDateTimestamp } from './utils.ts'
20-
import { fetchChainsFromRpcs } from '../providers/index.ts'
20+
import { fetchChainsFromRpcs, loadChainWallet } from '../providers/index.ts'
2121

2222
export const command = 'send <source> <router> <dest>'
2323
export const describe = 'Send a CCIP message from router on source to dest'
@@ -188,10 +188,12 @@ async function sendMessage(
188188
throw new Error('--token-receiver and --account intended only for Solana dest')
189189
}
190190

191+
let walletAddress, wallet
191192
if (!receiver) {
192193
if (sourceNetwork.family !== destNetwork.family)
193194
throw new Error('--receiver is required when sending to a different chain family')
194-
receiver = await source.getWalletAddress(argv) // send to self if same family
195+
;[walletAddress, wallet] = await loadChainWallet(source, argv)
196+
receiver = walletAddress // send to self if same family
195197
}
196198

197199
if (argv.estimateGasLimit != null || argv.onlyEstimate) {
@@ -213,10 +215,11 @@ async function sendMessage(
213215
tokenAmounts,
214216
)
215217

218+
if (!walletAddress) [walletAddress, wallet] = await loadChainWallet(source, argv)
216219
const estimated = await estimateExecGasForRequest(source, dest, {
217220
lane,
218221
message: {
219-
sender: await source.getWalletAddress(argv),
222+
sender: walletAddress,
220223
receiver,
221224
data,
222225
tokenAmounts: destTokenAmounts,
@@ -283,11 +286,12 @@ async function sendMessage(
283286
)
284287
if (argv.onlyGetFee) return
285288

289+
if (!walletAddress) [walletAddress, wallet] = await loadChainWallet(source, argv)
286290
const request = await source.sendMessage(
287291
argv.router,
288292
destNetwork.chainSelector,
289293
{ ...message, fee },
290-
argv,
294+
{ ...argv, wallet },
291295
)
292296
console.log(
293297
'🚀 Sending message to',

ccip-cli/src/providers/aptos.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
Ed25519Signature,
1313
generateSigningMessageForTransaction,
1414
} from '@aptos-labs/ts-sdk'
15-
import { AptosChain } from '@chainlink/ccip-sdk/src/index.ts'
1615
import AptosLedger from '@ledgerhq/hw-app-aptos'
1716
import HIDTransport from '@ledgerhq/hw-transport-node-hid'
1817
import { type BytesLike, getBytes, hexlify } from 'ethers'
@@ -72,7 +71,12 @@ export class AptosLedgerSigner /*implements AptosAsyncAccount*/ {
7271
}
7372
}
7473

75-
AptosChain.getWallet = async function loadAptosWallet({ wallet: walletOpt }: { wallet?: unknown }) {
74+
/**
75+
* Loads an Aptos wallet from the provided options.
76+
* @param opts.wallet - wallet options (as passed from yargs argv)
77+
* @returns Promise to AptosAsyncAccount instance
78+
*/
79+
export async function loadAptosWallet({ wallet: walletOpt }: { wallet?: unknown }) {
7680
if (!walletOpt) walletOpt = process.env['USER_KEY'] || process.env['OWNER_KEY']
7781
if (typeof walletOpt !== 'string')
7882
throw new Error(`Invalid wallet option: ${util.inspect(walletOpt)}`)

ccip-cli/src/providers/evm.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@ import { existsSync } from 'node:fs'
22
import { readFile } from 'node:fs/promises'
33
import util from 'util'
44

5-
import { EVMChain } from '@chainlink/ccip-sdk/src/index.ts'
65
import { LedgerSigner } from '@ethers-ext/signer-ledger'
76
import { password } from '@inquirer/prompts'
87
import HIDTransport from '@ledgerhq/hw-transport-node-hid'
9-
import { type Provider, type Signer, BaseWallet, SigningKey, Wallet } from 'ethers'
8+
import {
9+
type JsonRpcApiProvider,
10+
type Provider,
11+
type Signer,
12+
BaseWallet,
13+
SigningKey,
14+
Wallet,
15+
} from 'ethers'
1016

1117
// monkey-patch @ethers-ext/signer-ledger to preserve path when `.connect`ing provider
1218
Object.assign(LedgerSigner.prototype, {
@@ -18,14 +24,25 @@ Object.assign(LedgerSigner.prototype, {
1824
/**
1925
* Overwrite EVMChain.getWallet to support reading private key from file, env var or Ledger
2026
* @param provider - provider instance to be connected to signers
21-
* @param opts - wallet options (as passed to yargs argv)
27+
* @param opts.wallet - wallet options (as passed to yargs argv)
2228
* @returns Promise to Signer instance
2329
*/
24-
EVMChain.getWallet = async function loadEvmWallet(
25-
provider: Provider,
30+
export async function loadEvmWallet(
31+
provider: JsonRpcApiProvider,
2632
{ wallet: walletOpt }: { wallet?: unknown },
2733
): Promise<Signer> {
2834
if (!walletOpt) walletOpt = process.env['USER_KEY'] || process.env['OWNER_KEY']
35+
if (
36+
typeof walletOpt === 'number' ||
37+
(typeof walletOpt === 'string' && walletOpt.match(/^(\d+|0x[a-fA-F0-9]{40})$/))
38+
) {
39+
// if given a number, numeric string or address, use ethers `provider.getSigner` (e.g. geth or MM)
40+
return provider.getSigner(
41+
typeof walletOpt === 'string' && walletOpt.match(/^0x[a-fA-F0-9]{40}$/)
42+
? walletOpt
43+
: Number(walletOpt),
44+
)
45+
}
2946
if (typeof walletOpt !== 'string')
3047
throw new Error(`Invalid wallet option: ${util.inspect(walletOpt)}`)
3148
if ((walletOpt ?? '').startsWith('ledger')) {

ccip-cli/src/providers/index.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import {
44
type Chain,
55
type ChainGetter,
66
type ChainTransaction,
7+
type EVMChain,
8+
ChainFamily,
79
networkInfo,
810
supportedChains,
911
} from '@chainlink/ccip-sdk/src/index.ts'
1012

11-
import './aptos.ts'
12-
import './evm.ts'
13-
import './solana.ts'
13+
import { loadAptosWallet } from './aptos.ts'
14+
import { loadEvmWallet } from './evm.ts'
15+
import { loadSolanaWallet } from './solana.ts'
1416

1517
const RPCS_RE = /\b(?:http|ws)s?:\/\/[\w/\\@&?%~#.,;:=+-]+/
1618

@@ -141,3 +143,20 @@ export function fetchChainsFromRpcs(
141143
return chainGetter
142144
}
143145
}
146+
147+
export async function loadChainWallet(chain: Chain, opts: { wallet?: unknown }) {
148+
let wallet
149+
switch (chain.network.family) {
150+
case ChainFamily.EVM:
151+
wallet = await loadEvmWallet((chain as EVMChain).provider, opts)
152+
return [await wallet.getAddress(), wallet] as const
153+
case ChainFamily.Solana:
154+
wallet = await loadSolanaWallet(opts)
155+
return [wallet.publicKey.toBase58(), wallet] as const
156+
case ChainFamily.Aptos:
157+
wallet = await loadAptosWallet(opts)
158+
return [wallet.accountAddress.toString(), wallet] as const
159+
default:
160+
throw new Error(`Unsupported chain family: ${chain.network.family}`)
161+
}
162+
}

ccip-cli/src/providers/solana.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { existsSync, readFileSync } from 'node:fs'
22
import util from 'node:util'
33

4-
import { SolanaChain } from '@chainlink/ccip-sdk/src/index.ts'
5-
import { Wallet as SolanaWallet } from '@coral-xyz/anchor'
4+
import { Wallet as AnchorWallet } from '@coral-xyz/anchor'
65
import SolanaLedger from '@ledgerhq/hw-app-solana'
76
import HIDTransport from '@ledgerhq/hw-transport-node-hid'
87
import {
@@ -70,9 +69,14 @@ export class LedgerSolanaWallet {
7069
}
7170
}
7271

73-
SolanaChain.getWallet = async function loadSolanaWallet({
72+
/**
73+
* Loads a Solana wallet from a file or Ledger device.
74+
* @param opts.wallet - wallet options (as passed to yargs argv)
75+
* @returns Promise to Anchor Wallet instance
76+
*/
77+
export async function loadSolanaWallet({
7478
wallet: walletOpt,
75-
}: { wallet?: unknown } = {}): Promise<SolanaWallet> {
79+
}: { wallet?: unknown } = {}): Promise<AnchorWallet> {
7680
if (!walletOpt)
7781
walletOpt = process.env['USER_KEY'] || process.env['OWNER_KEY'] || '~/.config/solana/id.json'
7882
let wallet: string
@@ -83,11 +87,11 @@ SolanaChain.getWallet = async function loadSolanaWallet({
8387
let derivationPath = walletOpt.split(':')[1]
8488
if (!derivationPath) derivationPath = "44'/501'/0'"
8589
else if (!isNaN(Number(derivationPath))) derivationPath = `44'/501'/${derivationPath}'`
86-
return (await LedgerSolanaWallet.create(derivationPath)) as SolanaWallet
90+
return (await LedgerSolanaWallet.create(derivationPath)) as AnchorWallet
8791
} else if (existsSync(walletOpt)) {
8892
wallet = hexlify(new Uint8Array(JSON.parse(readFileSync(walletOpt, 'utf8'))))
8993
}
90-
return new SolanaWallet(
94+
return new AnchorWallet(
9195
Keypair.fromSecretKey(wallet.startsWith('0x') ? getBytes(wallet) : bs58.decode(wallet)),
9296
)
9397
}

ccip-sdk/README.md

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,29 @@ constructor from the specific library provider (e.g. `EVMChain.fromProvider(prov
5555

5656
## Wallet
5757

58-
Most chain families classes have a *cached* `getWallet` method, which handles creating a signer or
59-
wallet from raw private keys. They receive a generic `{ wallet?: unknown }` object, which may be
60-
passed from other methods, or CLI's `argv` options, to aid in wallet creation.
58+
Transaction-sending high-level methods, namely `Chain.sendMessage` and `Chain.executeReport`,
59+
require a `wallet` property in last `opts` parameter. This is marked as `unknown` in generic Chain
60+
abstract class, but required to be an asynchronous signer wallet respective to each chain family:
6161

62-
If they can't, users may override the *static* `getWallet` function (with parameters depending on
63-
chain family implementation), which is called to try to construct a wallet or signer instead.
64-
This can be used to extend the library to create signers according to each environment, without
65-
requiring a full class inheritance.
62+
- `EVMChain` requires an `ethers` `Signer`
63+
- `SolanaChain` requires an `anchor` `Wallet`
64+
- `AptosChain` requires an `aptos-ts-sdk` `Account`
6665

67-
Example:
68-
```ts
69-
import { EVMChain } from '@chainlink/ccip-sdk'
66+
These signers are used in the simplest way possible (i.e. using address accessors where needed,
67+
and `async signTransaction`-like methods), so developers may be able to easily inject their own
68+
implementations, to get called or intercept signature requests by these methods.
7069

71-
EVMChain.getWallet = async function(opts?: { provider?: Provider, wallet?: unknown }): Promise<Signer> {
72-
// instantiate Signer
73-
}
74-
```
70+
Optionally, `sendMessage` and `executeReport` also have companion `generateUnsignedSendMessage` and
71+
`generateUnsignedExecuteReport` methods, returning chain-family-specific unsigned data, which one
72+
can use to sign and send the transactions manually.
73+
74+
Notice that these are lower-level methods, and require the developer to handle the signing and
75+
sending of the transactions themselves, skipping niceties from the higher-level methods, like
76+
retries, gas estimation and transactions batching.
7577

7678
> [!TIP]
77-
> For EVMChain on Browsers, there's no need to override like the above, since providing a `{ wallet: number | address }` option object will make it create a signer from `provider.getSigner(number)`, which should load the account from the browser's wallet extension.
79+
> For EVMChain on Browsers, one can use `chain.provider.getSigner(numberOrAddress)` to fetch a
80+
provider-backed signer from compatible wallets, like Metamask.
7881

7982
## Tree-shakers
8083

ccip-sdk/src/aptos/index.ts

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
import util from 'util'
22

3-
import {
4-
Account,
5-
Aptos,
6-
AptosConfig,
7-
Ed25519PrivateKey,
8-
Network,
9-
TransactionResponseType,
10-
} from '@aptos-labs/ts-sdk'
3+
import { Aptos, AptosConfig, Network, TransactionResponseType } from '@aptos-labs/ts-sdk'
114
import {
125
type BytesLike,
136
concat,
@@ -61,7 +54,7 @@ import { executeReport } from './exec.ts'
6154
import { getAptosLeafHasher } from './hasher.ts'
6255
import { getUserTxByVersion, getVersionTimestamp, streamAptosLogs } from './logs.ts'
6356
import { getTokenInfo } from './token.ts'
64-
import { type AptosAsyncAccount, EVMExtraArgsV2Codec, SVMExtraArgsV1Codec } from './types.ts'
57+
import { EVMExtraArgsV2Codec, SVMExtraArgsV1Codec, isAptosAccount } from './types.ts'
6558
import type { CCIPMessage_V1_6_EVM } from '../evm/messages.ts'
6659
import {
6760
decodeMessage,
@@ -116,7 +109,6 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
116109
.then((modules) => modules.map(({ abi }) => abi!.name)),
117110
{ maxSize: 100, maxArgs: 1 },
118111
)
119-
this.getWallet = memoize(this.getWallet.bind(this), { maxSize: 1, maxArgs: 0 })
120112
this.provider.getTransactionByVersion = memoize(
121113
this.provider.getTransactionByVersion.bind(this.provider),
122114
{
@@ -292,25 +284,6 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
292284
}
293285
return registry
294286
}
295-
296-
static getWallet(_opts: { wallet?: unknown } = {}): Promise<AptosAsyncAccount> {
297-
return Promise.reject(new Error('TODO according to your environment'))
298-
}
299-
300-
// cached
301-
async getWallet(opts: { wallet?: unknown } = {}): Promise<AptosAsyncAccount> {
302-
if (isBytesLike(opts.wallet)) {
303-
return Account.fromPrivateKey({
304-
privateKey: new Ed25519PrivateKey(opts.wallet, false),
305-
})
306-
}
307-
return (this.constructor as typeof AptosChain).getWallet(opts)
308-
}
309-
310-
async getWalletAddress(opts?: { wallet?: unknown }): Promise<string> {
311-
return (await this.getWallet(opts)).accountAddress.toString()
312-
}
313-
314287
// Static methods for decoding
315288
static decodeMessage(log: {
316289
data: BytesLike | Record<string, unknown>
@@ -446,7 +419,12 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
446419
opts?: { wallet?: unknown; approveMax?: boolean },
447420
): Promise<CCIPRequest> {
448421
if (!message.fee) message.fee = await this.getFee(router, destChainSelector, message)
449-
const account = await this.getWallet(opts)
422+
const account = opts?.wallet
423+
if (!isAptosAccount(account)) {
424+
throw new Error(
425+
`${this.constructor.name}.sendMessage requires an Aptos account wallet, got=${util.inspect(opts?.wallet)}`,
426+
)
427+
}
450428

451429
const hash = await ccipSend(
452430
this.provider,
@@ -470,7 +448,12 @@ export class AptosChain extends Chain<typeof ChainFamily.Aptos> {
470448
execReport: ExecutionReport,
471449
opts?: { wallet?: unknown; gasLimit?: number },
472450
): Promise<ChainTransaction> {
473-
const account = await this.getWallet(opts)
451+
const account = opts?.wallet
452+
if (!isAptosAccount(account)) {
453+
throw new Error(
454+
`${this.constructor.name}.sendMessage requires an Aptos account wallet, got=${util.inspect(opts?.wallet)}`,
455+
)
456+
}
474457

475458
if (!('allowOutOfOrderExecution' in execReport.message && 'gasLimit' in execReport.message)) {
476459
throw new Error('Aptos expects EVMExtraArgsV2 reports')

ccip-sdk/src/aptos/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ export type AptosAsyncAccount = {
2020
) => Promise<AccountAuthenticator> | AccountAuthenticator
2121
}
2222

23+
export function isAptosAccount(account: unknown): account is AptosAsyncAccount {
24+
return (
25+
typeof account === 'object' &&
26+
account !== null &&
27+
'publicKey' in account &&
28+
'accountAddress' in account &&
29+
'signTransactionWithAuthenticator' in account
30+
)
31+
}
32+
2333
export const EVMExtraArgsV2Codec = bcs.struct('EVMExtraArgsV2', {
2434
gasLimit: bcs.u256(),
2535
allowOutOfOrderExecution: bcs.bool(),

0 commit comments

Comments
 (0)