diff --git a/package.json b/package.json index 91f2aa3..8f4338d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@terran-one/cw-simulate", - "version": "2.6.2", + "version": "2.7.0", "description": "Mock blockchain environment for simulating CosmWasm interactions", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/src/CWSimulateApp.ts b/src/CWSimulateApp.ts index b5cacc0..8520524 100644 --- a/src/CWSimulateApp.ts +++ b/src/CWSimulateApp.ts @@ -4,19 +4,23 @@ import { Err, Result } from 'ts-results'; import { WasmModule, WasmQuery } from './modules/wasm'; import { BankModule, BankQuery } from './modules/bank'; import { AppResponse, Binary } from './types'; +import { Transactional, TransactionalLens } from './store/transactional'; export interface CWSimulateAppOptions { chainId: string; bech32Prefix: string; } +export type ChainData = { + height: number; + time: number; +} + export class CWSimulateApp { public chainId: string; public bech32Prefix: string; - public store: Map; - public height: number; - public time: number; + public store: TransactionalLens; public wasm: WasmModule; public bank: BankModule; @@ -25,9 +29,10 @@ export class CWSimulateApp { constructor(options: CWSimulateAppOptions) { this.chainId = options.chainId; this.bech32Prefix = options.bech32Prefix; - this.store = Map(); - this.height = 1; - this.time = 0; + this.store = new Transactional().lens().initialize({ + height: 1, + time: 0, + }); this.wasm = new WasmModule(this); this.bank = new BankModule(this); @@ -47,6 +52,9 @@ export class CWSimulateApp { return Err(`unknown message: ${JSON.stringify(msg)}`); } } + + get height() { return this.store.get('height') } + get time() { return this.store.get('time') } } export type QueryMessage = diff --git a/src/modules/bank.spec.ts b/src/modules/bank.spec.ts index e2257a9..04aa0a8 100644 --- a/src/modules/bank.spec.ts +++ b/src/modules/bank.spec.ts @@ -1,8 +1,8 @@ -import { Map } from 'immutable'; +import { fromJS } from 'immutable'; import { cmd, exec, TestContract } from '../../testing/wasm-util'; import { CWSimulateApp } from '../CWSimulateApp'; import { fromBinary } from '../util'; -import { BankMessage, BankQuery, ParsedCoin } from './bank'; +import { BankMessage, BankQuery } from './bank'; type WrappedBankMessage = { bank: BankMessage; @@ -27,43 +27,43 @@ describe('BankModule', () => { bank.send('alice', 'bob', [{denom: 'foo', amount: '100'}]).unwrap(); // Assert - expect(bank.getBalance('alice')).toEqual([new ParsedCoin('foo', BigInt(900))]); - expect(bank.getBalance('bob')).toEqual([new ParsedCoin('foo', BigInt(100))]); - expect(bank.getBalances()).toEqual(Map([ - ['alice', [{denom: 'foo', amount: '900'}]], - ['bob', [{denom: 'foo', amount: '100'}]], - ])); + expect(bank.getBalance('alice')).toEqual([coin('foo', 900)]); + expect(bank.getBalance('bob')).toEqual([coin('foo', 100)]); + expect(bank.getBalances()).toEqual(fromJS({ + alice: [coin('foo', 900)], + bob: [coin('foo', 100)], + })); }); it('handle send failure', () => { // Arrange const bank = chain.bank; - bank.setBalance('alice', [{denom: 'foo', amount: '100'}]); + bank.setBalance('alice', [coin('foo', 100)]); // Act - const res = bank.send('alice', 'bob', [{denom: 'foo', amount: '1000'}]); + const res = bank.send('alice', 'bob', [coin('foo', 1000)]); // Assert expect(res.err).toBeDefined(); - expect(bank.getBalances()).toEqual(Map([ - ['alice', [{denom: 'foo', amount: '100'}]], - ])); - expect(bank.getBalance('alice')).toEqual([new ParsedCoin('foo', BigInt(100))]); + expect(bank.getBalances()).toEqual(fromJS({ + alice: [coin('foo', 100)], + })); + expect(bank.getBalance('alice')).toEqual([coin('foo', 100)]); }); it('handle burn', () => { // Arrange const bank = chain.bank; - bank.setBalance('alice', [{denom: 'foo', amount: '1000'}]); + bank.setBalance('alice', [coin('foo', 1000)]); // Act - bank.burn('alice', [{denom: 'foo', amount: '100'}]); + bank.burn('alice', [coin('foo', 100)]); // Assert - expect(bank.getBalance('alice')).toEqual([new ParsedCoin('foo', BigInt(900))]); - expect(bank.getBalances()).toEqual(Map([ - ['alice', [{denom: 'foo', amount: '900'}]], - ])); + expect(bank.getBalance('alice')).toEqual([coin('foo', 900)]); + expect(bank.getBalances()).toEqual(fromJS({ + alice: [coin('foo', 900)], + })); }); it('handle burn failure', () => { @@ -76,16 +76,16 @@ describe('BankModule', () => { // Assert expect(res.err).toBeDefined() - expect(bank.getBalance('alice')).toEqual([new ParsedCoin('foo', BigInt(100))]); - expect(bank.getBalances()).toEqual(Map([ - ['alice', [{denom: 'foo', amount: '100'}]], - ])); + expect(bank.getBalance('alice')).toEqual([coin('foo', 100)]); + expect(bank.getBalances()).toEqual(fromJS({ + alice: [coin('foo', 100)], + })); }); it('handle msg', () => { // Arrange const bank = chain.bank; - bank.setBalance('alice', [{denom: 'foo', amount: '1000'}]); + bank.setBalance('alice', [coin('foo', 1000)]); // Act let msg: WrappedBankMessage = { @@ -99,10 +99,10 @@ describe('BankModule', () => { chain.handleMsg('alice', msg); // Assert - expect(bank.getBalances()).toEqual(Map([ - ['alice', [{denom: 'foo', amount: '900'}]], - ['bob', [{denom: 'foo', amount: '100'}]], - ])); + expect(bank.getBalances()).toEqual(fromJS({ + alice: [coin('foo', 900)], + bob: [coin('foo', 100)], + })); }); it('contract integration', async () => { @@ -116,18 +116,18 @@ describe('BankModule', () => { cmd.bank({ send: { to_address: 'alice', - amount: [{denom: 'foo', amount: '100'}], + amount: [coin('foo', 100)], }, }), cmd.bank({ send: { to_address: 'bob', - amount: [{denom: 'foo', amount: '100'}], + amount: [coin('foo', 100)], }, }), cmd.bank({ burn: { - amount: [{denom: 'foo', amount: '100'}], + amount: [coin('foo', 100)], }, }), ); @@ -135,11 +135,11 @@ describe('BankModule', () => { // Assert expect(res.ok).toBeTruthy(); - expect(bank.getBalances()).toEqual(Map([ - [contract.address, [{denom: 'foo', amount: '700'}]], - ['alice', [{denom: 'foo', amount: '100'}]], - ['bob', [{denom: 'foo', amount: '100'}]], - ])); + expect(bank.getBalances()).toEqual(fromJS({ + [contract.address]: [coin('foo', 700)], + alice: [coin('foo', 100)], + bob: [coin('foo', 100)], + })); }); it('querier integration', () => { @@ -159,28 +159,29 @@ describe('BankModule', () => { }; bank.setBalance('alice', [ - { denom: 'foo', amount: '100' }, - { denom: 'bar', amount: '200' }, + coin('foo', 100), + coin('bar', 200), ]); bank.setBalance('bob', [ - { denom: 'foo', amount: '200' }, - { denom: 'bar', amount: '200' }, + coin('foo', 200), + coin('bar', 200), ]); let res = chain.querier.handleQuery({ bank: queryBalance }); expect(res.ok).toBeTruthy(); - expect(fromBinary(res.val)).toEqual({ amount: { denom: 'foo', amount: '100' }}); + expect(fromBinary(res.val)).toEqual({ amount: coin('foo', 100)}); res = chain.querier.handleQuery({ bank: queryAllBalances }); expect(res.ok).toBeTruthy(); expect(fromBinary(res.val)).toEqual({ amount: [ - { denom: 'foo', amount: '200' }, - { denom: 'bar', amount: '200' }, + coin('foo', 200), + coin('bar', 200), ], }); }); - it('handle delete', () => { + + it('handle delete', () => { // Arrange const bank = chain.bank; bank.setBalance('alice', [{denom: 'foo', amount: '1000'}]); @@ -191,8 +192,10 @@ describe('BankModule', () => { // Assert expect(bank.getBalance('alice')).toBeDefined(); - expect(bank.getBalances()).toEqual(Map([ - ['alice', [{denom: 'foo', amount: '1000'}]] - ])); + expect(bank.getBalances()).toEqual(fromJS({ + alice: [{denom: 'foo', amount: '1000'}], + })); }); }); + +const coin = (denom: string, amount: string | number) => ({ denom, amount: `${amount}` }); diff --git a/src/modules/bank.ts b/src/modules/bank.ts index 3bfb606..7361f6d 100644 --- a/src/modules/bank.ts +++ b/src/modules/bank.ts @@ -1,16 +1,20 @@ import { Coin } from '@cosmjs/amino'; -import { toAscii } from '@cosmjs/encoding'; -import { Map } from 'immutable'; +import { fromJS, List, Map } from 'immutable'; import { Err, Ok, Result } from 'ts-results'; import { Binary } from '../types'; import { CWSimulateApp } from '../CWSimulateApp'; import { toBinary } from '../util'; +import { TransactionalLens } from '../store/transactional'; export interface AppResponse { events: any[]; data: string | null; } +type BankData = { + balances: Record; +} + export type BankMessage = | { send: { @@ -41,89 +45,101 @@ export type BalanceResponse = { amount: Coin }; export type AllBalancesResponse = { amount: Coin[] }; export class BankModule { - public static STORE_KEY: Uint8Array = toAscii('bank'); + public readonly store: TransactionalLens; constructor(public chain: CWSimulateApp) { - this.chain.store.set('bank', {'balances': {}}); + this.store = this.chain.store.db.lens('bank').initialize({ + balances: {}, + }); } public send(sender: string, recipient: string, amount: Coin[]): Result { - let senderBalance = this.getBalance(sender).filter(c => c.amount > BigInt(0)); - let parsedCoins = amount - .map(ParsedCoin.fromCoin) - .filter(c => c.amount > BigInt(0)); - - // Deduct coins from sender - for (const coin of parsedCoins) { - const hasCoin = senderBalance.find(c => c.denom === coin.denom); - - if (hasCoin && hasCoin.amount >= coin.amount) { - hasCoin.amount -= coin.amount; + return this.store.tx(() => { + let senderBalance = this.getBalance(sender).map(ParsedCoin.fromCoin).filter(c => c.amount > BigInt(0)); + let parsedCoins = amount + .map(ParsedCoin.fromCoin) + .filter(c => c.amount > BigInt(0)); + + // Deduct coins from sender + for (const coin of parsedCoins) { + const hasCoin = senderBalance.find(c => c.denom === coin.denom); + + if (hasCoin && hasCoin.amount >= coin.amount) { + hasCoin.amount -= coin.amount; + } + else { + return Err(`Sender ${sender} has ${hasCoin?.amount ?? BigInt(0)} ${coin.denom}, needs ${coin.amount}`); + } } - else { - return Err(`Sender ${sender} has ${hasCoin?.amount ?? BigInt(0)} ${coin.denom}, needs ${coin.amount}`); + senderBalance = senderBalance.filter(c => c.amount > BigInt(0)); + + // Add amount to recipient + const recipientBalance = this.getBalance(recipient).map(ParsedCoin.fromCoin); + for (const coin of parsedCoins) { + const hasCoin = recipientBalance.find(c => c.denom === coin.denom); + + if (hasCoin) { + hasCoin.amount += coin.amount; + } + else { + recipientBalance.push(coin); + } } - } - senderBalance = senderBalance.filter(c => c.amount > BigInt(0)); - // Add amount to recipient - const recipientBalance = this.getBalance(recipient); - for (const coin of parsedCoins) { - const hasCoin = recipientBalance.find(c => c.denom === coin.denom); - - if (hasCoin) { - hasCoin.amount += coin.amount; - } - else { - recipientBalance.push(coin); - } - } - - this.setBalance(sender, senderBalance.map(c => c.toCoin())); - this.setBalance(recipient, recipientBalance.map(c => c.toCoin())); - return Ok(undefined); + this.setBalance(sender, senderBalance.map(c => c.toCoin())); + this.setBalance(recipient, recipientBalance.map(c => c.toCoin())); + return Ok(undefined); + }); } public burn(sender: string, amount: Coin[]): Result { - let balance = this.getBalance(sender); - let parsedCoins = amount - .map(ParsedCoin.fromCoin) - .filter(c => c.amount > BigInt(0)); - - for (const coin of parsedCoins) { - const hasCoin = balance.find(c => c.denom === coin.denom); - - if (hasCoin && hasCoin.amount >= coin.amount) { - hasCoin.amount -= coin.amount; - } - else { - return Err(`Sender ${sender} has ${hasCoin?.amount ?? 0} ${coin.denom}, needs ${coin.amount}`); + return this.store.tx(() => { + let balance = this.getBalance(sender).map(ParsedCoin.fromCoin); + let parsedCoins = amount + .map(ParsedCoin.fromCoin) + .filter(c => c.amount > BigInt(0)); + + for (const coin of parsedCoins) { + const hasCoin = balance.find(c => c.denom === coin.denom); + + if (hasCoin && hasCoin.amount >= coin.amount) { + hasCoin.amount -= coin.amount; + } + else { + return Err(`Sender ${sender} has ${hasCoin?.amount ?? 0} ${coin.denom}, needs ${coin.amount}`); + } } - } - balance = balance.filter(c => c.amount > BigInt(0)); - - this.setBalance(sender, balance.map(c => c.toCoin())); - return Ok(undefined); + balance = balance.filter(c => c.amount > BigInt(0)); + + this.setBalance(sender, balance.map(c => c.toCoin())); + return Ok(undefined); + }); } public setBalance(address: string, amount: Coin[]) { - this.chain.store = this.chain.store.setIn( - ['bank', 'balances', address], - amount - ); + this.store.tx(setter => { + setter('balances', address)(amount); + return Ok(undefined); + }); } - public getBalance(address: string): ParsedCoin[] { - return (this.getBalances().get(address) ?? []).map(ParsedCoin.fromCoin); + public getBalance(address: string) { + const immu = this.getBalances().get(address)?.toArray(); + return immu?.map(immuCoin => ({ + denom: immuCoin.get('denom')!, + amount: immuCoin.get('amount')!, + })) ?? []; } public getBalances() { - return (this.chain.store.getIn(['bank', 'balances'], Map([])) as Map); + return this.store.get('balances'); } public deleteBalance(address:string) { - this.chain.store = this.chain.store.deleteIn( ['bank', 'balances', address]); - + this.store.tx((_, deleter) => { + deleter('balances', address); + return Ok(undefined); + }); } public async handleMsg( @@ -174,13 +190,13 @@ export class BankModule { .getBalance(address) .find(c => c.denom === denom); return Ok(toBinary({ - amount: hasCoin?.toCoin() ?? { denom, amount: '0' }, + amount: hasCoin ?? { denom, amount: '0' }, })); } else if ('all_balances' in bankQuery) { let { address } = bankQuery.all_balances; return Ok(toBinary({ - amount: this.getBalance(address).map(c => c.toCoin()), + amount: this.getBalance(address), })); } return Err('Unknown bank query'); diff --git a/src/modules/wasm.ts b/src/modules/wasm.ts index f5fb24e..623a6ba 100644 --- a/src/modules/wasm.ts +++ b/src/modules/wasm.ts @@ -26,10 +26,12 @@ import { ExecuteTraceLog, ReplyTraceLog, DebugLog, + Snapshot, } from '../types'; import { Map } from 'immutable'; import { Err, Ok, Result } from 'ts-results'; import { fromBinary, fromRustResult, toBinary } from '../util'; +import { Transactional, TransactionalLens } from '../store/transactional'; function numberToBigEndianUint64(n: number): Uint8Array { const buffer = new ArrayBuffer(8); @@ -39,6 +41,14 @@ function numberToBigEndianUint64(n: number): Uint8Array { return new Uint8Array(buffer); } +type WasmData = { + lastCodeId: number; + lastInstanceId: number; + codes: Record; + contracts: Record; + contractStorage: Record>; +} + export interface Execute { contract_addr: string; msg: string; @@ -77,17 +87,19 @@ export type WasmQuery = | { contract_info: ContractInfoQuery }; export class WasmModule { - public lastCodeId: number; - public lastInstanceId: number; + public readonly store: TransactionalLens; // TODO: benchmark w/ many coexisting VMs private vms: Record = {}; constructor(public chain: CWSimulateApp) { - chain.store.set('wasm', { codes: {}, contracts: {}, contractStorage: {} }); - - this.lastCodeId = 0; - this.lastInstanceId = 0; + this.store = chain.store.db.lens('wasm').initialize({ + lastCodeId: 0, + lastInstanceId: 0, + codes: {}, + contracts: {}, + contractStorage: {}, + }); } static buildContractAddress(codeId: number, instanceId: number): Uint8Array { @@ -113,64 +125,66 @@ export class WasmModule { } setContractStorage(contractAddress: string, value: Map) { - this.chain.store = this.chain.store.setIn( - ['wasm', 'contractStorage', contractAddress], - value - ); + this.store.tx(setter => { + setter('contractStorage', contractAddress)(value); + return Ok(undefined); + }); } - getContractStorage(contractAddress: string, storage = this.chain.store) { - const existing = storage.getIn([ - 'wasm', - 'contractStorage', - contractAddress, - ]) as Map | undefined; - return existing ?? Map(); + getContractStorage(contractAddress: string, storage?: Snapshot) { + return this.lens(storage).get('contractStorage', contractAddress) ?? Map(); } setCodeInfo(codeId: number, codeInfo: CodeInfo) { - this.chain.store = this.chain.store.setIn( - ['wasm', 'codes', codeId], - codeInfo - ); + this.store.tx(setter => { + setter('codes', codeId)(codeInfo); + return Ok(undefined); + }); } - getCodeInfo(codeId: number, storage = this.chain.store): CodeInfo { - return storage.getIn(['wasm', 'codes', codeId]) as CodeInfo; + getCodeInfo(codeId: number, storage?: Snapshot) { + const lens = this.lens(storage).lens('codes', codeId); + if (!lens) return; + + const codeInfo: CodeInfo = { + creator: lens.get('creator'), + wasmCode: new Uint8Array(lens.get('wasmCode')), + }; + return codeInfo; } setContractInfo(contractAddress: string, contractInfo: ContractInfo) { - this.chain.store = this.chain.store.setIn( - ['wasm', 'contracts', contractAddress], - contractInfo - ); + this.store.tx(setter => { + setter('contracts', contractAddress)(Map(contractInfo)); + return Ok(undefined); + }); } - getContractInfo(contractAddress: string, storage = this.chain.store) { - return storage.getIn([ - 'wasm', - 'contracts', - contractAddress, - ]) as ContractInfo | undefined; + getContractInfo(contractAddress: string, storage?: Snapshot) { + const lens = this.lens(storage).lens('contracts', contractAddress); + if (!lens) return; + return lens.data.toObject() as any as ContractInfo; } deleteContractInfo(contractAddress: string) { - this.chain.store = this.chain.store.deleteIn([ - 'wasm', - 'contracts', - contractAddress, - ]); + this.store.tx((_, deleter) => { + deleter('contracts', contractAddress); + return Ok(undefined); + }); } create(creator: string, wasmCode: Uint8Array): number { - let codeInfo = { - creator, - wasmCode, - }; + return this.store.tx(setter => { + let codeInfo: CodeInfo = { + creator, + wasmCode, + }; - this.setCodeInfo(this.lastCodeId + 1, codeInfo); - this.lastCodeId += 1; - return this.lastCodeId; + const codeId = this.lastCodeId + 1; + this.setCodeInfo(codeId, codeInfo); + setter('lastCodeId', codeId); + return Ok(codeId); + }).unwrap(); } getExecutionEnv(contractAddress: string): ExecuteEnv { @@ -219,29 +233,31 @@ export class WasmModule { // TODO: add admin, label, etc. registerContractInstance(sender: string, codeId: number): string { - const contractAddressHash = WasmModule.buildContractAddress( - codeId, - this.lastInstanceId + 1 - ); + return this.store.tx(setter => { + const contractAddressHash = WasmModule.buildContractAddress( + codeId, + this.lastInstanceId + 1 + ); - const contractAddress = toBech32( - this.chain.bech32Prefix, - contractAddressHash - ); + const contractAddress = toBech32( + this.chain.bech32Prefix, + contractAddressHash + ); - const contractInfo = { - codeId, - creator: sender, - admin: null, - label: '', - created: this.chain.height, - }; + const contractInfo = { + codeId, + creator: sender, + admin: null, + label: '', + created: this.chain.height, + }; - this.setContractInfo(contractAddress, contractInfo); - this.setContractStorage(contractAddress, Map()); + this.setContractInfo(contractAddress, contractInfo); + this.setContractStorage(contractAddress, Map()); - this.lastInstanceId += 1; - return contractAddress; + setter('lastInstanceId')(this.lastInstanceId + 1); + return Ok(contractAddress); + }).unwrap(); } async callInstantiate( @@ -275,82 +291,83 @@ export class WasmModule { instantiateMsg: any, trace: TraceLog[] = [] ): Promise> { - // first register the contract instance - let snapshot = this.chain.store; - const contractAddress = this.registerContractInstance(sender, codeId); - let logs = [] as DebugLog[]; - - // then call instantiate - let response = await this.callInstantiate( - sender, - funds, - contractAddress, - instantiateMsg, - logs - ); - - if ('error' in response) { - // revert the contract instance registration - this.lastInstanceId -= 1; - this.deleteContractInfo(contractAddress); - this.chain.store = snapshot; - let result = Err(response.error); - trace.push({ - type: 'instantiate' as 'instantiate', - contractAddress, - msg: instantiateMsg, - response, - info: { - sender, - funds, - }, - env: this.getExecutionEnv(contractAddress), - logs, - storeSnapshot: snapshot, - result, - }); - return result; - } else { - let customEvent: Event = { - type: 'instantiate', - attributes: [ - { key: '_contract_address', value: contractAddress }, - { key: 'code_id', value: codeId.toString() }, - ], - }; - let res = this.buildAppResponse( - contractAddress, - customEvent, - response.ok - ); - - let subtrace: TraceLog[] = []; + return this.store.tx(async () => { + const snapshot = this.store.db.data; + + // first register the contract instance + const contractAddress = this.registerContractInstance(sender, codeId); + let logs = [] as DebugLog[]; - let result = await this.handleContractResponse( + // then call instantiate + let response = await this.callInstantiate( + sender, + funds, contractAddress, - response.ok.messages, - res, - subtrace + instantiateMsg, + logs ); - trace.push({ - type: 'instantiate' as 'instantiate', - contractAddress, - msg: instantiateMsg, - response, - info: { - sender, - funds, - }, - env: this.getExecutionEnv(contractAddress), - logs, - trace: subtrace, - storeSnapshot: this.chain.store, - result, - }); - - return result; - } + if ('error' in response) { + let result = Err(response.error); + trace.push({ + type: 'instantiate' as 'instantiate', + contractAddress, + msg: instantiateMsg, + response, + info: { + sender, + funds, + }, + env: this.getExecutionEnv(contractAddress), + logs, + storeSnapshot: snapshot, + result, + }); + + return result; + } + else { + let customEvent: Event = { + type: 'instantiate', + attributes: [ + { key: '_contract_address', value: contractAddress }, + { key: 'code_id', value: codeId.toString() }, + ], + }; + let res = this.buildAppResponse( + contractAddress, + customEvent, + response.ok + ); + + let subtrace: TraceLog[] = []; + + let result = await this.handleContractResponse( + contractAddress, + response.ok.messages, + res, + subtrace + ); + + trace.push({ + type: 'instantiate' as 'instantiate', + contractAddress, + msg: instantiateMsg, + response, + info: { + sender, + funds, + }, + env: this.getExecutionEnv(contractAddress), + logs, + trace: subtrace, + storeSnapshot: this.store.db.data, + result, + }); + + return result; + } + }); } callExecute( @@ -387,74 +404,82 @@ export class WasmModule { executeMsg: any, trace: TraceLog[] = [] ): Promise> { - let snapshot = this.chain.store; - let logs: DebugLog[] = []; + return this.store.tx(async () => { + let snapshot = this.store.db.data; + let logs: DebugLog[] = []; - let response = this.callExecute( - sender, - funds, - contractAddress, - executeMsg, - logs - ); - - if ('error' in response) { - this.chain.store = snapshot; // revert - let result = Err(response.error); - trace.push({ - type: 'execute' as 'execute', - contractAddress, - msg: executeMsg, - response, - env: this.getExecutionEnv(contractAddress), - info: { - sender, - funds, - }, - logs, - storeSnapshot: snapshot, - result, - }); - return result; - } else { - let customEvent = { - type: 'execute', - attributes: [ - { - key: '_contract_addr', - value: contractAddress, - }, - ], - }; - let res = this.buildAppResponse( - contractAddress, - customEvent, - response.ok - ); - let subtrace: TraceLog[] = []; - let result = await this.handleContractResponse( + let response = this.callExecute( + sender, + funds, contractAddress, - response.ok.messages, - res, - subtrace + executeMsg, + logs ); - trace.push({ - type: 'execute' as 'execute', - contractAddress, - msg: executeMsg, - response, - info: { - sender, - funds, - }, - env: this.getExecutionEnv(contractAddress), - trace: subtrace, - logs, - storeSnapshot: this.chain.store, - result, - }); - return result; - } + + if ('error' in response) { + let result = Err(response.error); + + trace.push({ + type: 'execute' as 'execute', + contractAddress, + msg: executeMsg, + response, + env: this.getExecutionEnv(contractAddress), + info: { + sender, + funds, + }, + logs, + storeSnapshot: snapshot, + result, + }); + + return result; + } + else { + let customEvent = { + type: 'execute', + attributes: [ + { + key: '_contract_addr', + value: contractAddress, + }, + ], + }; + + let res = this.buildAppResponse( + contractAddress, + customEvent, + response.ok + ); + + let subtrace: TraceLog[] = []; + let result = await this.handleContractResponse( + contractAddress, + response.ok.messages, + res, + subtrace + ); + + trace.push({ + type: 'execute' as 'execute', + contractAddress, + msg: executeMsg, + response, + info: { + sender, + funds, + }, + env: this.getExecutionEnv(contractAddress), + trace: subtrace, + logs, + storeSnapshot: this.store.db.data, + result, + }); + + return result; + } + }); } async handleContractResponse( @@ -485,62 +510,74 @@ export class WasmModule { message: SubMsg, trace: TraceLog[] = [] ): Promise> { - let { id, msg, gas_limit, reply_on } = message; - let r = await this.chain.handleMsg(contractAddress, msg, trace); - if (r.ok) { - // submessage success - let { events, data } = r.val; - if (reply_on === ReplyOn.Success || reply_on === ReplyOn.Always) { - // submessage success, call reply - let replyMsg: ReplyMsg = { - id, - result: { - ok: { - events, - data, + return this.store.tx(async () => { + let { id, msg, gas_limit, reply_on } = message; + let r = await this.chain.handleMsg(contractAddress, msg, trace); + + if (r.ok) { + // submessage success + let { events, data } = r.val; + + if (reply_on === ReplyOn.Success || reply_on === ReplyOn.Always) { + // submessage success, call reply + let replyMsg: ReplyMsg = { + id, + result: { + ok: { + events, + data, + }, }, - }, - }; - let replyRes = await this.reply(contractAddress, replyMsg, trace); - if (replyRes.err) { - // submessage success, call reply, reply failed - return replyRes; - } else { - // submessage success, call reply, reply success - if (replyRes.val.data !== null) { - data = replyRes.val.data; + }; + + let replyRes = await this.reply(contractAddress, replyMsg, trace); + if (replyRes.err) { + // submessage success, call reply, reply failed + return replyRes; + } + else { + // submessage success, call reply, reply success + if (replyRes.val.data !== null) { + data = replyRes.val.data; + } + events = [...events, ...replyRes.val.events]; } - events = [...events, ...replyRes.val.events]; } - } else { - // submessage success, don't call reply - data = null; + else { + // submessage success, don't call reply + data = null; + } + + return Ok({ events, data }); } - return Ok({ events, data }); - } else { - // submessage failed - if (reply_on === ReplyOn.Error || reply_on === ReplyOn.Always) { - // submessage failed, call reply - let replyMsg: ReplyMsg = { - id, - result: { - error: r.val, - }, - }; - let replyRes = await this.reply(contractAddress, replyMsg, trace); - if (replyRes.err) { - // submessage failed, call reply, reply failed - return replyRes; - } else { - // submessage failed, call reply, reply success - let { events, data } = replyRes.val; - return Ok({ events, data }); + else { + // submessage failed + if (reply_on === ReplyOn.Error || reply_on === ReplyOn.Always) { + // submessage failed, call reply + let replyMsg: ReplyMsg = { + id, + result: { + error: r.val, + }, + }; + + let replyRes = await this.reply(contractAddress, replyMsg, trace); + if (replyRes.err) { + // submessage failed, call reply, reply failed + return replyRes; + } + else { + // submessage failed, call reply, reply success + let { events, data } = replyRes.val; + return Ok({ events, data }); + } + } + else { + // submessage failed, don't call reply (equivalent to normal message) + return r; } - } else { - // submessage failed, don't call reply (equivalent to normal message) - return r; } - } + }); } callReply( @@ -571,8 +608,10 @@ export class WasmModule { ): Promise> { let logs: DebugLog[] = []; let response = this.callReply(contractAddress, replyMsg, logs); + if ('error' in response) { let result = Err(response.error); + trace.push({ type: 'reply' as 'reply', contractAddress, @@ -580,11 +619,13 @@ export class WasmModule { msg: replyMsg, response, logs, - storeSnapshot: this.chain.store, + storeSnapshot: this.store.db.data, result, }); + return result; - } else { + } + else { let customEvent = { type: 'reply', attributes: [ @@ -599,11 +640,13 @@ export class WasmModule { }, ], }; + let res = this.buildAppResponse( contractAddress, customEvent, response.ok ); + let subtrace: TraceLog[] = []; let result = await this.handleContractResponse( contractAddress, @@ -611,6 +654,7 @@ export class WasmModule { res, subtrace ); + trace.push({ type: 'reply' as 'reply', contractAddress, @@ -619,9 +663,10 @@ export class WasmModule { response, trace: subtrace, logs, - storeSnapshot: this.chain.store, + storeSnapshot: this.store.db.data, result, }); + return result; } } @@ -708,30 +753,32 @@ export class WasmModule { msg: WasmMsg, trace: TraceLog[] = [] ): Promise> { - let wasm = msg; - if ('execute' in wasm) { - let { contract_addr, funds, msg } = wasm.execute; - return await this.executeContract( - sender, - funds, - contract_addr, - fromBinary(msg), - trace - ); - } - else if ('instantiate' in wasm) { - let { code_id, funds, msg } = wasm.instantiate; - return await this.instantiateContract( - sender, - funds, - code_id, - fromBinary(msg), - trace, - ); - } - else { - throw new Error('Unknown wasm message'); - } + return this.store.tx(async () => { + let wasm = msg; + if ('execute' in wasm) { + let { contract_addr, funds, msg } = wasm.execute; + return await this.executeContract( + sender, + funds, + contract_addr, + fromBinary(msg), + trace + ); + } + else if ('instantiate' in wasm) { + let { code_id, funds, msg } = wasm.instantiate; + return await this.instantiateContract( + sender, + funds, + code_id, + fromBinary(msg), + trace, + ); + } + else { + throw new Error('Unknown wasm message'); + } + }); } handleQuery(query: WasmQuery): Result { @@ -782,4 +829,14 @@ export class WasmModule { return Err('Unknown wasm query'); } } + + private lens(storage?: Snapshot) { + return storage ? lensFromSnapshot(storage) : this.store; + } + + get lastCodeId() { return this.store.get('lastCodeId') } + get lastInstanceId() { return this.store.get('lastInstanceId') } +} +function lensFromSnapshot(snapshot: Snapshot) { + return new Transactional(snapshot).lens('wasm'); } diff --git a/src/store/transactional.ts b/src/store/transactional.ts new file mode 100644 index 0000000..c11f0af --- /dev/null +++ b/src/store/transactional.ts @@ -0,0 +1,126 @@ +import { fromJS, isCollection, isMap, List, Map } from "immutable"; +import { Ok, Result } from "ts-results"; + +type Primitive = boolean | number | bigint | string | null | undefined | symbol; + +type Prefix = [P, ...T]; +type First = T extends Prefix ? F : never; +type Shift = T extends Prefix ? R : []; + +// god type +type Lens = + P extends Prefix + ? First

