Repository Public
1// Sui full-node API (sui.rpc.v2) over gRPC on HTTP/2, without any dependency: public Sui full nodes switched JSON-RPC off in 20262// and answer only gRPC. This file holds (1) a unary / server-streaming gRPC call on node:http2, (2) a small schema-driven protobuf3// codec and (3) the handful of message layouts the adapter uses. Field numbers are those of the official .proto files4// (github.com/MystenLabs/sui-apis, proto/sui/rpc/v2); services/api/test/sui.test.ts checks every layout here against the5// message types generated from those files and shipped in @mysten/sui (devDependency).6import http2 from 'node:http2';78// ---------------------------------------------------------------- protobuf codec9export type Kind = 'string' | 'u64' | 'u32' | 'bool' | 'bytes' | Schema;10export interface Field { n: number; k: Kind; rep?: true }11export interface Schema { [name: string]: Field }1213const U64_MAX = (1n << 64n) - 1n;14const utf8 = new TextEncoder(), utf8d = new TextDecoder('utf-8', { fatal: true });15function varint(v: bigint, out: number[]): void {16 if (v < 0n || v > U64_MAX) throw new Error('protobuf: varint out of range');17 while (v > 0x7fn) { out.push(Number(v & 0x7fn) | 0x80); v >>= 7n; }18 out.push(Number(v));19}20function put(out: number[], f: Field, v: unknown): void {21 const scalar = f.k === 'u64' || f.k === 'u32' || f.k === 'bool';22 varint(BigInt((f.n << 3) | (scalar ? 0 : 2)), out);23 if (f.k === 'u64' || f.k === 'u32') { const b = BigInt(v as bigint | number); if (f.k === 'u32' && b > 0xffff_ffffn) throw new Error('protobuf: uint32 out of range'); varint(b, out); return; }24 if (f.k === 'bool') { out.push(v ? 1 : 0); return; }25 const body = f.k === 'string' ? utf8.encode(v as string) : f.k === 'bytes' ? (v as Uint8Array) : encode(f.k, v as Record<string, unknown>);26 varint(BigInt(body.length), out);27 for (const b of body) out.push(b);28}29/** Encode an object. Absent (undefined) fields are omitted; repeated fields are arrays. Unknown property names throw. */30export function encode(s: Schema, v: Record<string, unknown>): Uint8Array {31 const out: number[] = [];32 for (const name of Object.keys(v)) if (!s[name]) throw new Error(`protobuf: unknown field ${name}`);33 for (const [name, f] of Object.entries(s).sort((a, b) => a[1].n - b[1].n)) {34 const val = v[name];35 if (val === undefined) continue;36 if (f.rep) for (const x of val as unknown[]) put(out, f, x); else put(out, f, val);37 }38 return Uint8Array.from(out);39}40/** Decode a message. Fields not in the schema are skipped; absent fields are undefined (repeated: []). u64 -> bigint, u32 -> number. */41export function decode<T = Record<string, unknown>>(s: Schema, b: Uint8Array, depth = 0): T {42 if (depth > 16) throw new Error('protobuf: nesting too deep');43 const byNum = new Map<number, [string, Field]>(Object.entries(s).map(([name, f]) => [f.n, [name, f]]));44 const out: Record<string, unknown> = {};45 for (const [name, f] of Object.entries(s)) if (f.rep) out[name] = [];46 let o = 0;47 const rv = (): bigint => {48 let v = 0n, shift = 0n;49 for (;;) {50 if (o >= b.length || shift > 63n) throw new Error('protobuf: bad varint');51 const x = b[o++]!;52 v |= BigInt(x & 0x7f) << shift;53 if (!(x & 0x80)) return v & U64_MAX;54 shift += 7n;55 }56 };57 while (o < b.length) {58 const tag = rv(), num = Number(tag >> 3n), wire = Number(tag & 7n);59 let val: bigint | Uint8Array | null = null;60 if (wire === 0) val = rv();61 else if (wire === 2) { const len = Number(rv()); if (o + len > b.length) throw new Error('protobuf: truncated field'); val = b.subarray(o, o + len); o += len; }62 else if (wire === 1) { if (o + 8 > b.length) throw new Error('protobuf: truncated field'); o += 8; }63 else if (wire === 5) { if (o + 4 > b.length) throw new Error('protobuf: truncated field'); o += 4; }64 else throw new Error('protobuf: unsupported wire type');65 const hit = byNum.get(num);66 if (!hit) continue;67 const [name, f] = hit;68 const scalar = f.k === 'u64' || f.k === 'u32' || f.k === 'bool';69 if (scalar !== (wire === 0) || (!scalar && wire !== 2)) throw new Error(`protobuf: wrong wire type for ${name}`);70 let x: unknown;71 if (f.k === 'u64') x = val as bigint;72 else if (f.k === 'u32') { if ((val as bigint) > 0xffff_ffffn) throw new Error(`protobuf: ${name} out of range`); x = Number(val as bigint); }73 else if (f.k === 'bool') x = (val as bigint) !== 0n;74 else if (f.k === 'string') x = utf8d.decode(val as Uint8Array);75 else if (f.k === 'bytes') x = (val as Uint8Array).slice();76 else x = decode(f.k, val as Uint8Array, depth + 1);77 if (f.rep) (out[name] as unknown[]).push(x); else out[name] = x;78 }79 return out as T;80}8182// ---------------------------------------------------------------- message layouts (sui.rpc.v2)83const FieldMask: Schema = { paths: { n: 1, k: 'string', rep: true } };84const Timestamp: Schema = { seconds: { n: 1, k: 'u64' }, nanos: { n: 2, k: 'u32' } };85const Bcs: Schema = { name: { n: 1, k: 'string' }, value: { n: 2, k: 'bytes' } };86const Owner: Schema = { kind: { n: 1, k: 'u32' }, address: { n: 2, k: 'string' } };87const SuiObject: Schema = { objectId: { n: 2, k: 'string' }, version: { n: 3, k: 'u64' }, digest: { n: 4, k: 'string' }, owner: { n: 5, k: Owner }, objectType: { n: 6, k: 'string' }, balance: { n: 101, k: 'u64' } };88const Transaction: Schema = { bcs: { n: 1, k: Bcs }, sender: { n: 5, k: 'string' } };89const UserSignature: Schema = { bcs: { n: 1, k: Bcs } };90const ExecutionError: Schema = { description: { n: 1, k: 'string' }, command: { n: 2, k: 'u64' }, kind: { n: 3, k: 'u32' } };91const ExecutionStatus: Schema = { success: { n: 1, k: 'bool' }, error: { n: 2, k: ExecutionError } };92const GasCostSummary: Schema = { computationCost: { n: 1, k: 'u64' }, storageCost: { n: 2, k: 'u64' }, storageRebate: { n: 3, k: 'u64' }, nonRefundableStorageFee: { n: 4, k: 'u64' } };93const Effects: Schema = { status: { n: 4, k: ExecutionStatus }, epoch: { n: 5, k: 'u64' }, gasUsed: { n: 6, k: GasCostSummary } };94const BalanceChange: Schema = { address: { n: 1, k: 'string' }, coinType: { n: 2, k: 'string' }, amount: { n: 3, k: 'string' } };95const ExecutedTransaction: Schema = { digest: { n: 1, k: 'string' }, transaction: { n: 2, k: Transaction }, effects: { n: 4, k: Effects }, checkpoint: { n: 6, k: 'u64' }, timestamp: { n: 7, k: Timestamp }, balanceChanges: { n: 8, k: BalanceChange, rep: true } };96const Epoch: Schema = { epoch: { n: 1, k: 'u64' }, firstCheckpoint: { n: 4, k: 'u64' }, lastCheckpoint: { n: 5, k: 'u64' }, start: { n: 6, k: Timestamp }, end: { n: 7, k: Timestamp }, referenceGasPrice: { n: 8, k: 'u64' } };97const Balance: Schema = { coinType: { n: 1, k: 'string' }, balance: { n: 3, k: 'u64' }, addressBalance: { n: 4, k: 'u64' }, coinBalance: { n: 5, k: 'u64' } };98const AddressFilter: Schema = { address: { n: 1, k: 'string' } };99const TxLiteral: Schema = { negated: { n: 1, k: 'bool' }, affectedAddress: { n: 3, k: AddressFilter } };100const TxFilter: Schema = { terms: { n: 1, k: { literals: { n: 1, k: TxLiteral, rep: true } }, rep: true } };101const QueryOptions: Schema = { limit: { n: 1, k: 'u32' }, ordering: { n: 4, k: 'u32' } };102103export const M = {104 GetServiceInfoRequest: {} as Schema,105 GetServiceInfoResponse: { chainId: { n: 1, k: 'string' }, chain: { n: 2, k: 'string' }, epoch: { n: 3, k: 'u64' }, checkpointHeight: { n: 4, k: 'u64' }, timestamp: { n: 5, k: Timestamp }, lowestAvailableCheckpoint: { n: 6, k: 'u64' } } as Schema,106 GetEpochRequest: { epoch: { n: 1, k: 'u64' }, readMask: { n: 2, k: FieldMask } } as Schema,107 GetEpochResponse: { epoch: { n: 1, k: Epoch } } as Schema,108 GetBalanceRequest: { owner: { n: 1, k: 'string' }, coinType: { n: 2, k: 'string' } } as Schema,109 GetBalanceResponse: { balance: { n: 1, k: Balance } } as Schema,110 ListOwnedObjectsRequest: { owner: { n: 1, k: 'string' }, pageSize: { n: 2, k: 'u32' }, pageToken: { n: 3, k: 'bytes' }, readMask: { n: 4, k: FieldMask }, objectType: { n: 5, k: 'string' } } as Schema,111 ListOwnedObjectsResponse: { objects: { n: 1, k: SuiObject, rep: true }, nextPageToken: { n: 2, k: 'bytes' } } as Schema,112 GetObjectRequest: { objectId: { n: 1, k: 'string' }, readMask: { n: 3, k: FieldMask } } as Schema,113 GetObjectResponse: { object: { n: 1, k: SuiObject } } as Schema,114 GetTransactionRequest: { digest: { n: 1, k: 'string' }, readMask: { n: 2, k: FieldMask } } as Schema,115 GetTransactionResponse: { transaction: { n: 1, k: ExecutedTransaction } } as Schema,116 SimulateTransactionRequest: { transaction: { n: 1, k: Transaction }, readMask: { n: 2, k: FieldMask }, checks: { n: 3, k: 'u32' }, doGasSelection: { n: 4, k: 'bool' } } as Schema,117 SimulateTransactionResponse: { transaction: { n: 1, k: ExecutedTransaction } } as Schema,118 ExecuteTransactionRequest: { transaction: { n: 1, k: Transaction }, signatures: { n: 2, k: UserSignature, rep: true }, readMask: { n: 3, k: FieldMask } } as Schema,119 ExecuteTransactionResponse: { transaction: { n: 1, k: ExecutedTransaction } } as Schema,120 ListTransactionsRequest: { readMask: { n: 1, k: FieldMask }, startCheckpoint: { n: 2, k: 'u64' }, endCheckpoint: { n: 3, k: 'u64' }, filter: { n: 4, k: TxFilter }, options: { n: 5, k: QueryOptions } } as Schema,121 ListTransactionsResponse: { transaction: { n: 1, k: ExecutedTransaction }, watermark: { n: 2, k: { cursor: { n: 1, k: 'bytes' }, checkpoint: { n: 2, k: 'u64' } } }, end: { n: 3, k: { reason: { n: 1, k: 'u32' } } } } as Schema,122};123124export interface TimestampMsg { seconds?: bigint; nanos?: number }125export interface ServiceInfo { chainId?: string; chain?: string; epoch?: bigint; checkpointHeight?: bigint; timestamp?: TimestampMsg; lowestAvailableCheckpoint?: bigint }126export interface EpochMsg { epoch?: bigint; firstCheckpoint?: bigint; lastCheckpoint?: bigint; start?: TimestampMsg; end?: TimestampMsg; referenceGasPrice?: bigint }127export interface BalanceMsg { coinType?: string; balance?: bigint; addressBalance?: bigint; coinBalance?: bigint }128export interface ObjectMsg { objectId?: string; version?: bigint; digest?: string; owner?: { kind?: number; address?: string }; objectType?: string; balance?: bigint }129export interface GasMsg { computationCost?: bigint; storageCost?: bigint; storageRebate?: bigint; nonRefundableStorageFee?: bigint }130export interface ExecutedTx {131 digest?: string; transaction?: { sender?: string }; checkpoint?: bigint; timestamp?: TimestampMsg;132 effects?: { status?: { success?: boolean; error?: { description?: string; command?: bigint; kind?: number } }; epoch?: bigint; gasUsed?: GasMsg };133 balanceChanges: { address?: string; coinType?: string; amount?: string }[];134}135export interface ListedTx { transaction?: ExecutedTx; watermark?: { checkpoint?: bigint }; end?: { reason?: number } }136137// ---------------------------------------------------------------- transport138export const GRPC_NOT_FOUND = 5, GRPC_OUT_OF_RANGE = 11;139export class GrpcError extends Error {140 constructor(public grpcCode: number, message: string) { super(message); }141}142/** One gRPC call: request message bytes in, response message bytes out (one per message of the stream). Throws GrpcError on a gRPC status. */143export type GrpcCall = (base: string, method: string, request: Uint8Array, timeoutMs?: number) => Promise<Uint8Array[]>;144145const MAX_RESPONSE = 8 * 1024 * 1024;146function once(base: string, method: string, request: Uint8Array, timeoutMs: number): Promise<Uint8Array[]> {147 return new Promise((resolve, reject) => {148 const u = new URL(base);149 if (u.protocol !== 'https:') { reject(new Error('grpc: https only')); return; }150 const session = http2.connect(u.origin);151 let done = false;152 const finish = (err: Error | null, frames?: Uint8Array[]) => {153 if (done) return;154 done = true; clearTimeout(timer);155 try { session.destroy(); } catch { /* already closed */ }156 if (err) reject(err); else resolve(frames!);157 };158 const timer = setTimeout(() => finish(Object.assign(new Error('grpc: timeout'), { transport: true })), timeoutMs);159 session.on('error', (e) => finish(Object.assign(new Error(`grpc: ${e.message}`), { transport: true })));160 const frame = new Uint8Array(5 + request.length);161 new DataView(frame.buffer).setUint32(1, request.length); // byte 0 = 0: not compressed162 frame.set(request, 5);163 const req = session.request({ ':method': 'POST', ':path': method, 'content-type': 'application/grpc', te: 'trailers' });164 const chunks: Buffer[] = [];165 let size = 0, httpStatus = 0, status: string | undefined, message = '';166 const meta = (h: http2.IncomingHttpHeaders) => { if (h['grpc-status'] !== undefined) { status = String(h['grpc-status']); message = String(h['grpc-message'] ?? ''); } };167 req.on('response', (h) => { httpStatus = Number(h[':status']); meta(h); });168 req.on('trailers', meta);169 req.on('data', (d: Buffer) => { size += d.length; if (size > MAX_RESPONSE) finish(new Error('grpc: response too large')); else chunks.push(d); });170 req.on('error', (e) => finish(Object.assign(new Error(`grpc: ${e.message}`), { transport: true })));171 req.on('end', () => {172 if (httpStatus !== 200) return finish(new Error(`grpc: http ${httpStatus}`));173 if (status === undefined) return finish(new Error('grpc: no status'));174 if (status !== '0') { let m = message; try { m = decodeURIComponent(message); } catch { /* keep raw */ } return finish(new GrpcError(Number(status), m.slice(0, 300))); }175 const all = Buffer.concat(chunks), frames: Uint8Array[] = [];176 let o = 0;177 while (o < all.length) {178 if (o + 5 > all.length || all[o] !== 0) return finish(new Error('grpc: bad frame'));179 const len = all.readUInt32BE(o + 1);180 if (o + 5 + len > all.length) return finish(new Error('grpc: truncated frame'));181 frames.push(new Uint8Array(all.subarray(o + 5, o + 5 + len)));182 o += 5 + len;183 }184 finish(null, frames);185 });186 req.end(frame);187 });188}189/** gRPC over HTTP/2 with one retry on connection-level failures (never on an answer). Reads are safe to repeat; re-sending signed bytes is idempotent on-chain. */190export const grpcCall: GrpcCall = async (base, method, request, timeoutMs = 9000) => {191 try { return await once(base, method, request, timeoutMs); }192 catch (e) {193 if (!(e as { transport?: boolean }).transport) throw e;194 await new Promise((r) => setTimeout(r, 300));195 return once(base, method, request, timeoutMs);196 }197};198