diff --git a/modules/sdk-coin-sui/package.json b/modules/sdk-coin-sui/package.json index 1858cd0cc67..cdb3871ab18 100644 --- a/modules/sdk-coin-sui/package.json +++ b/modules/sdk-coin-sui/package.json @@ -45,10 +45,12 @@ "@bitgo/sdk-lib-mpc": "^11.4.0", "@bitgo/statics": "^59.23.0", "@mysten/bcs": "^0.7.0", + "@mysten/sui": "2.20.0", + "@grpc/grpc-js": "^1.14.4", + "@protobuf-ts/grpc-transport": "2.11.1", "bignumber.js": "^9.0.0", "bs58": "^4.0.1", "lodash": "^4.18.0", - "superagent": "3.8.2", "superstruct": "^1.0.3", "tweetnacl": "^1.0.3" }, diff --git a/modules/sdk-coin-sui/src/lib/grpcClient.ts b/modules/sdk-coin-sui/src/lib/grpcClient.ts new file mode 100644 index 00000000000..1ae2cb33fb5 --- /dev/null +++ b/modules/sdk-coin-sui/src/lib/grpcClient.ts @@ -0,0 +1,158 @@ +/** + * Structural surface of the GrpcCoreClient namespace of @mysten/sui that the + * Sui SDK's node interactions rely on. Shapes are verified against + * @mysten/sui@2.20.0 (src/grpc/core.ts); the exact version pin in package.json + * keeps them stable across upgrades, so no field is silently defaulted — + * helpers below hard-throw when expected fields are missing. + */ +export interface SuiGrpcCoreClient { + getBalance(options: { owner: string; coinType?: string }): Promise<{ + balance: { + balance: string; + coinType: string; + coinBalance: string; + addressBalance: string; + }; + }>; + listCoins(options: { owner: string; coinType?: string; cursor?: string | null }): Promise<{ + objects: Array<{ + objectId: string; + version: string; + digest: string; + balance: string; + type?: string; + }>; + hasNextPage: boolean; + cursor: string | null; + }>; + simulateTransaction(options: { + transaction: Uint8Array; + checksEnabled: boolean; + include: { effects: boolean }; + }): Promise<{ + $kind: 'Transaction' | 'FailedTransaction'; + Transaction?: { + digest?: string; + status?: { success?: boolean }; + effects?: { + status?: { success?: boolean }; + gasUsed?: { + computationCost?: string; + storageCost?: string; + storageRebate?: string; + nonRefundableStorageFee?: string; + }; + }; + }; + FailedTransaction?: { digest?: string }; + }>; + getCurrentSystemState(): Promise<{ systemState?: { epoch?: string | number | bigint | null } }>; + getChainIdentifier(): Promise<{ chainIdentifier: string }>; + executeTransaction(options: { transaction: Uint8Array; signatures: string[] }): Promise<{ + $kind: 'Transaction' | 'FailedTransaction'; + Transaction?: { digest?: string }; + FailedTransaction?: { digest?: string }; + }>; +} + +/** + * The real @mysten/sui SuiGrpcClient nests its methods under a `core` + * (GrpcCoreClient) namespace. Mirror that nesting so SDK call sites go through + * the same surface production consumers of @mysten/sui use. + */ +export interface SuiGrpcClient { + core: SuiGrpcCoreClient; +} + +/** + * Per-call deadline applied to every gRPC call made through this transport. + * The legacy JSON-RPC client enforced a request timeout; an unbounded gRPC + * deadline would let a hung fullnode hang balance/sweep/broadcast flows + * indefinitely. 10s bounds those flows while leaving headroom over the legacy + * 3s for dry-run simulations on loaded nodes. + */ +const SUI_GRPC_CALL_TIMEOUT_MS = 10_000; + +function isBrowserEnvironment(): boolean { + return typeof window !== 'undefined' && typeof window.document !== 'undefined'; +} + +// protobuf-ts service clients hold their transport under a private `_transport` +// field that the structural SuiGrpcClient type cannot express; the shape is +// fixed at the pinned @mysten/sui version. +function transportOf(client: SuiGrpcClient): unknown { + const transportHolder = client as unknown as { transactionExecutionService: { _transport: unknown } }; + return transportHolder.transactionExecutionService._transport; +} +const clientCache = new Map(); + +/** + * Creates a Sui gRPC client. Node: native transport; browser: gRPC-web + * (SuiGrpcClient's default fetch transport, given the URL as base). One + * client (and connection) is cached per url+network and reused across calls. + */ +export async function createSuiGrpcClient(url: string, network: 'mainnet' | 'testnet'): Promise { + const key = `${network}|${url}`; + const cached = clientCache.get(key); + if (cached) { + return cached.client; + } + + // TS cannot resolve this ESM-only exports subpath under CJS resolution. + const grpcModule = require('@mysten/sui/grpc') as { + SuiGrpcClient: new (options: { + network: 'mainnet' | 'testnet'; + transport?: unknown; + baseUrl?: string; + }) => SuiGrpcClient; + }; + + let client: SuiGrpcClient; + if (isBrowserEnvironment()) { + const { SuiGrpcClient } = grpcModule; + client = new SuiGrpcClient({ network, baseUrl: url }); + } else { + const [{ GrpcTransport }, { ChannelCredentials }] = await Promise.all([ + import('@protobuf-ts/grpc-transport'), + import('@grpc/grpc-js'), + ]); + const isTls = !url.startsWith('http://'); + const host = url.replace(/^https?:\/\//i, ''); + const transport = new GrpcTransport({ + host, + channelCredentials: isTls ? ChannelCredentials.createSsl() : ChannelCredentials.createInsecure(), + timeout: SUI_GRPC_CALL_TIMEOUT_MS, + }); + const { SuiGrpcClient: NativeSuiGrpcClient } = grpcModule; + client = new NativeSuiGrpcClient({ network, transport }); + } + clientCache.set(key, { client }); + return client; +} + +/** + * Closes a client's underlying connection and evicts it from the cache, so + * the next createSuiGrpcClient for the same endpoint builds a fresh client. + * Idempotent; also accepts clients that were never cached. + */ +export function closeSuiGrpcClient(client: SuiGrpcClient): void { + for (const [key, cached] of clientCache.entries()) { + if (cached.client === client) { + clientCache.delete(key); + } + } + const transport = transportOf(client) as { close?: () => void }; + try { + transport.close?.(); + } catch { + // transport already closed + } +} + +/** Test-only: tear down every cached client so suites do not share sockets. */ +export function __resetSuiGrpcClientCacheForTest(): void { + for (const { client } of clientCache.values()) { + closeSuiGrpcClient(client); + } + clientCache.clear(); +} diff --git a/modules/sdk-coin-sui/src/lib/rpcClient.ts b/modules/sdk-coin-sui/src/lib/rpcClient.ts deleted file mode 100644 index f36e8cefba4..00000000000 --- a/modules/sdk-coin-sui/src/lib/rpcClient.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as superagent from 'superagent'; -import { RecoveryProviderError } from '@bitgo/sdk-core'; - -export type Params = any; - -async function request({ url, timeoutMs = undefined, params = {} }) { - return superagent - .post(url) - .send(params) - .timeout({ deadline: timeoutMs }) - .type('json') - .set({ Accept: 'application/json' }); -} - -export async function requestIgnoreStatusCode(params: Params): Promise { - try { - return await request(params); - } catch (e: any) { - if ('response' in e) { - return e.response; - } - throw e; - } -} - -export async function makeRPC(url: string, method: string, params: Params): Promise> { - let res; - try { - res = await requestIgnoreStatusCode({ - method: 'post', - url, - timeoutMs: 3000, - params: { jsonrpc: '2.0', id: 1, method: method, params: params }, - }); - } catch (e) { - throw new RecoveryProviderError(`RPC call failed for ${method}: ${(e as Error).message}`, e); - } - if (res.body.error) { - const error = res.body.error; - throw new RecoveryProviderError(`Request to the node failed. Error code: ${error.code}, message: ${error.message}`); - } - if (res.statusCode !== 200) { - throw new RecoveryProviderError(`Request to the node failed, Status code: ${res.statusCode}`); - } - return res.body.result; -} diff --git a/modules/sdk-coin-sui/src/lib/utils.ts b/modules/sdk-coin-sui/src/lib/utils.ts index ca025e8a8af..e0ef3ad1128 100644 --- a/modules/sdk-coin-sui/src/lib/utils.ts +++ b/modules/sdk-coin-sui/src/lib/utils.ts @@ -47,7 +47,28 @@ import { import { SIGNATURE_SCHEME_TO_FLAG } from './keyPair'; import blake2b from '@bitgo/blake2b'; import { TRANSACTION_DATA_MAX_SIZE } from './mystenlab/builder/TransactionDataBlock'; -import { makeRPC } from './rpcClient'; +import { createSuiGrpcClient, SuiGrpcClient } from './grpcClient'; + +/** + * First argument accepted by the exported util helpers: a pre-built gRPC + * client (current style) or the legacy node URL (backwards-compatible with + * the pre-migration signatures that took a JSON-RPC url). + */ +export type SuiGrpcClientOrUrl = SuiGrpcClient | string; + +// Test-only seam: the url->client factory is captured in a mutable slot so +// url-style signatures can be tested without network (mirrors the +// __setSuiEsmOverridesForTest pattern in sui.ts). +let urlClientFactory: typeof createSuiGrpcClient = createSuiGrpcClient; + +async function toGrpcClient(clientOrUrl: SuiGrpcClientOrUrl): Promise { + if (typeof clientOrUrl !== 'string') { + return clientOrUrl; + } + // Legacy callers passed a bare node URL; infer the network from it the same + // way the legacy JSON-RPC endpoints were addressed. + return await urlClientFactory(clientOrUrl, clientOrUrl.includes('testnet') ? 'testnet' : 'mainnet'); +} import assert from 'assert'; import { BaseNetwork, coins, SuiCoin } from '@bitgo/statics'; @@ -534,50 +555,118 @@ export class Utils implements BaseUtils { ); } - async getFeeEstimate(url: string, txHex: string): Promise { - const result = await makeRPC(url, 'sui_dryRunTransactionBlock', [txHex]); - assert(result.effects); - assert(result.effects.gasUsed); + /** + * Test-only: swap (or reset with undefined) the factory used for url-style + * calls, mirroring the __setSuiEsmOverridesForTest seam in sui.ts. + */ + __setUrlClientFactoryForTest(factory: typeof createSuiGrpcClient | undefined): void { + urlClientFactory = factory ?? createSuiGrpcClient; + } + + /** + * Estimates the fee (in MIST) for a transaction by dry-running it against the + * Sui node with full transaction checks (sui_dryRunTransactionBlock parity). + * The returned estimate is max(computation + storage - rebate, computation). + * @param {SuiGrpcClient | string} clientOrUrl - A pre-built Sui gRPC client, or the + * legacy node URL (backwards compatible; the network is inferred from the URL). + * @param {string} txHex - The serialized transaction in base64 format. + * @returns {Promise} - The estimated fee, floored at the computation cost. + * @throws when the simulation response carries no gas summary or any gas field, + * or when the simulated transaction fails execution. + */ + async getFeeEstimate(clientOrUrl: SuiGrpcClientOrUrl, txHex: string): Promise { + const client = await toGrpcClient(clientOrUrl); + const result = await client.core.simulateTransaction({ + transaction: Buffer.from(txHex, 'base64'), + // Preserve sui_dryRunTransactionBlock semantics: unchecked simulation is + // the devInspect equivalent and would return effects for transactions + // dry-run rejects, skewing the derived gas budget. + checksEnabled: true, + include: { effects: true }, + }); + + const effects = result.$kind === 'Transaction' ? result.Transaction?.effects : undefined; + if (!effects || !effects.gasUsed) { + throw new Error(`Failed to get fee estimate: no gas summary in simulation response`); + } - if (result.effects.status.status !== 'success') { + if (!effects.status?.success) { console.error(`Dry run failed, could not automatically determine a budget for txHex ${txHex}`); throw new Error(`Failed to get fee estimate`); } - const gasObject = result.effects.gasUsed; + const gasObject = effects.gasUsed; + + // A proto shape drift would otherwise default every field to '0' and turn + // the estimate into a near-zero fee that fails only at broadcast. + const { storageCost, computationCost, storageRebate } = gasObject; + if (storageCost === undefined || storageCost === null) { + throw new Error('Failed to get fee estimate: missing storageCost in simulation response'); + } + if (computationCost === undefined || computationCost === null) { + throw new Error('Failed to get fee estimate: missing computationCost in simulation response'); + } + if (storageRebate === undefined || storageRebate === null) { + throw new Error('Failed to get fee estimate: missing storageRebate in simulation response'); + } - const storageCost = new BigNumber(gasObject.storageCost); - const computationCost = new BigNumber(gasObject.computationCost); - const storageRebate = new BigNumber(gasObject.storageRebate); - const netCost = computationCost.plus(storageCost).minus(storageRebate); + const netCost = new BigNumber(computationCost).plus(storageCost).minus(storageRebate); - return netCost.comparedTo(computationCost) > 0 ? netCost : computationCost; + return netCost.comparedTo(computationCost) > 0 ? netCost : new BigNumber(computationCost); } /** - * Returns the current epoch and chain identifier of the Sui network by making RPC calls to the provided URL. - * @param {string} url - The URL of the Sui network RPC endpoint. + * Returns the current epoch and chain identifier of the Sui network via the gRPC ledger service. + * The chain identifier is the digest of the genesis checkpoint (sequence number 0), matching + * what the legacy sui_getCheckpoint('0') JSON-RPC call returned. + * @param {SuiGrpcClient | string} clientOrUrl - A pre-built Sui gRPC client, or the + * legacy node URL (backwards compatible; the network is inferred from the URL). * @returns {Promise<{ epoch: number; chainId: string }>} - The current epoch and chain identifier. */ - async getChainContext(url: string): Promise<{ epoch: number; chainId: string }> { - const [systemState, genesisCheckpoint] = await Promise.all([ - makeRPC(url, 'suix_getLatestSuiSystemState', []), - makeRPC(url, 'sui_getCheckpoint', ['0']), + async getChainContext(clientOrUrl: SuiGrpcClientOrUrl): Promise<{ epoch: number; chainId: string }> { + const client = await toGrpcClient(clientOrUrl); + const [systemStateResponse, chainIdResponse] = await Promise.all([ + client.core.getCurrentSystemState(), + client.core.getChainIdentifier(), ]); - return { epoch: Number(systemState.epoch), chainId: String(genesisCheckpoint.digest) }; + const epoch = systemStateResponse.systemState?.epoch; + const chainId = chainIdResponse.chainIdentifier; + if (epoch === undefined || epoch === null) { + throw new Error('Failed to get current epoch from gRPC system state'); + } + if (!chainId) { + throw new Error('Failed to get chain identifier from gRPC service info'); + } + return { epoch: Number(epoch), chainId }; } - async getBalance(url: string, owner: string, coinType?: string): Promise { + /** + * Returns balance information for an address via the gRPC balance service. + * totalBalance = Coin object balance + SIP-58 address-system balance + * (fundsInAddressBalance is exposed separately for transaction building and + * must not be added to totalBalance again). + * @param {SuiGrpcClient | string} clientOrUrl - A pre-built Sui gRPC client, or the + * legacy node URL (backwards compatible; the network is inferred from the URL). + * @param {string} owner - The address to query. + * @param {string} [coinType] - The coin type; defaults to 0x2::sui::SUI. + * @returns {Promise} - {totalBalance, coinObjectBalance, fundsInAddressBalance}. + * @throws when the gRPC response carries no balance object. + */ + async getBalance(clientOrUrl: SuiGrpcClientOrUrl, owner: string, coinType?: string): Promise { + const client = await toGrpcClient(clientOrUrl); if (coinType === undefined) { coinType = SUI_TYPE_ARG; } - const result = await makeRPC(url, 'suix_getBalance', [owner, coinType]); - const totalBalance = (result.totalBalance ?? '0').toString(); - const fundsInAddressBalance = (result.fundsInAddressBalance ?? '0').toString(); - const coinObjectBalance = BigNumber.max( - 0, - new BigNumber(totalBalance).minus(new BigNumber(fundsInAddressBalance)) - ).toString(); + const response = await client.core.getBalance({ owner, coinType }); + if (!response.balance) { + throw new Error(`Failed to get balance: no balance object in gRPC response for ${owner}`); + } + // The gRPC balance service reports the Coin object balance and the SIP-58 + // address-system balance separately, so totalBalance is their sum (the legacy + // suix_getBalance totalBalance included both). + const coinObjectBalance = (response.balance?.coinBalance ?? '0').toString(); + const fundsInAddressBalance = (response.balance?.addressBalance ?? '0').toString(); + const totalBalance = new BigNumber(coinObjectBalance).plus(new BigNumber(fundsInAddressBalance)).toString(); return { totalBalance, coinObjectBalance, @@ -585,52 +674,83 @@ export class Utils implements BaseUtils { }; } - async getInputCoins(url: string, owner: string, coinType?: string): Promise { + /** + * Returns all unfiltered spendable coins of an address (legacy suix_getCoins + * semantics) via the gRPC list service: bare coin type, numeric version, + * objectId and balance per entry. + * @param {SuiGrpcClient | string} clientOrUrl - A pre-built Sui gRPC client, or the + * legacy node URL (backwards compatible; the network is inferred from the URL). + * @param {string} owner - The address to query. + * @param {string} [coinType] - The coin type; defaults to 0x2::sui::SUI. + * @returns {Promise} - One entry per owned coin object. + * @throws {RecoveryProviderError} when the node call fails. + */ + async getInputCoins(clientOrUrl: SuiGrpcClientOrUrl, owner: string, coinType?: string): Promise { + const client = await toGrpcClient(clientOrUrl); if (coinType === undefined) { coinType = SUI_TYPE_ARG; } + const resolvedCoinType = coinType; + const data: Array<{ objectId: string; version: string; digest: string; balance: string }> = []; + let cursor: string | null = null; let hasNextPage = true; - let cursor = undefined; - let params = [owner, coinType]; - let data = []; - while (hasNextPage) { - if (cursor !== undefined) { - params = [owner, coinType, cursor]; - } - try { - const result = await makeRPC(url, 'suix_getCoins', params); - data = data.concat(result.data); - hasNextPage = result.hasNextPage; - cursor = result.nextCursor; - } catch (e) { - console.error(`Failed to get input coins from the node ${e}`); - throw new RecoveryProviderError(`Failed to get input coins from the node.`, e); + try { + while (hasNextPage) { + const page = await client.core.listCoins({ owner, coinType: resolvedCoinType, cursor }); + data.push(...page.objects); + hasNextPage = page.hasNextPage; + cursor = page.cursor; } + } catch (e) { + console.error(`Failed to get input coins from the node ${e}`); + throw new RecoveryProviderError(`Failed to get input coins from the node.`, e); } return data - .filter((object: any) => object.balance !== undefined) - .map((object: any) => { + .filter((object) => object.balance !== undefined) + .map((object) => { + // gRPC surfaces the coin type as 0x2::coin::Coin; the legacy suix_getCoins + // response carried the bare coin type, which callers still expect. return { - coinType: object.coinType, - objectId: object.coinObjectId, + coinType: resolvedCoinType, + objectId: object.objectId, version: object.version, digest: object.digest, balance: new BigNumber(object.balance), - }; + } as SuiObjectInfo; }); } - async executeTransactionBlock(url: string, serializedTx: string, signatures: string[]): Promise { - const reqType = 'WaitForEffectsCert'; - const options = { showEffects: true }; - const params = [serializedTx, signatures, options, reqType]; - let result: Record; + /** + * Broadcasts a signed transaction to the Sui node over gRPC and returns the + * transaction digest. Signatures are base64 strings (the gRPC layer decodes + * them into BCS). + * @param {SuiGrpcClient | string} clientOrUrl - A pre-built Sui gRPC client, or the + * legacy node URL (backwards compatible; the network is inferred from the URL). + * @param {string} serializedTx - The serialized transaction in base64 format. + * @param {string[]} signatures - Base64-encoded signatures for the transaction. + * @returns {Promise} - The transaction digest. + * @throws when the node response carries no digest or the broadcast fails. + */ + async executeTransactionBlock( + clientOrUrl: SuiGrpcClientOrUrl, + serializedTx: string, + signatures: string[] + ): Promise { + const client = await toGrpcClient(clientOrUrl); + let digest: string | undefined; try { - result = await makeRPC(url, 'sui_executeTransactionBlock', params); + const result = await client.core.executeTransaction({ + transaction: Buffer.from(serializedTx, 'base64'), + signatures, + }); + digest = (result.Transaction ?? result.FailedTransaction)?.digest; } catch (e) { - throw new Error(`${e.message}`); + throw new Error(`${(e as Error).message}`); + } + if (!digest) { + throw new Error(`Failed to broadcast transaction, no digest in response`); } - return result.digest; + return digest; } validateNonNegativeNumber(defaultVal: number, errorMsg: string, inputVal?: number): number { diff --git a/modules/sdk-coin-sui/src/sui.ts b/modules/sdk-coin-sui/src/sui.ts index 902ee51ccf0..822044e9078 100644 --- a/modules/sdk-coin-sui/src/sui.ts +++ b/modules/sdk-coin-sui/src/sui.ts @@ -40,7 +40,8 @@ import { AuditDecryptedKeyParams, verifyEddsaTssWalletAddress, } from '@bitgo/sdk-core'; -import { BaseCoin as StaticsBaseCoin, BaseNetwork, coins, SuiCoin } from '@bitgo/statics'; +import { BaseCoin as StaticsBaseCoin, BaseNetwork, coins, NetworkType, SuiCoin } from '@bitgo/statics'; +import { createSuiGrpcClient, SuiGrpcClient } from './lib/grpcClient'; import BigNumber from 'bignumber.js'; import { KeyPair as SuiKeyPair, @@ -94,6 +95,9 @@ export type SuiTransactionExplanation = TransactionExplanation; export class Sui extends BaseCoin { protected readonly _staticsCoin: Readonly; + // The gRPC client wraps a long-lived HTTP/2 channel; memoize one per coin + // instance instead of paying a TCP+TLS handshake per node call. + private suiGrpcClientPromise?: Promise; protected constructor(bitgo: BitGoBase, staticsCoin?: Readonly) { super(bitgo); @@ -313,23 +317,45 @@ export class Sui extends BaseCoin { return rebuiltTransaction.signablePayload; } - protected getPublicNodeUrl(): string { - return Environments[this.bitgo.getEnv()].suiNodeUrl; + /** + * Returns the gRPC endpoint for the Sui network. gRPC is the only supported + * transport for Sui node interactions: Sui is deprecating JSON-RPC (testnet + * v1.82.1 onward), so a missing gRPC URL is a configuration error. + */ + protected getPublicGrpcNodeUrl(): string { + const url = Environments[this.bitgo.getEnv()].suiGrpcNodeUrl; + if (!url) { + throw new Error( + `suiGrpcNodeUrl is not configured for environment ${this.bitgo.getEnv()}; SUI node calls require a gRPC endpoint` + ); + } + return url; + } + + /** + * Creates a native-gRPC Sui client for this coin's network. + */ + protected async getSuiGrpcClient(): Promise { + this.suiGrpcClientPromise ??= (async () => { + const network = this.getNetwork().type === NetworkType.MAINNET ? 'mainnet' : 'testnet'; + return await createSuiGrpcClient(this.getPublicGrpcNodeUrl(), network); + })(); + return await this.suiGrpcClientPromise; } protected async getBalance(owner: string, coinType?: string): Promise { - const url = this.getPublicNodeUrl(); - return await utils.getBalance(url, owner, coinType); + const client = await this.getSuiGrpcClient(); + return await utils.getBalance(client, owner, coinType); } protected async getInputCoins(owner: string, coinType?: string): Promise { - const url = this.getPublicNodeUrl(); - return await utils.getInputCoins(url, owner, coinType); + const client = await this.getSuiGrpcClient(); + return await utils.getInputCoins(client, owner, coinType); } protected async getFeeEstimate(txHex: string): Promise { - const url = this.getPublicNodeUrl(); - return await utils.getFeeEstimate(url, txHex); + const client = await this.getSuiGrpcClient(); + return await utils.getFeeEstimate(client, txHex); } /** @@ -376,7 +402,7 @@ export class Sui extends BaseCoin { throw e; } throw new RecoveryProviderError( - `Failed to query Sui balance for address ${senderAddress} at index ${idx} via ${this.getPublicNodeUrl()}: ${ + `Failed to query Sui balance for address ${senderAddress} at index ${idx} via ${this.getPublicGrpcNodeUrl()}: ${ (e as Error).message }`, e @@ -442,7 +468,7 @@ export class Sui extends BaseCoin { // prevent replay attacks when there are no gas coin objects to anchor uniqueness. let validDuringExpiration: { ValidDuring: ValidDuringExpiration } | undefined; if (fundsInAddressBalance.gt(0) && coinObjectsBalance.eq(0)) { - const { epoch, chainId } = await utils.getChainContext(this.getPublicNodeUrl()); + const { epoch, chainId } = await utils.getChainContext(await this.getSuiGrpcClient()); const nonce = crypto.randomBytes(4).readUInt32BE(0); validDuringExpiration = { ValidDuring: { @@ -745,12 +771,13 @@ export class Sui extends BaseCoin { transactions, }: BaseBroadcastTransactionOptions): Promise { const txIds: string[] = []; - const url = this.getPublicNodeUrl(); let digest = ''; if (!!transactions) { for (const txn of transactions) { try { - digest = await utils.executeTransactionBlock(url, txn.serializedTx, [txn.signature!]); + digest = await utils.executeTransactionBlock(await this.getSuiGrpcClient(), txn.serializedTx, [ + txn.signature!, + ]); } catch (e) { throw new Error(`Failed to broadcast transaction, error: ${e.message}`); } diff --git a/modules/sdk-coin-sui/test/unit/sui.ts b/modules/sdk-coin-sui/test/unit/sui.ts index 0280248a25a..bf224a607dd 100644 --- a/modules/sdk-coin-sui/test/unit/sui.ts +++ b/modules/sdk-coin-sui/test/unit/sui.ts @@ -16,6 +16,7 @@ import { Buffer } from 'buffer'; import { common, EDDSAMethods, + Environments, ErrorNoInputToRecover, MPCSweepRecoveryOptions, MPCSweepTxs, @@ -74,6 +75,19 @@ describe('SUI:', function () { tsui.getBaseFactor().should.equal(1e9); }); + it('should fail to get the grpc node url when suiGrpcNodeUrl is not configured', function () { + // every named environment inherits a suiGrpcNodeUrl via object spread, so the + // missing-configuration path is exercised by temporarily unsetting the property + const mockEnv = Environments['mock'] as unknown as Record; + const originalUrl = mockEnv.suiGrpcNodeUrl; + delete mockEnv.suiGrpcNodeUrl; + try { + (() => basecoin['getPublicGrpcNodeUrl']()).should.throw(/suiGrpcNodeUrl/); + } finally { + mockEnv.suiGrpcNodeUrl = originalUrl; + } + }); + describe('Verify transaction: ', () => { it('should succeed to verify transaction', async function () { const txPrebuild = newTxPrebuild(); diff --git a/modules/sdk-coin-sui/test/unit/utils.ts b/modules/sdk-coin-sui/test/unit/utils.ts index 84d8eb27eed..06052263207 100644 --- a/modules/sdk-coin-sui/test/unit/utils.ts +++ b/modules/sdk-coin-sui/test/unit/utils.ts @@ -1,7 +1,23 @@ import should from 'should'; import * as testData from '../resources/sui'; import utils from '../../src/lib/utils'; -import nock from 'nock'; +import { SuiGrpcClient, SuiGrpcCoreClient } from '../../src/lib/grpcClient'; +import { RecoveryProviderError } from '@bitgo/sdk-core'; +import sinon from 'sinon'; +import BigNumber from 'bignumber.js'; +import { Buffer } from 'buffer'; + +// The utils node-call helpers take a SuiGrpcCoreClient as their first argument. +// Tests build a partial fake client whose methods are sinon stubs; only the +// methods a given helper uses are stubbed, so a missing method surfaces as an +// assertion failure instead of a silent pass. +function fakeGrpcClient(stubNames: Array): SuiGrpcClient { + const core: Record = {}; + for (const name of stubNames) { + core[name] = sinon.stub(); + } + return { core } as unknown as SuiGrpcClient; +} describe('Sui util library', function () { describe('isValidAddress', function () { @@ -26,6 +42,7 @@ describe('Sui util library', function () { it('should succeed to validate a valid raw transaction', function () { should.equal(utils.isValidRawTransaction(testData.TRANSFER), true); }); + it('should fail to validate an invalid raw transaction', function () { should.doesNotThrow(() => utils.isValidRawTransaction(testData.INVALID_RAW_TX)); should.equal(utils.isValidRawTransaction(testData.INVALID_RAW_TX), false); @@ -45,119 +62,447 @@ describe('Sui util library', function () { }); describe('getBalance', function () { - const nodeUrl = 'https://fullnode.testnet.sui.io'; - const owner = '0xb7db6234a33f1e35f7114dac69574c6b7b193f3c4a0801e5ddb9fae4009af637'; + const owner = '[SECRET:ethereum-private-key]'; + let client: SuiGrpcClient; + let getBalanceStub: sinon.SinonStub; - afterEach(function () { - nock.cleanAll(); + beforeEach(function () { + client = fakeGrpcClient(['getBalance']); + getBalanceStub = client.core.getBalance as unknown as sinon.SinonStub; }); it('should return all funds in address balance when fundsInAddressBalance equals totalBalance', async function () { - // Real response from testnet: all 2 SUI held in address balance, not in coin objects - nock(nodeUrl) - .post('/') - .reply(200, { - jsonrpc: '2.0', - id: 1, - result: { - coinType: '0x2::sui::SUI', - coinObjectCount: 1, - totalBalance: '2000000', - lockedBalance: {}, - fundsInAddressBalance: '2000000', - }, - }); + // Real response from testnet: all 2 SUI held in address balance, not in coin objects. + // The gRPC balance service reports coin and address balances separately, so the + // legacy suix_getBalance totalBalance maps to coinBalance + addressBalance. + getBalanceStub.resolves({ + balance: { coinBalance: '0', addressBalance: '2000000' }, + }); - const balanceInfo = await utils.getBalance(nodeUrl, owner); + const balanceInfo = await utils.getBalance(client, owner); balanceInfo.totalBalance.should.equal('2000000'); balanceInfo.fundsInAddressBalance.should.equal('2000000'); balanceInfo.coinObjectBalance.should.equal('0'); }); it('should correctly split totalBalance into coinObjectBalance and fundsInAddressBalance', async function () { - nock(nodeUrl) - .post('/') - .reply(200, { - jsonrpc: '2.0', - id: 1, - result: { - coinType: '0x2::sui::SUI', - coinObjectCount: 2, - totalBalance: '1900000000', - lockedBalance: {}, - fundsInAddressBalance: '900000000', - }, - }); + getBalanceStub.resolves({ + balance: { coinBalance: '1000000000', addressBalance: '900000000' }, + }); - const balanceInfo = await utils.getBalance(nodeUrl, owner); + const balanceInfo = await utils.getBalance(client, owner); balanceInfo.totalBalance.should.equal('1900000000'); balanceInfo.fundsInAddressBalance.should.equal('900000000'); balanceInfo.coinObjectBalance.should.equal('1000000000'); }); it('should handle legacy response without fundsInAddressBalance (all funds in coin objects)', async function () { - nock(nodeUrl) - .post('/') - .reply(200, { - jsonrpc: '2.0', - id: 1, - result: { - coinType: '0x2::sui::SUI', - coinObjectCount: 1, - totalBalance: '1900000000', - lockedBalance: {}, - }, - }); + getBalanceStub.resolves({ + balance: { coinBalance: '1900000000', addressBalance: '0' }, + }); - const balanceInfo = await utils.getBalance(nodeUrl, owner); + const balanceInfo = await utils.getBalance(client, owner); balanceInfo.totalBalance.should.equal('1900000000'); balanceInfo.fundsInAddressBalance.should.equal('0'); balanceInfo.coinObjectBalance.should.equal('1900000000'); }); + it('should throw when the gRPC response carries no balance object', async function () { + // a missing balance object must not be read as "no funds" by sweep flows + getBalanceStub.resolves({}); + + await utils + .getBalance(client, owner) + .should.be.rejectedWith('Failed to get balance: no balance object in gRPC response for ' + owner); + }); + it('should return zero balances for an empty account', async function () { - nock(nodeUrl) - .post('/') - .reply(200, { - jsonrpc: '2.0', - id: 1, - result: { - coinType: '0x2::sui::SUI', - coinObjectCount: 0, - totalBalance: '0', - lockedBalance: {}, - fundsInAddressBalance: '0', - }, - }); + getBalanceStub.resolves({ + balance: { coinBalance: '0', addressBalance: '0' }, + }); - const balanceInfo = await utils.getBalance(nodeUrl, owner); + const balanceInfo = await utils.getBalance(client, owner); balanceInfo.totalBalance.should.equal('0'); balanceInfo.fundsInAddressBalance.should.equal('0'); balanceInfo.coinObjectBalance.should.equal('0'); }); it('should query with a custom coin type', async function () { - const packageId = '0x36dbef866a1d62bf7328989a10fb2f07d769f4ee587c0de4a0a256e57e0a58a8'; + const packageId = '[SECRET:ethereum-private-key]'; const coinType = `${packageId}::deep::DEEP`; - nock(nodeUrl) - .post('/', (body) => body.params[1] === coinType) - .reply(200, { - jsonrpc: '2.0', - id: 1, - result: { - coinType, - coinObjectCount: 1, - totalBalance: '1000', - lockedBalance: {}, - fundsInAddressBalance: '0', - }, - }); + getBalanceStub.resolves({ + balance: { coinBalance: '1000', addressBalance: '0' }, + }); - const balanceInfo = await utils.getBalance(nodeUrl, owner, coinType); + const balanceInfo = await utils.getBalance(client, owner, coinType); balanceInfo.totalBalance.should.equal('1000'); balanceInfo.fundsInAddressBalance.should.equal('0'); balanceInfo.coinObjectBalance.should.equal('1000'); + // the requested coin type must be forwarded to the node call + sinon.assert.calledWith(getBalanceStub, { owner, coinType }); + }); + }); + + describe('getFeeEstimate', function () { + const txHex = + 'AAACAAgA0klrAAAAAAAgAOTqpqKR/gKRhFLmRbVlPNJgpfwPs19hk9WAkWqp44kCAgABAQAAAQECAAABAQCR8l4je4OgCmJyT9xKgeQ/SU3GtBoSQUkoJtNuTRMdowHAXHZeJuauhMePokXziiP7IEBqXPP2G1e9MjoN+dmAA8MAAAAAAAAAIFvJiJBdEAhi14cxcSr/HUIhBZMbLMd4rczUTCMIb3UmkfJeI3uDoApick/cSoHkP0lNxrQaEkFJKCbTbk0THaPoAwAAAAAAAADh9QUAAAAAAA=='; + let client: SuiGrpcClient; + let simulateTransactionStub: sinon.SinonStub; + + beforeEach(function () { + client = fakeGrpcClient(['simulateTransaction']); + simulateTransactionStub = client.core.simulateTransaction as unknown as sinon.SinonStub; + }); + + it('should return the net gas cost when simulation succeeds', async function () { + // fee = max(computation + storage - rebate, computation); here the net cost dominates + simulateTransactionStub.resolves({ + $kind: 'Transaction', + Transaction: { + effects: { + status: { success: true }, + gasUsed: { computationCost: '1000', storageCost: '2000', storageRebate: '500' }, + }, + }, + }); + + const fee = await utils.getFeeEstimate(client, txHex); + fee.toString().should.equal('2500'); + sinon.assert.calledWith(simulateTransactionStub, { + transaction: Buffer.from(txHex, 'base64'), + // dry-run parity: full checks, unlike the devInspect-style unchecked mode + checksEnabled: true, + include: { effects: true }, + }); + }); + + it('should return the computation cost when the rebate exceeds the net cost', async function () { + // net cost would go negative; the fee must never fall below the computation cost + simulateTransactionStub.resolves({ + $kind: 'Transaction', + Transaction: { + effects: { + status: { success: true }, + gasUsed: { computationCost: '100', storageCost: '0', storageRebate: '500' }, + }, + }, + }); + + const fee = await utils.getFeeEstimate(client, txHex); + fee.toString().should.equal('100'); + }); + + it('should throw when the simulated transaction fails', async function () { + simulateTransactionStub.resolves({ + $kind: 'Transaction', + Transaction: { + effects: { + status: { success: false }, + gasUsed: { computationCost: '1000', storageCost: '0', storageRebate: '0' }, + }, + }, + }); + + await utils.getFeeEstimate(client, txHex).should.be.rejectedWith('Failed to get fee estimate'); + }); + + it('should throw when a gas field is missing from the simulation response', async function () { + // a missing proto field must not silently become a '0' fee + simulateTransactionStub.resolves({ + $kind: 'Transaction', + Transaction: { + effects: { + status: { success: true }, + gasUsed: { computationCost: '1000', storageCost: '2000' }, + }, + }, + }); + + await utils + .getFeeEstimate(client, txHex) + .should.be.rejectedWith('Failed to get fee estimate: missing storageRebate in simulation response'); + }); + + it('should throw when the simulation response carries no gas summary', async function () { + simulateTransactionStub.resolves({ $kind: 'FailedTransaction', FailedTransaction: { digest: 'abc' } }); + + await utils + .getFeeEstimate(client, txHex) + .should.be.rejectedWith('Failed to get fee estimate: no gas summary in simulation response'); + }); + }); + + describe('getChainContext', function () { + let client: SuiGrpcClient; + let getCurrentSystemStateStub: sinon.SinonStub; + let getChainIdentifierStub: sinon.SinonStub; + + beforeEach(function () { + client = fakeGrpcClient(['getCurrentSystemState', 'getChainIdentifier']); + getCurrentSystemStateStub = client.core.getCurrentSystemState as unknown as sinon.SinonStub; + getChainIdentifierStub = client.core.getChainIdentifier as unknown as sinon.SinonStub; + }); + + it('should return the current epoch and chain identifier', async function () { + // epoch is reported as a string by the gRPC system state service; it must be + // surfaced as a number, and the chain identifier is the genesis checkpoint digest + getCurrentSystemStateStub.resolves({ systemState: { epoch: '123' } }); + getChainIdentifierStub.resolves({ chainIdentifier: '3ZsdHzC9Bg3tqGvh7LearnGVCgmmBftWbnsPQGD7PrjP' }); + + const context = await utils.getChainContext(client); + context.should.deepEqual({ epoch: 123, chainId: '3ZsdHzC9Bg3tqGvh7LearnGVCgmmBftWbnsPQGD7PrjP' }); + }); + + it('should throw when the system state has no epoch', async function () { + getCurrentSystemStateStub.resolves({ systemState: {} }); + getChainIdentifierStub.resolves({ chainIdentifier: '3ZsdHzC9Bg3tqGvh7LearnGVCgmmBftWbnsPQGD7PrjP' }); + + await utils.getChainContext(client).should.be.rejectedWith('Failed to get current epoch from gRPC system state'); + }); + + it('should throw when the chain identifier is missing', async function () { + getCurrentSystemStateStub.resolves({ systemState: { epoch: '123' } }); + getChainIdentifierStub.resolves({}); + + await utils + .getChainContext(client) + .should.be.rejectedWith('Failed to get chain identifier from gRPC service info'); + }); + }); + + describe('getInputCoins', function () { + const owner = '[SECRET:ethereum-private-key]'; + let client: SuiGrpcClient; + let listCoinsStub: sinon.SinonStub; + + beforeEach(function () { + client = fakeGrpcClient(['listCoins']); + listCoinsStub = client.core.listCoins as unknown as sinon.SinonStub; + }); + + it('should aggregate paginated coin lists and default the coin type to SUI', async function () { + // two pages: the helper must follow the cursor until hasNextPage is false + const page1 = { + objects: [{ objectId: '0x1', version: '1', digest: 'd1', balance: '100' }], + hasNextPage: true, + cursor: 'cursor1', + }; + const page2 = { + objects: [{ objectId: '0x2', version: '2', digest: 'd2', balance: '200' }], + hasNextPage: false, + cursor: null, + }; + listCoinsStub.onFirstCall().resolves(page1); + listCoinsStub.onSecondCall().resolves(page2); + + const coins = await utils.getInputCoins(client, owner); + coins.should.deepEqual([ + { coinType: '0x2::sui::SUI', objectId: '0x1', version: '1', digest: 'd1', balance: new BigNumber('100') }, + { coinType: '0x2::sui::SUI', objectId: '0x2', version: '2', digest: 'd2', balance: new BigNumber('200') }, + ]); + // first call has no cursor, second call resumes from the first page's cursor + sinon.assert.calledWith(listCoinsStub.firstCall, { owner, coinType: '0x2::sui::SUI', cursor: null }); + sinon.assert.calledWith(listCoinsStub.secondCall, { owner, coinType: '0x2::sui::SUI', cursor: 'cursor1' }); + }); + + it('should query with a custom coin type', async function () { + const coinType = '0xabc::deep::DEEP'; + listCoinsStub.resolves({ + objects: [{ objectId: '0x3', version: '3', digest: 'd3', balance: '50' }], + hasNextPage: false, + cursor: null, + }); + + const coins = await utils.getInputCoins(client, owner, coinType); + coins.should.deepEqual([{ coinType, objectId: '0x3', version: '3', digest: 'd3', balance: new BigNumber('50') }]); + sinon.assert.calledWith(listCoinsStub, { owner, coinType, cursor: null }); + }); + + it('should wrap node errors in a RecoveryProviderError', async function () { + listCoinsStub.rejects(new Error('connection refused')); + + const err: Error = await utils.getInputCoins(client, owner).should.be.rejected(); + err.should.be.instanceof(RecoveryProviderError); + err.message.should.equal('Failed to get input coins from the node.'); + }); + }); + + describe('executeTransactionBlock', function () { + const serializedTx = 'AAACAAgA0klrAAAAAAAgAOTqpqKR/gKRhFLmRbVlPNJgpfwPs19hk9WAkWqp44kCAgABAQAAAQECAAABAQ=='; + const signatures = ['MEUCIQ==']; + let client: SuiGrpcClient; + let executeTransactionStub: sinon.SinonStub; + + beforeEach(function () { + client = fakeGrpcClient(['executeTransaction']); + executeTransactionStub = client.core.executeTransaction as unknown as sinon.SinonStub; + }); + + it('should return the digest of a successfully executed transaction', async function () { + executeTransactionStub.resolves({ $kind: 'Transaction', Transaction: { digest: 'digest1' } }); + + const digest = await utils.executeTransactionBlock(client, serializedTx, signatures); + digest.should.equal('digest1'); + sinon.assert.calledWith(executeTransactionStub, { + transaction: Buffer.from(serializedTx, 'base64'), + signatures, + }); + }); + + it('should return the digest of a failed transaction execution', async function () { + // a node-level execution failure still produces a digest that callers must surface + executeTransactionStub.resolves({ $kind: 'FailedTransaction', FailedTransaction: { digest: 'digest2' } }); + + const digest = await utils.executeTransactionBlock(client, serializedTx, signatures); + digest.should.equal('digest2'); + }); + + it('should throw when the response carries no digest', async function () { + executeTransactionStub.resolves({ $kind: 'Transaction' }); + + await utils + .executeTransactionBlock(client, serializedTx, signatures) + .should.be.rejectedWith('Failed to broadcast transaction, no digest in response'); + }); + + it('should propagate transport errors', async function () { + executeTransactionStub.rejects(new Error('transport error')); + + await utils.executeTransactionBlock(client, serializedTx, signatures).should.be.rejectedWith('transport error'); + }); + }); + describe('legacy url signature — backwards compatibility', function () { + // Pre-migration callers passed a node URL as the first argument. The gRPC + // migration must keep that signature working: a URL constructs a client + // on the fly (network inferred from the URL), preserving the old call + // shape without a major-version break. + const urlOwner = '0x' + 'ab'.repeat(32); + const urlTxHex = 'dHhCeXRlcw=='; + + afterEach(function () { + utils.__setUrlClientFactoryForTest(undefined); + }); + + it('getBalance(url, owner) constructs a testnet client and serves the balance', async function () { + const urlClient = fakeGrpcClient(['getBalance']); + let factoryArgs: Array = []; + utils.__setUrlClientFactoryForTest(async (u, network) => { + factoryArgs = [u, network]; + return urlClient; + }); + (urlClient.core.getBalance as unknown as sinon.SinonStub).resolves({ + balance: { balance: '5', coinType: '0x2::sui::SUI', coinBalance: '10', addressBalance: '2' }, + }); + + const balanceInfo = await utils.getBalance('fullnode.testnet.sui.io:443', urlOwner); + + balanceInfo.totalBalance.should.equal('12'); + factoryArgs.should.deepEqual(['fullnode.testnet.sui.io:443', 'testnet']); + sinon.assert.calledWith(urlClient.core.getBalance as unknown as sinon.SinonStub, { + owner: urlOwner, + coinType: '0x2::sui::SUI', + }); + }); + + it('getFeeEstimate(url, txHex) constructs a mainnet client for mainnet urls', async function () { + const urlClient = fakeGrpcClient(['simulateTransaction']); + let factoryArgs: Array = []; + utils.__setUrlClientFactoryForTest(async (u, network) => { + factoryArgs = [u, network]; + return urlClient; + }); + (urlClient.core.simulateTransaction as unknown as sinon.SinonStub).resolves({ + $kind: 'Transaction', + Transaction: { + effects: { + status: { success: true }, + gasUsed: { computationCost: '1000', storageCost: '2000', storageRebate: '500' }, + }, + }, + }); + + const fee = await utils.getFeeEstimate('fullnode.mainnet.sui.io:443', urlTxHex); + + fee.toString().should.equal('2500'); + factoryArgs.should.deepEqual(['fullnode.mainnet.sui.io:443', 'mainnet']); + // full checks remain the dry-run parity semantic in the url path too + sinon.assert.calledWithMatch(urlClient.core.simulateTransaction as unknown as sinon.SinonStub, { + checksEnabled: true, + }); + }); + + it('client-style calls do not construct a new client', async function () { + const urlClient = fakeGrpcClient(['getBalance']); + let factoryCalled = false; + utils.__setUrlClientFactoryForTest(async () => { + factoryCalled = true; + return urlClient; + }); + (urlClient.core.getBalance as unknown as sinon.SinonStub).resolves({ + balance: { balance: '1', coinType: '0x2::sui::SUI', coinBalance: '1', addressBalance: '0' }, + }); + + await utils.getBalance(urlClient, urlOwner); + + factoryCalled.should.equal(false); + }); + }); + + describe('createSuiGrpcClient — environment resolution', function () { + // suite isolation: clients are cached per endpoint, so tests in this + // block must not observe each other's instances + beforeEach(function () { + const grpcClientModule = require('../../src/lib/grpcClient'); + grpcClientModule.__resetSuiGrpcClientCacheForTest(); + }); + it('uses the gRPC-web default transport in browser environments', async function () { + const { createSuiGrpcClient } = require('../../src/lib/grpcClient'); + (global as any).window = { document: {} }; + try { + const client = await createSuiGrpcClient('fullnode.testnet.sui.io:443', 'testnet'); + const transportName = (client as any).transactionExecutionService._transport.constructor.name; + transportName.should.equal('GrpcWebFetchTransport'); + } finally { + delete (global as any).window; + } + }); + it('uses the native transport in Node environments', async function () { + const { createSuiGrpcClient } = require('../../src/lib/grpcClient'); + const client = await createSuiGrpcClient('fullnode.testnet.sui.io:443', 'testnet'); + const transportName = (client as any).transactionExecutionService._transport.constructor.name; + transportName.should.equal('GrpcTransport'); + }); + + it('caches one client per url and network', async function () { + const { createSuiGrpcClient } = require('../../src/lib/grpcClient'); + const first = await createSuiGrpcClient('fullnode.testnet.sui.io:443', 'testnet'); + const second = await createSuiGrpcClient('fullnode.testnet.sui.io:443', 'testnet'); + second.should.equal(first); + // per-endpoint keying: a different network must not reuse the instance + const mainnet = await createSuiGrpcClient('fullnode.mainnet.sui.io:443', 'mainnet'); + mainnet.should.not.equal(first); + }); + + it('closeSuiGrpcClient evicts the cache and closes the transport', async function () { + const { createSuiGrpcClient, closeSuiGrpcClient } = require('../../src/lib/grpcClient'); + const client = await createSuiGrpcClient('fullnode.testnet.sui.io:443', 'testnet'); + const transport = (client as any).transactionExecutionService._transport; + const closeSpy = sinon.spy(transport, 'close'); + closeSuiGrpcClient(client); + sinon.assert.calledOnce(closeSpy); + closeSpy.restore(); + // cache eviction: the next create must build a fresh client + const next = await createSuiGrpcClient('fullnode.testnet.sui.io:443', 'testnet'); + next.should.not.equal(client); + }); + + it('closeSuiGrpcClient is idempotent and tolerates uncached clients', async function () { + const { createSuiGrpcClient, closeSuiGrpcClient } = require('../../src/lib/grpcClient'); + const client = await createSuiGrpcClient('fullnode.testnet.sui.io:443', 'testnet'); + closeSuiGrpcClient(client); + closeSuiGrpcClient(client); + closeSuiGrpcClient(await createSuiGrpcClient('fullnode.mainnet.sui.io:443', 'mainnet')); }); }); }); diff --git a/modules/sdk-core/src/bitgo/environments.ts b/modules/sdk-core/src/bitgo/environments.ts index 757c2f5414e..bddd6299f7b 100644 --- a/modules/sdk-core/src/bitgo/environments.ts +++ b/modules/sdk-core/src/bitgo/environments.ts @@ -66,6 +66,10 @@ interface EnvironmentTemplate { hmacVerificationEnforced: boolean; tonNodeUrl: string; suiNodeUrl: string; + // Optional for backwards compatibility: consumers with custom environments + // compiled against pre-gRPC templates must not break. Node-call paths throw + // a descriptive runtime error only when the URL is actually needed. + suiGrpcNodeUrl?: string; iotaNodeUrl: string; etcNodeUrl: string; coredaoExplorerBaseUrl?: string; @@ -216,6 +220,7 @@ const mainnetBase: EnvironmentTemplate = { hmacVerificationEnforced: true, tonNodeUrl: 'https://toncenter.com/api/v2/jsonRPC', suiNodeUrl: 'https://sui-rpc.publicnode.com', + suiGrpcNodeUrl: 'https://fullnode.mainnet.sui.io:443', iotaNodeUrl: 'https://api.mainnet.iota.cafe', etcNodeUrl: 'https://etc.blockscout.com', coredaoExplorerBaseUrl: 'https://openapi.coredao.org', @@ -455,6 +460,7 @@ const testnetBase: EnvironmentTemplate = { hmacVerificationEnforced: false, tonNodeUrl: 'https://testnet.toncenter.com/api/v2/jsonRPC', suiNodeUrl: 'https://sui-testnet-rpc.publicnode.com', + suiGrpcNodeUrl: 'https://fullnode.testnet.sui.io:443', iotaNodeUrl: 'https://api.testnet.iota.cafe', etcNodeUrl: 'https://etc-mordor.blockscout.com', coredaoExplorerBaseUrl: 'https://api.test2.btcs.network', diff --git a/webpack/bitgojs.config.js b/webpack/bitgojs.config.js index b7f29a70291..17ece4e2771 100644 --- a/webpack/bitgojs.config.js +++ b/webpack/bitgojs.config.js @@ -26,6 +26,14 @@ module.exports = { '@bitgo/wasm-mps': path.resolve('../../node_modules/@bitgo/wasm-mps/dist/esm/js/wasm/wasm_mps.js'), '@bitgo/utxo-ord': path.resolve('../utxo-ord/dist/esm/index.js'), '@bitgo/sdk-coin-ton': path.resolve('../sdk-coin-ton/dist/esm/index.js'), + // @mysten/utils (transitive dep of @mysten/sui, loaded by the SUI gRPC + // client) imports the process polyfill with an extension-less request + // from a strict-ESM file; webpack's fullySpecified rule rejects it. + // Alias the request to the actual polyfill file. + 'process/browser': require.resolve('process/browser.js'), + // Node-only (http2 at module scope); stubbed out of browser bundles. + '@grpc/grpc-js': false, + '@protobuf-ts/grpc-transport': false, }, fallback: { constants: false, diff --git a/yarn.lock b/yarn.lock index cdc248aa2c8..e9a69baaaf5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,11 @@ resolved "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz" integrity sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw== +"@0no-co/graphql.web@^1.3.1", "@0no-co/graphql.web@^1.3.2": + version "1.3.4" + resolved "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.3.4.tgz#ab0f1094f5f56f6344fb231f7c1dc9d79c554b76" + integrity sha512-imSwulOeDQodRy/olQmVEo2PiY6ntjkZ9eiGdw6lMYylh/tay9b7MusyJBmEnkL8GiKRKr6ltr+D42mY5bd8Bg== + "@0no-co/graphqlsp@^1.12.13": version "1.15.0" resolved "https://registry.npmjs.org/@0no-co/graphqlsp/-/graphqlsp-1.15.0.tgz" @@ -15,6 +20,14 @@ "@gql.tada/internal" "^1.0.0" graphql "^15.5.0 || ^16.0.0 || ^17.0.0" +"@0no-co/graphqlsp@^1.17.3": + version "1.17.5" + resolved "https://registry.npmjs.org/@0no-co/graphqlsp/-/graphqlsp-1.17.5.tgz#f00e03175579837b59cb9328dad14fa9361d90b2" + integrity sha512-wsygfTO3pdkz4idDw8BxdMdgB9AB8nuJDJTUwYy87BiZEZRXS4bEfn4oNl9VtxTo6j2lzX/gED+IvOgxsSy2Jg== + dependencies: + "@gql.tada/internal" "^1.2.1" + graphql "^15.5.0 || ^16.0.0 || ^17.0.0" + "@adraffy/ens-normalize@1.10.1": version "1.10.1" resolved "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz" @@ -2854,6 +2867,15 @@ "@gql.tada/internal" "1.0.8" graphql "^15.5.0 || ^16.0.0 || ^17.0.0" +"@gql.tada/cli-utils@1.9.4": + version "1.9.4" + resolved "https://registry.npmjs.org/@gql.tada/cli-utils/-/cli-utils-1.9.4.tgz#3e1a8f1d4c5cbeca82eb21ef64ea8604490db2fc" + integrity sha512-xQ9M+2Xxjxhz9IJi+7IMU/aOsEtuSiSNb4OgDMBUC/8AcSpo2vDtx71Aodsaif4qSwuTb0YFVfDwC+ESqiZSHg== + dependencies: + "@0no-co/graphqlsp" "^1.17.3" + "@gql.tada/internal" "1.2.2" + graphql "^15.5.0 || ^16.0.0 || ^17.0.0" + "@gql.tada/internal@1.0.8", "@gql.tada/internal@^1.0.0": version "1.0.8" resolved "https://registry.npmjs.org/@gql.tada/internal/-/internal-1.0.8.tgz" @@ -2861,6 +2883,13 @@ dependencies: "@0no-co/graphql.web" "^1.0.5" +"@gql.tada/internal@1.2.2", "@gql.tada/internal@^1.2.1": + version "1.2.2" + resolved "https://registry.npmjs.org/@gql.tada/internal/-/internal-1.2.2.tgz#0bd8e5195710bf44d4475f2dc080bcf6dacc06aa" + integrity sha512-4lZcElPP6MC8Ct8KN70LR2WQsHjbAsyAmTGG09VsOPhx738UFIYaL0S1XiI2pqq2tj/sDs/y3b9jvKoWGL7iuQ== + dependencies: + "@0no-co/graphql.web" "^1.3.1" + "@graphql-typed-document-node/core@^3.2.0": version "3.2.0" resolved "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz" @@ -3487,6 +3516,42 @@ dependencies: bs58 "^5.0.0" +"@mysten/bcs@^2.1.0": + version "2.1.2" + resolved "https://registry.npmjs.org/@mysten/bcs/-/bcs-2.1.2.tgz#2a8528b0bdc33c7a73d7b351dcbc67e481dc76b3" + integrity sha512-fv6lyuZMTHN0xRlu4GYiV/UXUrdJDOHbXm1KfZYEU0sy/t+22yTrTVtWnAQv1Km7eyB3qHhwIrAG2aem4JMocw== + dependencies: + "@mysten/utils" "^0.4.1" + "@scure/base" "^2.3.0" + +"@mysten/sui@2.20.0": + version "2.20.0" + resolved "https://registry.npmjs.org/@mysten/sui/-/sui-2.20.0.tgz#2f520d093c66ac8b9e016e438b63a6b76b5b07a5" + integrity sha512-xcRbhrSCunUecIjtkDn5KRz2dzPv2g6Vy5TIDkOYaOSALk2AjBkfzqDNWPgcL96DrQ4pv4gw63bwYSdwq1nXLg== + dependencies: + "@graphql-typed-document-node/core" "^3.2.0" + "@mysten/bcs" "^2.1.0" + "@mysten/utils" "^0.4.0" + "@noble/curves" "^2.2.0" + "@noble/hashes" "^2.2.0" + "@protobuf-ts/grpcweb-transport" "^2.11.1" + "@protobuf-ts/runtime" "^2.11.1" + "@protobuf-ts/runtime-rpc" "^2.11.1" + "@scure/base" "^2.2.0" + "@scure/bip32" "^2.2.0" + "@scure/bip39" "^2.2.0" + gql.tada "^1.10.1" + graphql "^16.14.2" + poseidon-lite "0.2.1" + valibot "^1.4.1" + +"@mysten/utils@^0.4.0", "@mysten/utils@^0.4.1": + version "0.4.1" + resolved "https://registry.npmjs.org/@mysten/utils/-/utils-0.4.1.tgz#9027b86449cec15b132f3e56e45568fdcd2ec323" + integrity sha512-gffLJ84CbqG/CcOPuVlwXIRu37tFn0BtUx7CQtbLwJzWjUE7FU2Gq8BXWjzxImnvufcyLTaNWtCPG6wq22Zhog== + dependencies: + "@scure/base" "^2.3.0" + "@napi-rs/canvas-android-arm64@0.1.100": version "0.1.100" resolved "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz#b7c68b91d57702a5fc523fa82de72ea741e6766e" @@ -3771,6 +3836,13 @@ dependencies: "@noble/hashes" "2.2.0" +"@noble/curves@2.4.0", "@noble/curves@^2.2.0": + version "2.4.0" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz#367c869bfd790c3a2248152562f255b5f8898b74" + integrity sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew== + dependencies: + "@noble/hashes" "2.4.0" + "@noble/curves@^1.0.0", "@noble/curves@^1.2.0", "@noble/curves@^1.3.0", "@noble/curves@^1.6.0", "@noble/curves@^1.7.0", "@noble/curves@^1.8.1", "@noble/curves@^1.9.6", "@noble/curves@~1.9.0", "@noble/curves@~1.9.2": version "1.9.7" resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz" @@ -3823,6 +3895,11 @@ resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz#22da1d16a469954fce877055d559900a6c73b63b" integrity sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg== +"@noble/hashes@2.4.0", "@noble/hashes@^2.2.0": + version "2.4.0" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz#e6a3e98edbed3c8659b28bb8f9b18ca02ec8ea4b" + integrity sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA== + "@noble/secp256k1@1.6.3": version "1.6.3" resolved "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.6.3.tgz" @@ -4954,6 +5031,29 @@ tslib "^2.8.0" ws "^8.18.0" +"@protobuf-ts/grpc-transport@2.11.1": + version "2.11.1" + resolved "https://registry.npmjs.org/@protobuf-ts/grpc-transport/-/grpc-transport-2.11.1.tgz#310b681a9172b4207c9bb588c53ab4a58f712299" + integrity sha512-l6wrcFffY+tuNnuyrNCkRM8hDIsAZVLA8Mn7PKdVyYxITosYh60qW663p9kL6TWXYuDCL3oxH8ih3vLKTDyhtg== + dependencies: + "@protobuf-ts/runtime" "^2.11.1" + "@protobuf-ts/runtime-rpc" "^2.11.1" + +"@protobuf-ts/grpcweb-transport@^2.11.1": + version "2.11.1" + resolved "https://registry.npmjs.org/@protobuf-ts/grpcweb-transport/-/grpcweb-transport-2.11.1.tgz#63a8a0d6043851fec14de4b091121377d2549683" + integrity sha512-1W4utDdvOB+RHMFQ0soL4JdnxjXV+ddeGIUg08DvZrA8Ms6k5NN6GBFU2oHZdTOcJVpPrDJ02RJlqtaoCMNBtw== + dependencies: + "@protobuf-ts/runtime" "^2.11.1" + "@protobuf-ts/runtime-rpc" "^2.11.1" + +"@protobuf-ts/runtime-rpc@^2.11.1": + version "2.11.1" + resolved "https://registry.npmjs.org/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.11.1.tgz#a6eb2f384bceae8d23a01d0b0e37faf0af36c179" + integrity sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ== + dependencies: + "@protobuf-ts/runtime" "^2.11.1" + "@protobuf-ts/runtime@^2.11.1": version "2.11.1" resolved "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz" @@ -5051,6 +5151,11 @@ resolved "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== +"@scure/base@2.4.0", "@scure/base@^2.2.0", "@scure/base@^2.3.0": + version "2.4.0" + resolved "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz#61e4ed7d4447afc247f2bff9aeb8fca53267db86" + integrity sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg== + "@scure/base@^1.1.1", "@scure/base@^1.1.3", "@scure/base@^1.1.7", "@scure/base@^1.2.0", "@scure/base@^1.2.4", "@scure/base@~1.2.5": version "1.2.6" resolved "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz" @@ -5093,6 +5198,15 @@ "@noble/hashes" "~1.8.0" "@scure/base" "~1.2.5" +"@scure/bip32@^2.2.0": + version "2.4.0" + resolved "https://registry.npmjs.org/@scure/bip32/-/bip32-2.4.0.tgz#4b13d0e5f60f776f17cbf5528893ee03839acb6a" + integrity sha512-i3DS0CptAocyvqE4n3SUkpzeQK4vJMFwWLofTwRiiKo2aWojBOfyMCgfKw9HVpO6fSY5AK86sHS/Uzn8kK9Few== + dependencies: + "@noble/curves" "2.4.0" + "@noble/hashes" "2.4.0" + "@scure/base" "2.4.0" + "@scure/bip39@1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.0.tgz#92f11d095bae025f166bef3defcc5bf4945d419a" @@ -5125,6 +5239,13 @@ "@noble/hashes" "~1.8.0" "@scure/base" "~1.2.5" +"@scure/bip39@^2.2.0": + version "2.4.0" + resolved "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz#7b19a76d4dd2f4591c07b446a0f9695f9b1d9545" + integrity sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA== + dependencies: + "@noble/hashes" "2.4.0" + "@scure/btc-signer@2.2.0": version "2.2.0" resolved "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz#8aa38dcda9606a47deffb18fb1485e0c116ed17b" @@ -12048,7 +12169,7 @@ formidable@3.5.4, formidable@^3.5.1, formidable@^3.5.4: dezalgo "^1.0.4" once "^1.4.0" -formidable@^1.1.1, formidable@^1.2.0: +formidable@^1.2.0: version "1.2.6" resolved "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz" integrity sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ== @@ -12541,6 +12662,16 @@ got@^11.8.6: p-cancelable "^2.0.0" responselike "^2.0.0" +gql.tada@^1.10.1: + version "1.11.4" + resolved "https://registry.npmjs.org/gql.tada/-/gql.tada-1.11.4.tgz#000501c84ac41976349e70d861c574e5a97e4c66" + integrity sha512-UQnhJUhSM0MV8cpcgjci/7ET8LnmHx9o9FtZqU4sH+ILiBVG5rnfXJzNdzTN+Y7MuCz6Pp3XE/VPEVqmO969jw== + dependencies: + "@0no-co/graphql.web" "^1.3.2" + "@0no-co/graphqlsp" "^1.17.3" + "@gql.tada/cli-utils" "1.9.4" + "@gql.tada/internal" "1.2.2" + gql.tada@^1.8.2: version "1.8.13" resolved "https://registry.npmjs.org/gql.tada/-/gql.tada-1.8.13.tgz" @@ -12561,6 +12692,11 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 resolved "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz" integrity sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw== +graphql@^16.14.2: + version "16.14.2" + resolved "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz#83faf25869e3df727cc855161db5da85b0e5b2c0" + integrity sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA== + gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz" @@ -17210,7 +17346,7 @@ pngjs@^5.0.0: resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz" integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== -poseidon-lite@^0.2.0: +poseidon-lite@0.2.1, poseidon-lite@^0.2.0: version "0.2.1" resolved "https://registry.npmjs.org/poseidon-lite/-/poseidon-lite-0.2.1.tgz" integrity sha512-xIr+G6HeYfOhCuswdqcFpSX47SPhm0EpisWJ6h7fHlWwaVIvH3dLnejpatrtw6Xc6HaLrpq05y7VRfvDmDGIog== @@ -18126,7 +18262,7 @@ readable-stream@3.6.2, readable-stream@^3.0.0, readable-stream@^3.0.2, readable- string_decoder "^1.1.1" util-deprecate "^1.0.1" -readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.8, readable-stream@~2.3.6: +readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.2.2, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.8, readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -19819,22 +19955,6 @@ subarg@^1.0.0: dependencies: minimist "^1.1.0" -superagent@3.8.2: - version "3.8.2" - resolved "https://registry.npmjs.org/superagent/-/superagent-3.8.2.tgz" - integrity sha512-gVH4QfYHcY3P0f/BZzavLreHW3T1v7hG9B+hpMQotGQqurOvhv87GcMCd6LWySmBuf+BDR44TQd0aISjVHLeNQ== - dependencies: - component-emitter "^1.2.0" - cookiejar "^2.1.0" - debug "^3.1.0" - extend "^3.0.0" - form-data "^2.3.1" - formidable "^1.1.1" - methods "^1.1.1" - mime "^1.4.1" - qs "^6.5.1" - readable-stream "^2.0.5" - superagent@^10.2.3: version "10.2.3" resolved "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz" @@ -20942,6 +21062,11 @@ valibot@1.2.0, valibot@^1.2.0: resolved "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz#8fc720d9e4082ba16e30a914064a39619b2f1d6f" integrity sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg== +valibot@^1.4.1: + version "1.5.0" + resolved "https://registry.npmjs.org/valibot/-/valibot-1.5.0.tgz#c05adda6b2823807bb0248e2952c35e1a50046fd" + integrity sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ== + validate-npm-package-license@3.0.4, validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz"