Skip to content

Commit

Permalink
Merge pull request #50 from freespek/jk/xdr
Browse files Browse the repository at this point in the history
Read a contract as `State`
  • Loading branch information
konnov authored May 10, 2024
2 parents 1406d59 + 220b83d commit b3febef
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 1 deletion.
25 changes: 25 additions & 0 deletions solarkraft/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions solarkraft/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
211 changes: 211 additions & 0 deletions solarkraft/src/io/xdrToState.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>.
// 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<Response> {
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<State> {
// 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<string, V.Value>(pairs)
}
29 changes: 29 additions & 0 deletions solarkraft/test/io/xdrToState.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
3 changes: 2 additions & 1 deletion solarkraft/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"esModuleInterop": true,
"moduleResolution": "Node16",
"sourceMap": true,
"outDir": "dist"
"outDir": "dist",
"skipLibCheck": true
},
"lib": ["ES2021"],
"include": [ "src", "test" ],
Expand Down

0 comments on commit b3febef

Please sign in to comment.