diff --git a/__tests__/grpc-transport-auth.test.ts b/__tests__/grpc-transport-auth.test.ts new file mode 100644 index 0000000..ebc30b1 --- /dev/null +++ b/__tests__/grpc-transport-auth.test.ts @@ -0,0 +1,39 @@ +import { Blockchain } from "../src/models/blockchain"; +import { AlphalendClient } from "../src/core/client"; + +// SuiGrpcClient's convenience constructor drops `meta`, so the token must ride +// on an explicitly-built transport. These tests pin the property actually +// shipped: the transport's defaultOptions carry the x-token metadata. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const transportOf = (client: any) => client.ledgerService._transport; + +describe("gRPC transport auth", () => { + it("Blockchain attaches grpcToken as x-token metadata on the transport", () => { + const blockchain = new Blockchain( + "mainnet", + undefined, + "https://example.invalid:443", + "TEST_TOKEN", + ); + expect(transportOf(blockchain.suiGrpcClient).defaultOptions.meta).toEqual({ + "x-token": "TEST_TOKEN", + }); + }); + + it("Blockchain sends no metadata when grpcToken is absent", () => { + const blockchain = new Blockchain("mainnet"); + expect( + transportOf(blockchain.suiGrpcClient).defaultOptions.meta, + ).toBeUndefined(); + }); + + it("AlphalendClient threads options through to the transport", () => { + const client = new AlphalendClient("mainnet", undefined, { + grpcUrl: "https://example.invalid:443", + grpcToken: "TEST_TOKEN", + }); + expect( + transportOf(client.blockchain.suiGrpcClient).defaultOptions.meta, + ).toEqual({ "x-token": "TEST_TOKEN" }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 435c0f7..d071ed6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@cetusprotocol/aggregator-sdk": "^1.5.5", "@naviprotocol/lending": "2.0.3", + "@protobuf-ts/grpcweb-transport": "^2.11.1", "@types/bn.js": "^5.2.0", "bn.js": "^5.2.5", "decimal.js": "^10.5.0", diff --git a/package.json b/package.json index d39d94c..5ca2e70 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "dependencies": { "@cetusprotocol/aggregator-sdk": "^1.5.5", "@naviprotocol/lending": "2.0.3", + "@protobuf-ts/grpcweb-transport": "^2.11.1", "@types/bn.js": "^5.2.0", "bn.js": "^5.2.5", "decimal.js": "^10.5.0", diff --git a/src/core/client.ts b/src/core/client.ts index d490858..f8f8471 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -96,7 +96,9 @@ export class AlphalendClient { * @param network One of the supported Sui networks. * @param graphqlUrl Optional GraphQL endpoint override. If omitted, a default * public endpoint for the given `network` is used. - * @param options Optional prebuilt coin metadata map for offline testing. + * @param options Optional prebuilt coin metadata map for offline testing, + * and/or a gRPC endpoint override + auth token for + * `blockchain.suiGrpcClient` (see AlphalendClientOptions). */ constructor( network: Network, @@ -107,7 +109,12 @@ export class AlphalendClient { this.constants = getConstants(network); this.lendingProtocol = new LendingProtocol(network, graphqlUrl); - this.blockchain = new Blockchain(network, graphqlUrl); + this.blockchain = new Blockchain( + network, + graphqlUrl, + options?.grpcUrl, + options?.grpcToken, + ); this.cetusSwap = new CetusSwap("mainnet"); // If a coin metadata map is provided, use it and mark as initialized diff --git a/src/core/coinHelpers.ts b/src/core/coinHelpers.ts index 7c7dd21..a936444 100644 --- a/src/core/coinHelpers.ts +++ b/src/core/coinHelpers.ts @@ -12,6 +12,7 @@ import { } from "@mysten/sui/transactions"; import { graphql } from "@mysten/sui/graphql/schema"; import { SuiGrpcClient } from "@mysten/sui/grpc"; +import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport"; import type { SuiClientTypes } from "@mysten/sui/client"; import { normalizeStructTag, SUI_TYPE_ARG } from "@mysten/sui/utils"; @@ -76,8 +77,9 @@ export async function getCoinObjectCounts( address: string, network: Network, grpcUrl?: string, + grpcToken?: string, ): Promise { - const client = grpcClient(network, grpcUrl); + const client = grpcClient(network, grpcUrl, grpcToken); const coinTypes = await listCoinTypes(client, address); const counts = await mapWithConcurrency( @@ -118,6 +120,7 @@ export async function buildMergeCoinsTransaction( address: string, network: Network, grpcUrl?: string, + grpcToken?: string, ): Promise { const blockchain = new Blockchain(network); const normalizedCoinType = normalizeStructTag(coinType); @@ -135,7 +138,7 @@ export async function buildMergeCoinsTransaction( addressBalance >= MIN_ADDRESS_BALANCE_FOR_GAS; const coins = await getCoinObjectsOfType( - grpcClient(network, grpcUrl), + grpcClient(network, grpcUrl, grpcToken), address, normalizedCoinType, ); @@ -298,11 +301,24 @@ async function getAddressBalance( return BigInt(response.data?.address?.balance?.addressBalance ?? 0); } -/** gRPC-web over fetch; works in browsers and Node. */ -function grpcClient(network: Network, grpcUrl?: string): SuiGrpcClient { +/** + * gRPC-web over fetch; works in browsers and Node. `grpcToken` is sent as + * `x-token` metadata. The transport is built explicitly because + * SuiGrpcClient's convenience constructor forwards only `baseUrl`/`fetchInit` + * to the transport it builds and silently drops `meta` — the token would + * never be sent. + */ +function grpcClient( + network: Network, + grpcUrl?: string, + grpcToken?: string, +): SuiGrpcClient { return new SuiGrpcClient({ network, - baseUrl: grpcUrl ?? GRPC_URL[network], + transport: new GrpcWebFetchTransport({ + baseUrl: grpcUrl ?? GRPC_URL[network], + ...(grpcToken ? { meta: { "x-token": grpcToken } } : {}), + }), }); } diff --git a/src/core/types.ts b/src/core/types.ts index 379cf9a..6e63421 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -20,6 +20,10 @@ import { Decimal } from "decimal.js"; */ export interface AlphalendClientOptions { coinMetadataMap?: Map; + /** Optional Sui gRPC endpoint override for the client's `blockchain.suiGrpcClient`. */ + grpcUrl?: string; + /** Auth token for the gRPC endpoint, sent as `x-token` metadata (e.g. a BlockPI key). */ + grpcToken?: string; } /** diff --git a/src/models/blockchain.ts b/src/models/blockchain.ts index 5747f16..b703af0 100644 --- a/src/models/blockchain.ts +++ b/src/models/blockchain.ts @@ -1,12 +1,16 @@ /** * Blockchain interface wrapper for Sui network operations using the SuiGraphQLClient. * - * All operations go through GraphQL, including transaction simulation via - * `gqlClient.core.simulateTransaction`, which builds the transaction, resolves - * gas, and simulates server-side. No JSON-RPC or gRPC client is used. + * All of the SDK's own operations go through GraphQL, including transaction + * simulation via `gqlClient.core.simulateTransaction`, which builds the + * transaction, resolves gas, and simulates server-side. No JSON-RPC is used. + * A `SuiGrpcClient` is also constructed (endpoint/token via the constructor) + * so consumers — and future SDK reads — can go over gRPC without re-plumbing. */ import { SuiGraphQLClient } from "@mysten/sui/graphql"; +import { SuiGrpcClient } from "@mysten/sui/grpc"; +import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport"; import { graphql } from "@mysten/sui/graphql/schema"; import { Transaction, @@ -39,6 +43,12 @@ const GRAPHQL_URL: Record = { devnet: "https://graphql.devnet.sui.io/graphql", }; +const GRPC_URL: Record = { + mainnet: "https://fullnode.mainnet.sui.io:443", + testnet: "https://fullnode.testnet.sui.io:443", + devnet: "https://fullnode.devnet.sui.io:443", +}; + export interface GqlObject { address: string; contents?: T; @@ -52,17 +62,42 @@ export interface EarliestTxInfo { export class Blockchain { network: Network; gqlClient: SuiGraphQLClient; + /** + * gRPC (v2 Core) client. Not used by the SDK's own reads yet — exposed so + * consumers can share one configured client, and so future SDK reads can + * move to gRPC without a plumbing change. + */ + suiGrpcClient: SuiGrpcClient; constants: Constants; private initialSharedVersionCache: Map = new Map(); - constructor(network: Network, graphqlUrl?: string) { + constructor( + network: Network, + graphqlUrl?: string, + grpcUrl?: string, + grpcToken?: string, + ) { this.network = network; this.constants = getConstants(network); this.gqlClient = new SuiGraphQLClient({ url: graphqlUrl ?? GRAPHQL_URL[network], network, }); + // SuiGrpcClient's convenience constructor forwards only `baseUrl`/`fetchInit` + // to the transport it builds and silently drops `meta`, so the token would + // never be sent. Build the transport explicitly — its `defaultOptions.meta` + // is merged into every call as gRPC metadata (the `x-token` auth header). + // `network` is passed through unchanged: GRPC_URL covers all three + // networks, and narrowing devnet to "mainnet" would mislabel the client + // for network-keyed resolution (e.g. MVR). + this.suiGrpcClient = new SuiGrpcClient({ + network, + transport: new GrpcWebFetchTransport({ + baseUrl: grpcUrl ?? GRPC_URL[network], + ...(grpcToken ? { meta: { "x-token": grpcToken } } : {}), + }), + }); } // --------------------------------------------------------------------------