Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/portfolio-api/src/resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
* Represents a published transaction with its type, optional amount, destination, and status.
*
* @typedef {object} PublishedTx
* @property {TxType} type - The type of transaction (CCTP_TO_EVM, GMP or CCTP_TO_AGORIC)
* @property {TxType} type - The type of transaction (CCTP_TO_EVM, GMP, CCTP_TO_AGORIC, or MAKE_ACCOUNT)
* @property {bigint} [amount] - Optional transaction amount as a bigint
* @property {AccountId} destinationAddress - The destination account identifier for the transaction
* @property {AccountId} destinationAddress - The destination account identifier for the transaction (CCTP/GMP destination, or MAKE_ACCOUNT factory address in CAIP format)
* @property {string} [expectedAddr] - The expected smart wallet hex address to be created (for MAKE_ACCOUNT only, format: 0x...)
* @property {TxStatus} status - Current status of the transaction (pending, success, or failed)
*/

Expand All @@ -35,5 +36,6 @@ export const TxType = /** @type {const} */ ({
CCTP_TO_EVM: 'CCTP_TO_EVM',
GMP: 'GMP',
CCTP_TO_AGORIC: 'CCTP_TO_AGORIC',
MAKE_ACCOUNT: 'MAKE_ACCOUNT',
});
harden(TxType);
11 changes: 11 additions & 0 deletions packages/portfolio-contract/src/resolver/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ export const PublishedTxShape: TypedPattern<PublishedTx> = M.or(
},
{},
),
// MAKE_ACCOUNT requires expectedAddr (hex) and destinationAddress is factory (CAIP)
M.splitRecord(
{
type: M.or(TxType.MAKE_ACCOUNT),
destinationAddress: M.string(),
expectedAddr: M.string(),
status: TxStatus.PENDING,
},
{},
{},
),
);

// Backwards compatibility
Expand Down
90 changes: 90 additions & 0 deletions services/ymax-planner/src/pending-tx-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import type {
} from './support.ts';
import { lookBackCctp, watchCctpTransfer } from './watchers/cctp-watcher.ts';
import { lookBackGmp, watchGmp } from './watchers/gmp-watcher.ts';
import {
watchSmartWalletTx,
lookBackSmartWalletTx,
} from './watchers/wallet-watcher.ts';

export type EvmChain = keyof typeof AxelarChain;

