Skip to content

Commit de1d92f

Browse files
authored
Merge pull request #155 from freespek/igor/aggregate-storage153
implement basic state aggregation
2 parents a3548ad + a1d0684 commit de1d92f

File tree

7 files changed

+624
-13
lines changed

7 files changed

+624
-13
lines changed

solarkraft/src/aggregate.ts

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* @license
3+
* [Apache-2.0](https://github.com/freespek/solarkraft/blob/main/LICENSE)
4+
*/
5+
/**
6+
* A command to aggregate the full contract state from the collected transactions.
7+
* This command is potentially expensive, as the full contract state potentially
8+
* grows much faster than individual transactions (think of multiple pair-to-pair
9+
* token transfers between a large number of accounts). In the long run, it makes
10+
* sense to collect the states from the history archives. For the time being,
11+
* we aggregate the states from the transactions directly, in order to evaluate
12+
* the approach.
13+
*
14+
* We need this feature primarily for input generation. This is an experimental
15+
* feature in Phase 1. We always aggregate the state, starting with the minimal
16+
* available height. Obviously, there is a lot of room for improvement here.
17+
*
18+
* Although it is tempting to aggregate transactions directly in fetch.ts,
19+
* this is the wrong approach. Horizon may give us transactions out of order,
20+
* so we need to sort them by height before state aggregation.
21+
*
22+
* Igor Konnov, 2024
23+
*/
24+
25+
import { existsSync, writeFileSync } from 'fs'
26+
import { join } from 'path'
27+
import { JSONbig } from './globals.js'
28+
import {
29+
emptyFullState,
30+
loadContractCallEntry,
31+
storagePath,
32+
yieldListEntriesForContract,
33+
} from './fetcher/storage.js'
34+
import { applyCallToState } from './aggregator/aggregator.js'
35+
36+
/**
37+
* Aggregate the fetched transactions to compute the full contract state.
38+
* @param args the aggregator arguments
39+
*/
40+
export async function aggregate(args: any) {
41+
const storageRoot = storagePath(args.home)
42+
if (!existsSync(storageRoot)) {
43+
console.error(`The storage is empty. Run 'solarkraft fetch'`)
44+
return
45+
}
46+
47+
const contractId = args.id
48+
49+
// We have to sort the entries by height. Hence, we read them first and then sort.
50+
let lastEntry = undefined
51+
const entries = []
52+
for (const e of yieldListEntriesForContract(
53+
contractId,
54+
join(storageRoot, contractId)
55+
)) {
56+
if (e.height <= args.heightTo) {
57+
entries.push(e)
58+
}
59+
if (lastEntry && lastEntry.height === e.height) {
60+
// this should not happen on the testnet, as there is only one transaction per height
61+
console.warn(
62+
`Height ${e.height}: transactions ${e.txHash} and ${lastEntry.txHash} may be out of order`
63+
)
64+
}
65+
lastEntry = e
66+
}
67+
// sort the entries
68+
entries.sort((a, b) => a.height - b.height)
69+
70+
// now we can aggregate the state
71+
let nentries = 0
72+
let state = emptyFullState()
73+
for (const entry of entries) {
74+
nentries++
75+
const txEntry = loadContractCallEntry(args.home, entry.txHash)
76+
if (txEntry.isRight()) {
77+
if (args.verbose) {
78+
console.log(`Height ${entry.height}: applied ${entry.txHash}`)
79+
}
80+
state = applyCallToState(state, txEntry.value)
81+
} else {
82+
console.error(
83+
`Failed to load the transaction ${entry.txHash}: ${txEntry.value}`
84+
)
85+
return
86+
}
87+
}
88+
89+
// save the aggregated state
90+
const contents = JSONbig.stringify(state)
91+
writeFileSync(args.out, contents)
92+
if (args.verbose) {
93+
console.log(`Aggregated ${nentries} transactions into ${args.out}`)
94+
}
95+
}
+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* @license
3+
* [Apache-2.0](https://github.com/freespek/solarkraft/blob/main/LICENSE)
4+
*/
5+
// State aggregator. We do not expect this aggregator to be efficient.
6+
// It is a proof of concept that is needed to implement input generation.
7+
//
8+
// See: https://github.com/freespek/solarkraft/issues/153
9+
//
10+
// Igor Konnov, 2024
11+
12+
import {
13+
ContractCallEntry,
14+
emptyContractStorage,
15+
FieldsMap,
16+
FullState,
17+
MultiContractStorage,
18+
} from '../fetcher/storage.js'
19+
20+
/**
21+
* Apply the updates from a contract call to the state.
22+
* @param state the state to update
23+
* @param callEntry the call entry to apply
24+
* @returns the updated state
25+
*/
26+
export function applyCallToState(
27+
state: FullState,
28+
callEntry: ContractCallEntry
29+
): FullState {
30+
if (callEntry.txSuccess !== true) {
31+
console.warn(`Skipping failed transaction ${callEntry.txHash}`)
32+
return state
33+
}
34+
35+
return {
36+
contractId: callEntry.contractId,
37+
timestamp: callEntry.timestamp,
38+
height: callEntry.height,
39+
latestTxHash: callEntry.txHash,
40+
storage: updateContractStorage(
41+
state.storage,
42+
callEntry.oldStorage,
43+
callEntry.storage
44+
),
45+
}
46+
}
47+
48+
function updateContractStorage(
49+
fullStorage: MultiContractStorage,
50+
oldStorage: MultiContractStorage,
51+
storage: MultiContractStorage
52+
): MultiContractStorage {
53+
let updatedStorage = fullStorage
54+
for (const [contractId, contractStorage] of storage) {
55+
const contractFullStorage =
56+
fullStorage.get(contractId) ?? emptyContractStorage()
57+
const contractOldStorage =
58+
oldStorage.get(contractId) ?? emptyContractStorage()
59+
updatedStorage = updatedStorage.set(contractId, {
60+
instance: updateFieldsMap(
61+
contractFullStorage.instance,
62+
contractOldStorage.instance,
63+
contractStorage.instance
64+
),
65+
persistent: updateFieldsMap(
66+
contractFullStorage.persistent,
67+
contractOldStorage.persistent,
68+
contractStorage.persistent
69+
),
70+
temporary: updateFieldsMap(
71+
contractFullStorage.temporary,
72+
contractOldStorage.temporary,
73+
contractStorage.temporary
74+
),
75+
})
76+
}
77+
return updatedStorage
78+
}
79+
80+
function updateFieldsMap(
81+
fullStorageFields: FieldsMap,
82+
oldFieldsInCall: FieldsMap,
83+
fieldsInCall: FieldsMap
84+
): FieldsMap {
85+
let updatedFields = fullStorageFields
86+
// note that storage entries in ContractCallEntry are small subsets of the full storage
87+
for (const key of oldFieldsInCall.keys()) {
88+
if (!fieldsInCall.has(key)) {
89+
updatedFields = updatedFields.delete(key)
90+
}
91+
}
92+
for (const [key, value] of fieldsInCall) {
93+
updatedFields = updatedFields.set(key, value)
94+
}
95+
return updatedFields
96+
}

solarkraft/src/fetcher/storage.ts

+123-9
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,56 @@ export interface FetcherState {
178178
heights: OrderedMap<string, number>
179179
}
180180

181+
/**
182+
* This entry collects the full contract state.
183+
* This is obviously expensive to store, so it should be improved,
184+
* if the MVP proves successful.
185+
*/
186+
export interface FullState {
187+
/**
188+
* The number of seconds elapsed since unix epoch of when the latest contract ledger was closed.
189+
*/
190+
timestamp: number
191+
/**
192+
* The ledger number that this state corresponds to.
193+
*/
194+
height: number
195+
/**
196+
* The hash of the latest transaction that led to this state.
197+
*/
198+
latestTxHash: string
199+
/**
200+
* The address of the contract being called.
201+
*/
202+
contractId: string
203+
/**
204+
* Ordered mapping from contract address to instance/persistent/temporary storage
205+
* of the respective contract. The contract storage for a given durability is
206+
* an ordered mapping from field names to their native values (JS).
207+
*
208+
* This mapping contains values only for the fields that have been created
209+
* or updated by a transaction in the past. It may happen that
210+
* `storage` contains fewer fields than `oldStorage`, when the contract
211+
* deletes some fields from the storage. Also, fields may be cleared from `storage`
212+
* when the storage goes over TTL.
213+
*/
214+
storage: MultiContractStorage
215+
}
216+
217+
/**
218+
* The empty full state that is to be initialized.
219+
* @returns an empty full state
220+
*/
221+
export function emptyFullState(): FullState {
222+
return {
223+
timestamp: 0,
224+
height: 0,
225+
latestTxHash: '',
226+
contractId: '',
227+
storage: emptyMultiContractStorage(),
228+
}
229+
}
230+
181231
/**
182232
* Given the solarkraft home, construct the path to store the transactions.
183233
* @param solarkraftHome path to solarkraft home (or project directory)
@@ -191,8 +241,9 @@ export function storagePath(solarkraftHome: string): string {
191241
* Store a contract call entry in the file storage.
192242
* @param home the storage root directory
193243
* @param entry a call entry
244+
* @returns the filename, where the entry was stored
194245
*/
195-
export function saveContractCallEntry(home: string, entry: ContractCallEntry) {
246+
export function saveContractCallEntry(home: string, entry: ContractCallEntry): string {
196247
const filename = getEntryFilename(storagePath(home), entry)
197248
const verificationStatus: VerificationStatus =
198249
entry.verificationStatus ?? VerificationStatus.Unknown
@@ -261,6 +312,44 @@ export function loadContractCallEntry(
261312
})
262313
}
263314

315+
/**
316+
* Load full state of a contract from the storage.
317+
* @param solarkraftHome the .solarkraft directory
318+
* @param contractId the contract address
319+
* @returns the loaded full state
320+
*/
321+
export function loadContractFullState(
322+
solarkraftHome: string,
323+
contractId: string
324+
): Result<FullState> {
325+
const filename = getFullStateFilename(
326+
storagePath(solarkraftHome),
327+
contractId
328+
)
329+
if (!existsSync(filename)) {
330+
return left(`No state found for contract ${contractId}`)
331+
}
332+
const contents = readFileSync(filename)
333+
const loaded = JSONbig.parse(contents)
334+
return right({
335+
...loaded,
336+
storage: storageFromJS(loaded.storage),
337+
})
338+
}
339+
340+
/**
341+
* Store contract full state in the file storage.
342+
* @param home the storage root directory
343+
* @param state a state of the contract
344+
* @returns the filename, where the state was stored
345+
*/
346+
export function saveContractFullState(home: string, state: FullState): string {
347+
const filename = getFullStateFilename(storagePath(home), state.contractId)
348+
const contents = JSONbig.stringify(state)
349+
writeFileSync(filename, contents)
350+
return filename
351+
}
352+
264353
/**
265354
* Generate storage entries for a given contract id in a path.
266355
* @param contractId contract identifier (address)
@@ -275,7 +364,8 @@ export function* yieldListEntriesForContract(
275364
if (dirent.isDirectory() && /^[0-9]+$/.exec(dirent.name)) {
276365
// This directory may contain several transactions for the same height.
277366
const height = Number.parseInt(dirent.name)
278-
for (const ledgerDirent of readdirSync(join(path, dirent.name), {
367+
const dirPath = join(path, dirent.name)
368+
for (const ledgerDirent of readdirSync(dirPath, {
279369
withFileTypes: true,
280370
})) {
281371
// match all storage entries, which may be reported in different cases
@@ -284,10 +374,7 @@ export function* yieldListEntriesForContract(
284374
)
285375
if (ledgerDirent.isFile() && matcher) {
286376
const txHash = matcher[1]
287-
const filename = join(
288-
ledgerDirent.path,
289-
`entry-${txHash}.json`
290-
)
377+
const filename = join(dirPath, `entry-${txHash}.json`)
291378
const contents = JSONbig.parse(
292379
readFileSync(filename, 'utf-8')
293380
)
@@ -351,10 +438,22 @@ export function saveFetcherState(home: string, state: FetcherState): string {
351438
* @returns the filename
352439
*/
353440
function getEntryFilename(root: string, entry: ContractCallEntry) {
354-
const dir = getOrCreateDirectory(root, entry)
441+
const dir = getOrCreateEntryParentDir(root, entry)
355442
return join(dir, `entry-${entry.txHash}.json`)
356443
}
357444

445+
/**
446+
* Get the directory name to store contract state.
447+
*
448+
* @param root storage root
449+
* @param contractId the contract id to retrieve the state for
450+
* @returns the directory name
451+
*/
452+
function getFullStateFilename(root: string, contractId: string) {
453+
const dir = getOrCreateContractDir(root, contractId)
454+
return join(dir, 'state.json')
455+
}
456+
358457
/**
359458
* Get the filename for the fetcher state.
360459
*
@@ -374,8 +473,23 @@ function getFetcherStateFilename(root: string) {
374473
* @param entry call entry
375474
* @returns the directory name
376475
*/
377-
function getOrCreateDirectory(root: string, entry: ContractCallEntry) {
378-
const directory = join(root, entry.contractId, entry.height.toString())
476+
function getOrCreateEntryParentDir(root: string, entry: ContractCallEntry) {
477+
const contractDir = getOrCreateContractDir(root, entry.contractId)
478+
const directory = join(contractDir, entry.height.toString())
479+
mkdirSync(directory, { recursive: true })
480+
return directory
481+
}
482+
483+
/**
484+
* Return the contract directory.
485+
* If this directory does not exist, create it recursively.
486+
*
487+
* @param root storage root
488+
* @param contractId contract address
489+
* @returns the directory name
490+
*/
491+
function getOrCreateContractDir(root: string, contractId: string) {
492+
const directory = join(root, contractId)
379493
mkdirSync(directory, { recursive: true })
380494
return directory
381495
}

0 commit comments

Comments
 (0)