diff --git a/ContractExamples/contracts/setter/src/lib.rs b/ContractExamples/contracts/setter/src/lib.rs index 76b47215..c27fddc9 100644 --- a/ContractExamples/contracts/setter/src/lib.rs +++ b/ContractExamples/contracts/setter/src/lib.rs @@ -120,6 +120,13 @@ impl SetterContract { old } + pub fn set_bool_if_notset(env: Env) { + if env.storage().instance().has(&MY_BOOL) { + panic!("already set"); + } + env.storage().instance().set(&MY_BOOL, &true); + } + pub fn set_u32(env: Env, v: u32) -> u32 { let old: u32 = env.storage().instance().get(&MY_U32).unwrap_or(0); env.storage().instance().set(&MY_U32, &v); diff --git a/ContractExamples/scripts/setter-populate.sh b/ContractExamples/scripts/setter-populate.sh index 129d2cf1..2359b802 100755 --- a/ContractExamples/scripts/setter-populate.sh +++ b/ContractExamples/scripts/setter-populate.sh @@ -20,6 +20,8 @@ NET=testnet ACCOUNT=alice soroban keys address $ACCOUNT || (echo "add the account $ACCOUNT via soroban keys generate"; exit 1) +ACCOUNT2=bob +soroban keys address $ACCOUNT2 || (echo "add the account $ACCOUNT2 via soroban keys generate"; exit 1) set -x @@ -59,3 +61,7 @@ soroban contract invoke --id $(cat .setter.id) --source $ACCOUNT --network $NET -- set_enum --v '{ "B": "-200" }' soroban contract invoke --id $(cat .setter.id) --source $ACCOUNT --network $NET \ -- remove_bool + +# we can provoke a failed transaction by submitting 2 transactions in parallel from different accounts +soroban contract invoke --id $(cat .setter.id) --source $ACCOUNT --network $NET -- set_bool_if_notset & +soroban contract invoke --id $(cat .setter.id) --source $ACCOUNT2 --network $NET -- set_bool_if_notset diff --git a/solarkraft/src/fetch.ts b/solarkraft/src/fetch.ts index caa26ffb..f32585d2 100644 --- a/solarkraft/src/fetch.ts +++ b/solarkraft/src/fetch.ts @@ -113,27 +113,30 @@ export async function fetch(args: any) { // initiate the streaming loop const closeHandler = server .operations() + .includeFailed(true) .cursor(startCursor) .stream({ - onmessage: async (msg: any) => { - if (msg.transaction_successful) { - const callEntryMaybe = await extractContractCall( - msg, - (id) => contractId === id, - typemapJson - ) - if (callEntryMaybe.isJust()) { - const entry = callEntryMaybe.value - console.log(`+ save: ${entry.height}`) - saveContractCallEntry(args.home, entry) - } - } // TODO(#61): else: shall we also store reverted transactions? + onmessage: async (op: any) => { + if (op.type !== 'invoke_host_function') { + return + } + op = op as Horizon.ServerApi.InvokeHostFunctionOperationRecord + const callEntryMaybe = await extractContractCall( + op, + (id) => contractId === id, + typemapJson + ) + if (callEntryMaybe.isJust()) { + const entry = callEntryMaybe.value + console.log(`+ save: ${entry.height}`) + saveContractCallEntry(args.home, entry) + } nEvents++ if (nEvents % HEIGHT_FETCHING_PERIOD === 0) { // Fetch the height of the current message and persist it for the future runs. // Note that messages may come slightly out of order, so the heights are not precise. - const tx = await msg.transaction() + const tx = await op.transaction() lastHeight = Math.max(lastHeight, tx.ledger_attr) console.log(`= at: ${lastHeight}`) // Load and save the state. Other fetchers may work concurrently, diff --git a/solarkraft/src/fetcher/callDecoder.ts b/solarkraft/src/fetcher/callDecoder.ts index 02da466c..654b2a8b 100644 --- a/solarkraft/src/fetcher/callDecoder.ts +++ b/solarkraft/src/fetcher/callDecoder.ts @@ -10,7 +10,7 @@ * @license [Apache-2.0](https://github.com/freespek/solarkraft/blob/main/LICENSE) */ -import sdk, { Address } from '@stellar/stellar-sdk' +import sdk, { Address, Horizon } from '@stellar/stellar-sdk' import { ContractCallEntry, emptyContractStorage, @@ -26,10 +26,11 @@ import { Maybe, just, none } from '@sweet-monads/maybe' * @param matcher a quick matcher over the contractId to avoid expensive deserialization */ export async function extractContractCall( - op: any, + op: Horizon.ServerApi.InvokeHostFunctionOperationRecord, matcher: (contractId: string) => boolean, typemapJson: any = {} ): Promise> { + // `op.function` can be one of HostFunctionTypeHostFunctionTypeInvokeContract, HostFunctionTypeHostFunctionTypeCreateContract, or HostFunctionTypeHostFunctionTypeUploadContractWasm. // https://developers.stellar.org/network/horizon/api-reference/resources/operations/object/invoke-host-function if (op.function !== 'HostFunctionTypeHostFunctionTypeInvokeContract') { return none() @@ -120,6 +121,7 @@ export async function extractContractCall( height, timestamp, txHash, + txSuccess: op.transaction_successful, contractId, method, methodArgs, diff --git a/solarkraft/src/fetcher/storage.ts b/solarkraft/src/fetcher/storage.ts index 266413a5..83a9accf 100644 --- a/solarkraft/src/fetcher/storage.ts +++ b/solarkraft/src/fetcher/storage.ts @@ -81,6 +81,10 @@ export interface ContractCallEntry { * Transaction hash. */ txHash: string + /** + * Whether the transaction was successful or failed. + */ + txSuccess: boolean /** * The address of the contract being called. */ @@ -213,13 +217,7 @@ export function loadContractCallEntry(filename: string): ContractCallEntry { const contents = readFileSync(filename) const loaded = JSONbig.parse(contents) return { - timestamp: loaded.timestamp as number, - height: loaded.height as number, - contractId: loaded.contractId as string, - txHash: loaded.txHash as string, - method: loaded.method as string, - methodArgs: loaded.methodArgs as any[], - returnValue: loaded.returnValue, + ...loaded, fields: OrderedMap(loaded.fields), oldFields: OrderedMap(loaded.oldFields), oldStorage: Immutable.fromJS(loaded.oldStorage), diff --git a/solarkraft/test/e2e/list.test.ts b/solarkraft/test/e2e/list.test.ts index 2a2e86ec..3a7b733b 100644 --- a/solarkraft/test/e2e/list.test.ts +++ b/solarkraft/test/e2e/list.test.ts @@ -25,6 +25,7 @@ describe('list', () => { timestamp: 1716393856, height: 1000, txHash: TX_HASH, + txSuccess: true, contractId: CONTRACT_ID, method: 'set_i32', methodArgs: [42], diff --git a/solarkraft/test/integration/callDecoder.test.ts b/solarkraft/test/integration/callDecoder.test.ts index e4f06472..6a900fde 100644 --- a/solarkraft/test/integration/callDecoder.test.ts +++ b/solarkraft/test/integration/callDecoder.test.ts @@ -35,13 +35,14 @@ const SOROBAN_URL = 'https://soroban-testnet.stellar.org:443' // hard-coded WASM code hash of the setter contract on the ledger (deployed via setter-populate.sh) const WASM_HASH = - '5218397dd64e5b5c2eead7e03f15a3ddb25c759fdf068d6f7dc8bffc8a033711' + '61da69e298a4c923eb699c22f4846cfb5f4926ab2b6a572bb85729e108a968d4' // contract ID of the deployed setter contract (will be set up by `before()`) let CONTRACT_ID: string -// a random keypair to sign transactions -const sourceKeypair = Keypair.random() +// 2 fresh keypairs to sign transactions (we need two accounts to produce concurrent transactions) +const alice = Keypair.random() +const bob = Keypair.random() // 0xbeef const beef = Buffer.from([0xbe, 0xef]) @@ -59,7 +60,10 @@ async function extractEntry(txHash: string): Promise { const operations = await server.operations().forTransaction(txHash).call() let resultingEntry: Maybe = none() for (const op of operations.records) { - const entry = await extractContractCall(op, (id) => id === CONTRACT_ID) + const entry = await extractContractCall( + op as Horizon.ServerApi.InvokeHostFunctionOperationRecord, + (id) => id === CONTRACT_ID + ) if (entry.isJust()) { assert( resultingEntry.isNone(), @@ -78,16 +82,25 @@ async function extractEntry(txHash: string): Promise { } } -// submit a transaction; if successful, return its transaction hash and the response +// submit a transaction, return its transaction hash and the response async function submitTx( server: SorobanRpc.Server, - tx: Transaction -): Promise<[string, SorobanRpc.Api.GetSuccessfulTransactionResponse]> { + tx: Transaction, + keypair: Keypair +): Promise< + [ + string, + ( + | SorobanRpc.Api.GetSuccessfulTransactionResponse + | SorobanRpc.Api.GetFailedTransactionResponse + ), + ] +> { // Use the RPC server to "prepare" the transaction (simulate, update storage footprint) const preparedTransaction = await server.prepareTransaction(tx) // Sign the transaction with the source account's keypair - preparedTransaction.sign(sourceKeypair) + preparedTransaction.sign(keypair) // Submit the transaction const sendResponse = await server.sendTransaction(preparedTransaction) @@ -100,26 +113,26 @@ async function submitTx( getResponse = await server.getTransaction(sendResponse.hash) await new Promise((resolve) => setTimeout(resolve, 1000)) } - if (getResponse.status === 'SUCCESS') { - if (!getResponse.resultMetaXdr) { - throw 'Empty resultMetaXDR in getTransaction response' - } - } else { - throw `Transaction failed: ${getResponse.resultXdr}` - } return [sendResponse.hash, getResponse] } else { + console.error('Transaction failed:', sendResponse.status) throw sendResponse.errorResult } } -// Invoke contract function `functionName` with arguments `args` and return the transaction hash. -// -// `args` are converted to Soroban values using `nativeToScVal`. +// Invoke contract function `functionName` with arguments `args`, return the transaction hash and the response async function callContract( functionName: string, ...args: xdr.ScVal[] -): Promise { +): Promise< + [ + string, + ( + | SorobanRpc.Api.GetSuccessfulTransactionResponse + | SorobanRpc.Api.GetFailedTransactionResponse + ), + ] +> { // adapted from https://developers.stellar.org/docs/learn/encyclopedia/contract-development/contract-interactions/stellar-transaction#function const server = new SorobanRpc.Server(SOROBAN_URL) @@ -127,7 +140,7 @@ async function callContract( const contract = new Contract(CONTRACT_ID) // build the transaction - const sourceAccount = await server.getAccount(sourceKeypair.publicKey()) + const sourceAccount = await server.getAccount(alice.publicKey()) const builtTransaction = new TransactionBuilder(sourceAccount, { fee: BASE_FEE, networkPassphrase: Networks.TESTNET, @@ -136,8 +149,7 @@ async function callContract( .setTimeout(30) // tx is valid for 30 seconds .build() - const [txHash] = await submitTx(server, builtTransaction) - return txHash + return await submitTx(server, builtTransaction, alice) } describe('call decoder from Horizon', function () { @@ -149,31 +161,36 @@ describe('call decoder from Horizon', function () { // This may take a bit; increase the timeout this.timeout(50_000) - // Fund the keypair - console.log(`Funding the keypair ${sourceKeypair.publicKey()} ...`) + // Fund the keypairs + console.log( + `Funding the keypairs ${alice.publicKey()} and ${bob.publicKey()}...` + ) const horizon = new Horizon.Server(HORIZON_URL) - await horizon.friendbot(sourceKeypair.publicKey()).call() + await horizon.friendbot(alice.publicKey()).call() + await horizon.friendbot(bob.publicKey()).call() // Redeploy a fresh copy of the setter contract WASM from CONTRACT_ID_TEMPLATE console.log(`Creating a contract from WASM code ${WASM_HASH} ...`) const soroban = new SorobanRpc.Server(SOROBAN_URL) - const sourceAccount = await soroban.getAccount( - sourceKeypair.publicKey() - ) + const sourceAccount = await soroban.getAccount(alice.publicKey()) const builtTransaction = new TransactionBuilder(sourceAccount, { fee: BASE_FEE, networkPassphrase: Networks.TESTNET, }) .addOperation( Operation.createCustomContract({ - address: Address.fromString(sourceKeypair.publicKey()), + address: Address.fromString(alice.publicKey()), wasmHash: Buffer.from(WASM_HASH, 'hex'), }) ) .setTimeout(30) // tx is valid for 30 seconds .build() - const [txHash, response] = await submitTx(soroban, builtTransaction) + const [txHash, response] = await submitTx( + soroban, + builtTransaction, + alice + ) CONTRACT_ID = Address.fromScAddress( response.resultMetaXdr.v3().sorobanMeta().returnValue().address() ).toString() @@ -181,8 +198,14 @@ describe('call decoder from Horizon', function () { `Fresh setter contract deployed by tx ${txHash} at ${CONTRACT_ID}` ) }) - it('call #1: Setter.set_bool(true)', async function () { - const txHash = await callContract('set_bool', nativeToScVal(true)) + + it('call #1: Setter.set_bool(true)', async () => { + const [txHash, response] = await callContract( + 'set_bool', + nativeToScVal(true) + ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -205,11 +228,13 @@ describe('call decoder from Horizon', function () { }) }) - it('call #2: Setter.set_u32([42u32])', async function () { - const txHash = await callContract( + it('call #2: Setter.set_u32([42u32])', async () => { + const [txHash, response] = await callContract( 'set_u32', nativeToScVal(42, { type: 'u32' }) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -239,11 +264,13 @@ describe('call decoder from Horizon', function () { }) }) - it('call #3: Setter.set_i32([-42u32])', async function () { - const txHash = await callContract( + it('call #3: Setter.set_i32([-42u32])', async () => { + const [txHash, response] = await callContract( 'set_i32', nativeToScVal(-42, { type: 'i32' }) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -277,11 +304,13 @@ describe('call decoder from Horizon', function () { }) }) - it('call #4: Setter.set_u64([42u64])', async function () { - const txHash = await callContract( + it('call #4: Setter.set_u64([42u64])', async () => { + const [txHash, response] = await callContract( 'set_u64', nativeToScVal(42, { type: 'u64' }) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -323,10 +352,12 @@ describe('call decoder from Horizon', function () { }) it('call #5: Setter.set_i64([-42i64])', async () => { - const txHash = await callContract( + const [txHash, response] = await callContract( 'set_i64', nativeToScVal(-42, { type: 'i64' }) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -376,10 +407,12 @@ describe('call decoder from Horizon', function () { }) it('call #6: Setter.set_u128([42u128])', async () => { - const txHash = await callContract( + const [txHash, response] = await callContract( 'set_u128', nativeToScVal(42, { type: 'u128' }) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -433,10 +466,12 @@ describe('call decoder from Horizon', function () { }) it('call #7: Setter.set_i128([-42i128])', async () => { - const txHash = await callContract( + const [txHash, response] = await callContract( 'set_i128', nativeToScVal(-42, { type: 'i128' }) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -494,10 +529,12 @@ describe('call decoder from Horizon', function () { }) it('call #8: Setter.set_sym("hello")', async () => { - const txHash = await callContract( + const [txHash, response] = await callContract( 'set_sym', nativeToScVal('hello', { type: 'symbol' }) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -559,7 +596,12 @@ describe('call decoder from Horizon', function () { }) it('call #9: Setter.set_bytes(0xbeef)', async () => { - const txHash = await callContract('set_bytes', nativeToScVal(beef)) + const [txHash, response] = await callContract( + 'set_bytes', + nativeToScVal(beef) + ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -625,7 +667,12 @@ describe('call decoder from Horizon', function () { }) it('call #10: Setter.set_bytes32(...)', async () => { - const txHash = await callContract('set_bytes32', nativeToScVal(bytes32)) + const [txHash, response] = await callContract( + 'set_bytes32', + nativeToScVal(bytes32) + ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -695,10 +742,12 @@ describe('call decoder from Horizon', function () { }) it('call #11: Setter.set_vec([[1i32, -2i32, 3i32]])', async () => { - const txHash = await callContract( + const [txHash, response] = await callContract( 'set_vec', nativeToScVal([1, -2, 3], { type: 'i32' }) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -772,13 +821,15 @@ describe('call decoder from Horizon', function () { }) it('call #12: Setter.set_map([{2u32: 3i32, 4u32: 5i32}])', async () => { - const txHash = await callContract( + const [txHash, response] = await callContract( 'set_map', nativeToScVal( { '2': 3, '4': 5 }, { type: { '2': ['u32', 'i32'], '4': ['u32', 'i32'] } } ) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -856,7 +907,7 @@ describe('call decoder from Horizon', function () { }) it('call #13: Setter.set_address([GDIY...R4W4]', async () => { - const txHash = await callContract( + const [txHash, response] = await callContract( 'set_address', nativeToScVal( Address.fromString( @@ -864,6 +915,8 @@ describe('call decoder from Horizon', function () { ) ) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -957,13 +1010,15 @@ describe('call decoder from Horizon', function () { }) it('call #14: Setter.set_struct([{"a"sym: 1u32, "b"sym: -100i128}])', async () => { - const txHash = await callContract( + const [txHash, response] = await callContract( 'set_struct', nativeToScVal( { a: 1, b: -100n }, { type: { a: ['symbol', 'u32'], b: ['symbol', 'i128'] } } ) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -1057,13 +1112,15 @@ describe('call decoder from Horizon', function () { }) it('call #15: Setter.set_enum([["B"sym, -200i128]])', async () => { - const txHash = await callContract( + const [txHash, response] = await callContract( 'set_enum', xdr.ScVal.scvVec([ xdr.ScVal.scvSymbol('B'), nativeToScVal(-200, { type: 'i128' }), ]) ) + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -1161,7 +1218,9 @@ describe('call decoder from Horizon', function () { }) it('call #16: Setter.remove_bool()', async () => { - const txHash = await callContract('remove_bool') + const [txHash, response] = await callContract('remove_bool') + assert.equal(response.status, 'SUCCESS') + const entry = await extractEntry(txHash) assert.isDefined(entry.timestamp) assert.isDefined(entry.height) @@ -1257,4 +1316,51 @@ describe('call decoder from Horizon', function () { }, }) }) + + it('extracts failed transactions', async function () { + // submit 2 conflicting tx in parallel by different accounts to provoke a failed transaction + + // Craft a conflicting transaction + const server = new SorobanRpc.Server(SOROBAN_URL) + const contract = new Contract(CONTRACT_ID) + const sourceAccount = await server.getAccount(bob.publicKey()) + const builtTransaction = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation(contract.call('set_bool_if_notset')) + .setTimeout(30) // tx is valid for 30 seconds + .build() + + // submit one transaction by `alice` and one by `bob` + const txHashesAndResponses = await Promise.all([ + callContract('set_bool_if_notset'), // call by `alice` + submitTx(server, builtTransaction, bob), // call by `bob` + ]) + + // partition into successful and failed txs + const [successfulTxs, failedTxs] = txHashesAndResponses.reduce( + ([success, fail], [txHash, response]) => { + if (response.status === 'SUCCESS') { + success.push(txHash) + } else { + fail.push(txHash) + } + return [success, fail] + }, + [[], []] + ) + + // assert that we have one successful and one failed tx + assert.equal(successfulTxs.length, 1) + assert.equal(failedTxs.length, 1) + assert.notEqual(successfulTxs[0], failedTxs[0]) + + // extract entries and assert transaction success + const successfulEntry = await extractEntry(successfulTxs[0]) + const failedEntry = await extractEntry(failedTxs[0]) + + assert.isTrue(successfulEntry.txSuccess) + assert.isFalse(failedEntry.txSuccess) + }) }) diff --git a/solarkraft/test/unit/instrument.test.ts b/solarkraft/test/unit/instrument.test.ts index 9e7c4f24..965af116 100644 --- a/solarkraft/test/unit/instrument.test.ts +++ b/solarkraft/test/unit/instrument.test.ts @@ -129,6 +129,7 @@ describe('Apalache JSON instrumentor', () => { timestamp: 1716393856, height: 100, txHash: '0xasdf', + txSuccess: true, contractId: '0xqwer', returnValue: null, method: 'Claim', @@ -179,6 +180,7 @@ describe('Apalache JSON instrumentor', () => { timestamp: 1716393856, height: 100, txHash: '0xasdf', + txSuccess: true, contractId: '0xqwer', returnValue: null, method: 'Claim', @@ -237,6 +239,7 @@ describe('Apalache JSON instrumentor', () => { timestamp: 1716393856, height: 100, txHash: '0xasdf', + txSuccess: true, contractId: '0xqwer', returnValue: null, method: 'Claim', @@ -288,6 +291,7 @@ describe('Apalache JSON instrumentor', () => { timestamp: 1716393856, height: 100, txHash: '0xasdf', + txSuccess: true, contractId: '0xqwer', returnValue: null, method: 'Claim', @@ -335,6 +339,7 @@ describe('Apalache JSON instrumentor', () => { timestamp: 1716393856, height: 100, txHash: '0xasdf', + txSuccess: true, contractId: '0xqwer', returnValue: null, method: 'foo', diff --git a/solarkraft/test/unit/storage.test.ts b/solarkraft/test/unit/storage.test.ts index 3f688c4f..514c282f 100644 --- a/solarkraft/test/unit/storage.test.ts +++ b/solarkraft/test/unit/storage.test.ts @@ -30,6 +30,7 @@ describe('storage tests', () => { timestamp: 1716393856, height: 1000, txHash: TX_HASH, + txSuccess: true, contractId: CONTRACT_ID, method: 'set_i32', methodArgs: [42],