diff --git a/solarkraft/package-lock.json b/solarkraft/package-lock.json index 94e8642d..2e7ab4b5 100644 --- a/solarkraft/package-lock.json +++ b/solarkraft/package-lock.json @@ -12,6 +12,7 @@ "@stellar/stellar-base": "^11.0.1", "@stellar/stellar-sdk": "^11.3.0", "@sweet-monads/either": "^3.3.1", + "@types/urijs": "^1.19.25", "axios": "^1.6.7", "chalk": "^5.3.0", "immutable": "^5.0.0-beta.5", @@ -25,10 +26,12 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", + "@types/eventsource": "^1.1.15", "@types/node": "^20.11.26", "chai": "^5.1.0", "copyfiles": "^2.4.1", "eslint": "^8.57.0", + "eventsource": "^2.0.2", "genversion": "^3.2.0", "husky": "^9.0.11", "mocha": "^10.3.0", @@ -281,6 +284,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/eventsource": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", + "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", + "dev": true + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -315,6 +324,11 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/urijs": { + "version": "1.19.25", + "resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.25.tgz", + "integrity": "sha512-XOfUup9r3Y06nFAZh3WvO0rBU4OtlfPB/vgxpjg+NRdGU6CN6djdc6OEiH+PcqHCY6eFLo9Ista73uarf4gnBg==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz", @@ -3578,6 +3592,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@types/eventsource": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", + "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", + "dev": true + }, "@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -3612,6 +3632,11 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "@types/urijs": { + "version": "1.19.25", + "resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.25.tgz", + "integrity": "sha512-XOfUup9r3Y06nFAZh3WvO0rBU4OtlfPB/vgxpjg+NRdGU6CN6djdc6OEiH+PcqHCY6eFLo9Ista73uarf4gnBg==" + }, "@typescript-eslint/eslint-plugin": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz", diff --git a/solarkraft/package.json b/solarkraft/package.json index f6cc5315..fe72026f 100644 --- a/solarkraft/package.json +++ b/solarkraft/package.json @@ -43,6 +43,7 @@ "@stellar/stellar-base": "^11.0.1", "@stellar/stellar-sdk": "^11.3.0", "@sweet-monads/either": "^3.3.1", + "@types/urijs": "^1.19.25", "axios": "^1.6.7", "chalk": "^5.3.0", "immutable": "^5.0.0-beta.5", @@ -53,10 +54,12 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", + "@types/eventsource": "^1.1.15", "@types/node": "^20.11.26", "chai": "^5.1.0", "copyfiles": "^2.4.1", "eslint": "^8.57.0", + "eventsource": "^2.0.2", "genversion": "^3.2.0", "husky": "^9.0.11", "mocha": "^10.3.0", diff --git a/solarkraft/src/io/xdrToState.ts b/solarkraft/src/io/xdrToState.ts new file mode 100644 index 00000000..5e7d3c8d --- /dev/null +++ b/solarkraft/src/io/xdrToState.ts @@ -0,0 +1,211 @@ +/** + * @license + * [Apache-2.0](https://github.com/freespek/solarkraft/blob/main/LICENSE) + */ +import { State } from '../state/state.js' +import * as V from '../state/value.js' + +import { xdr, Address } from '@stellar/stellar-sdk' +import { OrderedMap } from 'immutable' + +// Request bodies have a standardized shape, we parameterize the method (e.g. "getLedgerEntries"), +// and the keys (e.g. the fields of the contracts to be read by "getLedgerEntries") +// Technically we can assign unique IDs to our requests/responses, but this field should not matter for now. +export function makeRequestBody( + method: string, + contractLedgerKeys: string[], + id: number = 0 +): object { + return { + jsonrpc: '2.0', + id: id, + method: method, + params: { + keys: contractLedgerKeys, + }, + } +} + +export type Response = { + jsonrpc: string + id: number + result: { + entries: { + key: string + xdr: string + lastModifiedLedgerSeq: number + }[] + latestLedger: number + } +} + +// Async RPC calls give us a Promise. +// We need to make sure it's safe to cast to a Response, so we can access fields programmatically +function isResponse(data: unknown): boolean { + if (typeof data === 'object' && data !== null) { + const maybe = data as Response + if (typeof maybe['jsonrpc'] !== 'string') return false + if (typeof maybe['id'] !== 'number') return false + const res = maybe['result'] + if (typeof res !== 'object') return false + if (typeof res['latestLedger'] !== 'number') return false + const entries = res['entries'] + if (!Array.isArray(entries)) return false + + return !entries.some((e) => { + if (typeof e['key'] !== 'string') return true + if (typeof e['xdr'] !== 'string') return true + if (typeof e['lastModifiedLedgerSeq'] !== 'number') return true + return false + }) + } else return false +} + +// ------------ SOROBAN RPC METHODS ------------ +// ---- Copied from https://developers.stellar.org/network/soroban-rpc/api-reference/methods/getLedgerEntries ---- + +// Given a contract ID and a field belonging to that contract (`symbolText`), +// creates a lookup key to be used to query the current value of +// the field variable +function getLedgerKeySymbol(contractId: string, symbolText: string) { + const ledgerKey = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(contractId).toScAddress(), + key: xdr.ScVal.scvSymbol(symbolText), + durability: xdr.ContractDataDurability.persistent(), + }) + ) + return ledgerKey.toXDR('base64') +} + +// Given either the testnet URL, or a path to a local node, as `where`, we can send a +// request with the given body and receive what should be a Response +// If the response does not have the correct shape, throws a TypeError. +async function queryRPC(where: string, requestBody: object): Promise { + const res = await fetch(where, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + const json = await res.json() + + if (!isResponse(json)) + throw new TypeError( + 'Response received does not match the Response type' + ) + + return json as Response +} + +// ------------------------ + +// Conversion method from ScVal to Value +// After the RPC response is returned, it contains XDR-encoded values of type ScVal. In order to use +// our internal representation, we need to convert each ScVal to a corresponding Value +// TODO: in the future, we could consider getting rid of `Value` and using `ScVal` directly, as +// well as implementing a direct `ScVal` -> ITF translation. Note, however, that `ScValType` types are +// not in direct correspondence with Sorban types, and that handling `ScVal`s is more complicated than `Value`s +export function scValAsValue(v: xdr.ScVal): V.Value { + const vType = v.switch() + switch (vType) { + case xdr.ScValType.scvBool(): + return V.bool(v.value() as boolean) + case xdr.ScValType.scvAddress(): + return V.addr( + (v.value() as xdr.ScAddress).accountId().value().toString() + ) + case xdr.ScValType.scvSymbol(): + return V.symb(v.value() as string) + case xdr.ScValType.scvU32(): + return V.u32(BigInt(v.value() as number)) + case xdr.ScValType.scvI32(): + return V.i32(BigInt(v.value() as number)) + case xdr.ScValType.scvU64(): + return V.u64((v.value() as xdr.Uint64).toBigInt()) + case xdr.ScValType.scvI64(): + return V.i64((v.value() as xdr.Int64).toBigInt()) + case xdr.ScValType.scvU128(): { + const cast = v.value() as xdr.UInt128Parts + const hiInt = cast.hi().toBigInt() * 2n ** 64n + const loInt = cast.lo().toBigInt() + return V.u128(hiInt + loInt) + } + case xdr.ScValType.scvI128(): { + // TODO: investigate how exactly the conversion to u128 from Int128Parts works. + throw new Error('scvI128 conversion not yet implemented') + // const cast = v.value() as xdr.Int128Parts + // const hiBits = cast.hi().toBigInt() + // const sign = hiBits >> 63n + // const hiInt = hiBits & ~(1n << 63n) + // const loInt = cast.lo().toBigInt() + // V.u128(sign * (hiInt + loInt)) + } + case xdr.ScValType.scvBytes(): + return V.bytes(Array.from((v.value() as Buffer).valueOf())) + + case xdr.ScValType.scvVec(): + return V.vec((v.value() as xdr.ScVal[]).map(scValAsValue), false) + + case xdr.ScValType.scvMap(): { + const entries = v.value() as xdr.ScMapEntry[] + const pairs: V.KeyValuePair[] = entries.map((x) => [ + scValAsValue(x.key()), + scValAsValue(x.val()), + ]) + return V.mapFromKV(pairs, false) + } + + default: { + console.log(`Got: ${v.switch().name}`) + throw new TypeError(`${v} does not correspond to any Value`) + } + } +} + +// Main entry point function +// Given a contract's ID `cid`, and the field names, the values of which we want to read, retrieves a `State` +// object, by submitting an RPC query to the location `where`, and interpreting the Response obtained. +// The keys of the `State` returned are the field names provided, and their corresponding values are the `Value`s interpreted from +// the response XDRs. +export async function fetchContractState( + cid: string, + fields: string[], + where: string +): Promise { + // First, we must convert each key (string) to a `LedgerKey`. + const keys = fields.map((fld) => getLedgerKeySymbol(cid, fld)) + + // Second, we create the request, containing all of the ledger keys at once + const request = makeRequestBody('getLedgerEntries', keys) + + // We receive a Response (queryRPC throws if the response shape is incorrect) + const response: Response = await queryRPC(where, request) + + // We don't care about the response metadata, we just need to extract the xdrs. + const xdrs = response.result.entries.map((e) => e.xdr) + + // sanity check, the number of fields in the resonse should match the number of fields in the query + if (xdrs.length !== fields.length) + throw new Error( + 'Number of fields in response does not match the number of fields in the input' + ) + + // Each XDR is a base64 encoded `LedgerEntryData` object, we read them back + const data = xdrs.map((singleXDR) => + xdr.LedgerEntryData.fromXDR(singleXDR, 'base64') + ) + + // To create a state, we need to check under _.contractData.val to read the value. + // Since it comes in ScVal, we need scValAsValue to convert it. + const values = data.map((d) => scValAsValue(d.contractData().val())) + + // Finally, we zip the inputs (field names) with the values, and create the State map + const pairs: Iterable<[string, V.Value]> = fields.map((f, idx) => [ + f, + values[idx], + ]) + + return OrderedMap(pairs) +} diff --git a/solarkraft/test/io/xdrToState.test.ts b/solarkraft/test/io/xdrToState.test.ts new file mode 100644 index 00000000..3ccc4248 --- /dev/null +++ b/solarkraft/test/io/xdrToState.test.ts @@ -0,0 +1,29 @@ +// an example unit test to copy from + +import { assert } from 'chai' +import { describe, it } from 'mocha' + +import { fetchContractState } from '../../src/io/xdrToState.js' + +describe('xdr', () => { + it('tests an async call to the testnet URL, reading the contract from the online tutorial', async () => { + // Data from https://developers.stellar.org/network/soroban-rpc/api-reference/methods/getLedgerEntries + const contractId = + 'CCPYZFKEAXHHS5VVW5J45TOU7S2EODJ7TZNJIA5LKDVL3PESCES6FNCI' + const where = 'https://soroban-testnet.stellar.org' + + const fields = ['COUNTER'] + + const state = await fetchContractState(contractId, fields, where) + + const keys = Array.from(state.keySeq()) + assert(keys.length === fields.length) + for (const i of fields.keys()) { + assert(fields[i] === keys[i]) + } + + const ctrVal = state.get('COUNTER') + assert(ctrVal.type === 'u32') + assert(ctrVal.val === 2n) + }) +}) diff --git a/solarkraft/tsconfig.json b/solarkraft/tsconfig.json index 717a8dd5..0761facd 100644 --- a/solarkraft/tsconfig.json +++ b/solarkraft/tsconfig.json @@ -6,7 +6,8 @@ "esModuleInterop": true, "moduleResolution": "Node16", "sourceMap": true, - "outDir": "dist" + "outDir": "dist", + "skipLibCheck": true }, "lib": ["ES2021"], "include": [ "src", "test" ],