Expand All @@ -47,6 +51,7 @@ export type GmpTransfer = {

type CctpTx = PendingTx & { type: typeof TxType.CCTP_TO_EVM; amount: bigint };
type GmpTx = PendingTx & { type: typeof TxType.GMP };
type MakeAccountTx = PendingTx & { type: typeof TxType.MAKE_ACCOUNT };

type LiveWatchOpts = { mode: 'live'; timeoutMs: number };
type LookBackWatchOpts = {
Expand All @@ -71,6 +76,7 @@ export type PendingTxMonitor<
type MonitorRegistry = {
[TxType.CCTP_TO_EVM]: PendingTxMonitor<CctpTx, EvmContext>;
[TxType.GMP]: PendingTxMonitor<GmpTx, EvmContext>;
[TxType.MAKE_ACCOUNT]: PendingTxMonitor<MakeAccountTx, EvmContext>;
};

const cctpMonitor: PendingTxMonitor<CctpTx, EvmContext> = {
Expand Down Expand Up @@ -253,9 +259,93 @@ const gmpMonitor: PendingTxMonitor<GmpTx, EvmContext> = {
},
};

const makeAccountMonitor: PendingTxMonitor<MakeAccountTx, EvmContext> = {
watch: async (ctx, tx, log, opts) => {
await null;

const { txId, expectedAddr, destinationAddress } = tx;
const logPrefix = `[${txId}]`;

expectedAddr || Fail`${logPrefix} Missing expectedAddr`;
destinationAddress ||
Fail`${logPrefix} Missing destinationAddress (factory)`;

const {
namespace,
reference,
accountAddress: factoryAddr,
} = parseAccountId(destinationAddress);
const caipId: CaipChainId = `${namespace}:${reference}`;

const provider =
ctx.evmProviders[caipId] ||
Fail`${logPrefix} No EVM provider for chain: ${caipId}`;

const watchArgs = {
factoryAddr: factoryAddr as `0x${string}`,
provider,
expectedAddr: expectedAddr as `0x${string}`,
log: (msg, ...args) => log(logPrefix, msg, ...args),
};

let walletCreated: boolean | undefined;

if (opts.mode === 'live') {
walletCreated = await watchSmartWalletTx({
...watchArgs,
timeoutMs: opts.timeoutMs,
});
} else {
const abortController = new AbortController();
const liveResultP = watchSmartWalletTx({
...watchArgs,
timeoutMs: opts.timeoutMs,
signal: abortController.signal,
});
void liveResultP.then(found => {
if (found) {
log(`${logPrefix} Live mode completed`);
abortController.abort();
}
});

await null;

const currentBlock = await provider.getBlockNumber();
await waitForBlock(provider, currentBlock + 1);

walletCreated = await lookBackSmartWalletTx({
...watchArgs,
publishTimeMs: opts.publishTimeMs,
chainId: caipId,
signal: abortController.signal,
});

if (walletCreated) {
log(`${logPrefix} Lookback found wallet creation`);
abortController.abort();
} else {
log(
`${logPrefix} Lookback completed without finding wallet creation, waiting for live mode`,
);
walletCreated = await liveResultP;
}
}

await resolvePendingTx({
signingSmartWalletKit: ctx.signingSmartWalletKit,
txId,
status: walletCreated ? TxStatus.SUCCESS : TxStatus.FAILED,
});

log(`${logPrefix} MAKE_ACCOUNT tx resolved`);
},
};

const createMonitorRegistry = (): MonitorRegistry => ({
[TxType.CCTP_TO_EVM]: cctpMonitor,
[TxType.GMP]: gmpMonitor,
[TxType.MAKE_ACCOUNT]: makeAccountMonitor,
});

export type HandlePendingTxOpts = {
Expand Down
174 changes: 174 additions & 0 deletions services/ymax-planner/src/watchers/wallet-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type { Filter, WebSocketProvider, Log } from 'ethers';
import { id, zeroPadValue, getAddress, AbiCoder } from 'ethers';
import type { CaipChainId } from '@agoric/orchestration';
import {
getBlockNumberBeforeRealTime,
scanEvmLogsInChunks,
} from '../support.ts';
import { TX_TIMEOUT_MS } from '../pending-tx-manager.ts';

const SMART_WALLET_CREATED_SIGNATURE = id(
'SmartWalletCreated(address,string,string,string)',
);
const abiCoder = new AbiCoder();

const extractAddress = topic => {
return getAddress(`0x${topic.slice(-40)}`);
};

const parseSmartWalletCreatedLog = (log: any) => {
if (!log.topics || !log.data) {
throw new Error('Malformed SmartWalletCreated log');
}

const wallet = extractAddress(log.topics[1]);

const [owner, sourceChain, sourceAddress] = abiCoder.decode(
['string', 'string', 'string'],
log.data,
);

return {
wallet,
owner,
sourceChain,
sourceAddress,
};
};

type SmartWalletWatch = {
factoryAddr: `0x${string}`;
provider: WebSocketProvider;
expectedAddr: `0x${string}`;
log?: (...args: unknown[]) => void;
};

export const watchSmartWalletTx = ({
factoryAddr,
provider,
expectedAddr,
timeoutMs = TX_TIMEOUT_MS,
log = () => {},
setTimeout = globalThis.setTimeout,
signal,
}: SmartWalletWatch & {
timeoutMs?: number;
setTimeout?: typeof globalThis.setTimeout;
signal?: AbortSignal;
}): Promise<boolean> => {
return new Promise(resolve => {
if (signal?.aborted) {
resolve(false);
return;
}

const TO_TOPIC = zeroPadValue(expectedAddr.toLowerCase(), 32);
const filter = {
address: factoryAddr,
topics: [SMART_WALLET_CREATED_SIGNATURE, TO_TOPIC],
};

log(`Watching SmartWalletCreated events emitted by ${factoryAddr}`);

let walletCreated = false;
let timeoutId: NodeJS.Timeout;
let listeners: Array<{ event: any; listener: any }> = [];

const finish = (result: boolean) => {
resolve(result);
if (timeoutId) clearTimeout(timeoutId);
for (const { event, listener } of listeners) {
void provider.off(event, listener);
}
listeners = [];
};

signal?.addEventListener('abort', () => finish(false));

const listenForSmartWalletCreation = (eventLog: Log) => {
let eventData;
try {
eventData = parseSmartWalletCreatedLog(eventLog);
} catch (error: any) {
log(`Log parsing error:`, error.message);
return;
}

const { wallet } = eventData;

log(`SmartWalletCreated event detected: wallet:${wallet}`);

if (wallet === expectedAddr) {
log(`✓ Address matches! Expected: ${expectedAddr}, Found: ${wallet}`);
walletCreated = true;
finish(true);
return;
}
log(`Address mismatch. Expected: ${expectedAddr}, Found: ${wallet}`);
};

void provider.on(filter, listenForSmartWalletCreation);
listeners.push({ event: filter, listener: listenForSmartWalletCreation });

timeoutId = setTimeout(() => {
if (!walletCreated) {
log(
`✗ No matching SmartWalletCreated event found within ${timeoutMs / 60000} minutes`,
);
}
}, timeoutMs);
});
};

export const lookBackSmartWalletTx = async ({
factoryAddr,
provider,
expectedAddr,
publishTimeMs,
chainId,
log = () => {},
signal,
}: SmartWalletWatch & {
publishTimeMs: number;
chainId: CaipChainId;
signal?: AbortSignal;
}): Promise<boolean> => {
await null;
try {
const fromBlock = await getBlockNumberBeforeRealTime(
provider,
publishTimeMs,
);
const toBlock = await provider.getBlockNumber();

log(
`Searching blocks ${fromBlock} → ${toBlock} for SmartWalletCreated events emitted by ${factoryAddr}`,
);

const toTopic = zeroPadValue(expectedAddr.toLowerCase(), 32);
const baseFilter: Filter = {
address: factoryAddr,
topics: [SMART_WALLET_CREATED_SIGNATURE, toTopic],
};

const matchingEvent = await scanEvmLogsInChunks(
{ provider, baseFilter, fromBlock, toBlock, chainId, log, signal },
ev => {
try {
const t = parseSmartWalletCreatedLog(ev);
log(`Check: addresss=${t.wallet}`);
return t.wallet === expectedAddr;
} catch (e) {
log(`Parse error:`, e);
return false;
}
},
);

if (!matchingEvent) log(`No matching SmartWalletCreated event found`);
return !!matchingEvent;
} catch (error) {
log(`Error:`, error);
return false;
}
};
Loading
Loading