Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Read a contract as State #50

Merged
merged 4 commits into from
May 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
Comment on lines +44 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this could be rewritten, but this is definitely not a show-stopper for stopping this PR from being merged


// ------------ 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(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note: this method only works for persistent fields. Perhaps, it's worth adding a comment

})
)
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const hiInt = cast.hi().toBigInt() * 2n ** 64n
const hiInt = cast.hi().toBigInt() * (2n ** 64n)

There is no bug here. I had to lookup the precedence of * and **, so it would be better to have it explicit

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had parenthesis initially, and I think the liter or the formatter actually removed them

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