extends keyof T + ? Shift

extends Prefix + ? Lens], Shift

> + : T[First

] + : never + : T; + +type Immutify = + T extends Primitive + ? T + : T extends ArrayLike + ? List> + : T extends Record + ? Map> + : T; + +type TxUpdater = (set: TxSetter) => void; +type TxSetter = (current: Map) => Map; +type LensSetter =

(...path: P) => ((value: Lens | Immutify>) => void); +type LensDeleter =

(...path: P) => void; + +/** Transactional database underlying multi-module chain storage. */ +export class Transactional { + constructor(private _data = Map()) {} + + lens(...path: PropertyKey[]) { + return new TransactionalLens(this, path); + } + + tx>(cb: (update: TxUpdater) => Promise): Promise; + tx>(cb: (update: TxUpdater) => R): R; + tx>(cb: (update: TxUpdater) => R | Promise): R | Promise { + let valid = true; + const snapshot = this._data; + const updater: TxUpdater = setter => { + if (!valid) throw new Error('Attempted to set data outside tx'); + this._data = setter(this._data); + }; + + try { + const result = cb(updater); + if ('then' in result) { + return result.then(r => { + if (r.err) { + this._data = snapshot; + } + return r; + }) + .catch(reason => { + this._data = snapshot; + return reason; + }) + } + else { + if (result.err) { + this._data = snapshot; + } + return result; + } + } + catch (ex) { + this._data = snapshot; + throw ex; + } + finally { + valid = false; + } + } + + get data() { + return this._data; + } +} + +export class TransactionalLens { + constructor(public readonly db: Transactional, public readonly prefix: PropertyKey[]) {} + + initialize(data: M) { + this.db.tx(update => { + const coll = fromJS(data); + if (!isCollection(coll)) throw new Error('Not an Immutable.Map'); + update(curr => curr.setIn([...this.prefix], coll)); + return Ok(undefined); + }).unwrap(); + return this; + } + + get

(...path: P): Immutify> { + return this.db.data.getIn([...this.prefix, ...path]) as any; + } + + tx>(cb: (setter: LensSetter, deleter: LensDeleter) => Promise): Promise; + tx>(cb: (setter: LensSetter, deleter: LensDeleter) => R): R; + tx>(cb: (setter: LensSetter, deleter: LensDeleter) => R | Promise): R | Promise { + //@ts-ignore + return this.db.tx(update => { + const setter: LensSetter =

(...path: P) => + (value: Lens | Immutify>) => { + const v = isCollection(value) ? value : fromJS(value); + update(curr => curr.setIn([...this.prefix, ...path], v)); + } + const deleter: LensDeleter =

(...path: P) => { + update(curr => curr.deleteIn([...this.prefix, ...path])); + } + return cb(setter, deleter); + }); + } + + lens

(...path: P): TransactionalLens> { + return new TransactionalLens>(this.db, [...this.prefix, ...path]); + } + + get data() { return this.db.data.getIn([...this.prefix]) as Immutify } +} diff --git a/src/types.ts b/src/types.ts index 8ddeb25..7955f0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -117,6 +117,8 @@ type CallDebugLog = { [K in T]: { fn: K } & CosmWasmAPI[K]; }>; +export type Snapshot = Immutable.Map; + interface TraceLogCommon { type: string; contractAddress: string; @@ -125,7 +127,7 @@ interface TraceLogCommon { response: RustResult; logs: DebugLog[]; trace?: TraceLog[]; - storeSnapshot: Immutable.Map; + storeSnapshot: Snapshot; result: Result; }