feat: protection against read after making a internal write

This commit is contained in:
ppe
2022-09-18 00:00:25 +02:00
committed by just_ppe
parent 0d1e665369
commit b55b57fda7
14 changed files with 338 additions and 66 deletions

View File

@@ -59,7 +59,7 @@ Transaction which is sent to Bundlr, consists of:
**NOTE** The original transaction is not modified in any way - this is to preserve the original
signature!
2. After receiving proper response and recipes from Bundlr, the Warp gateway indexes the contract
2. After receiving proper response and receipt from Bundlr, the Warp gateway indexes the contract
transactions data internally - to make them instantly available.
3. Finally, the Warp gateway returns an object as a `response` - that consists of fields:

View File

@@ -130,7 +130,7 @@ Using the `sort_key`, `vrf-proof` and `vrf-pubkey`, the client can always verify
**NOTE** The original transaction is not modified in any way - this is to preserve the original
signature!
After receiving proper response and recipes from Bundlr, the Warp gateway indexes the contract interaction
After receiving proper response and receipt from Bundlr, the Warp gateway indexes the contract interaction
internally - to make it instantly available.
#### 4. Finally, the Warp gateway returns the response from the Bundlr to the client.

View File

@@ -74,17 +74,13 @@ async function handle(state, action) {
if (input.lockLength) {
lockLength = input.lockLength;
}
console.log('====== AFTR CONTRACT deposit FN - calling claim on: ', input.tokenId);
const transferResult = await SmartWeave.contracts.write(input.tokenId, {
function: "claim",
txID: input.txID,
qty: input.qty
});
console.log('====== AFTR CONTRACT deposit FN - calling claim result ', transferResult);
console.log('====== AFTR CONTRACT deposit FN - PST state', transferResult.state);
const tokenInfo = getTokenInfo(transferResult.state);
console.log('Token info', tokenInfo);
const txObj = {
txID: input.txID,
tokenId: input.tokenId,
@@ -128,7 +124,6 @@ async function handle(state, action) {
});
}
if (input.function === "claim") {
console.log('====== Claim function BEGIN ===');
const txID = input.txID;
const qty = input.qty;
if (!state.claimable.length) {
@@ -161,8 +156,6 @@ async function handle(state, action) {
balances[caller] += obj.qty;
state.claimable.splice(index, 1);
state.claims.push(txID);
console.log('====== Claim function END ===');
}

View File

@@ -0,0 +1,196 @@
async function handle(state, action) {
const balances = state.balances;
const input = action.input;
const caller = action.caller;
let target = "";
let balance = 0;
if (input.function === "balance") {
target = isArweaveAddress(input.target || caller);
if (typeof target !== "string") {
throw new ContractError("Must specificy target to get balance for.");
}
balance = 0;
if (target in balances) {
balance = balances[target];
}
}
if (input.function === "transfer") {
const target2 = input.target;
const qty = input.qty;
const callerAddress = isArweaveAddress(caller);
const targetAddress = isArweaveAddress(target2);
if (!Number.isInteger(qty)) {
throw new ContractError('Invalid value for "qty". Must be an integer.');
}
if (!targetAddress) {
throw new ContractError("No target specified.");
}
if (qty <= 0 || callerAddress === targetAddress) {
throw new ContractError("Invalid token transfer.");
}
if (!(callerAddress in balances)) {
throw new ContractError("Caller doesn't own a balance in the Vehicle.");
}
if (balances[callerAddress] < qty) {
throw new ContractError(`Caller balance not high enough to send ${qty} token(s)!`);
}
if (SmartWeave.contract.id === target2) {
throw new ContractError("A vehicle token cannot be transferred to itself because it would add itself the balances object of the vehicle, thus changing the membership of the vehicle without a vote.");
}
if (state.ownership === "single" && callerAddress === state.creator && balances[callerAddress] - qty <= 0) {
throw new ContractError("Invalid transfer because the creator's balance would be 0.");
}
balances[callerAddress] -= qty;
if (targetAddress in balances) {
balances[targetAddress] += qty;
} else {
balances[targetAddress] = qty;
}
}
if (input.function === "mint") {
if (!input.qty) {
throw new ContractError("Missing qty.");
}
if (!(caller in state.balances)) {
balances[caller] = input.qty;
}
}
if (input.function === "deposit") {
if (!input.txID) {
throw new ContractError("The transaction is not valid. Tokens were not transferred to the vehicle.");
}
if (!input.tokenId) {
throw new ContractError("No token supplied. Tokens were not transferred to the vehicle.");
}
if (input.tokenId === SmartWeave.contract.id) {
throw new ContractError("Deposit not allowed because you can't deposit an asset of itself.");
}
if (!input.qty || typeof +input.qty !== "number" || +input.qty <= 0) {
throw new ContractError("Qty is invalid.");
}
let lockLength = 0;
if (input.lockLength) {
lockLength = input.lockLength;
}
await SmartWeave.contracts.write(input.tokenId, {
function: "claim",
txID: input.txID,
qty: input.qty
});
// note: getTokenInfo underneath makes a readContractState on input.tokenId
// the SDK should now throw in such case (i.e. making a read on a contract on which
// we've just made write).
const tokenInfo = await getTokenInfo(input.tokenId);
const txObj = {
txID: input.txID,
tokenId: input.tokenId,
source: caller,
balance: input.qty,
start: SmartWeave.block.height,
name: tokenInfo.name,
ticker: tokenInfo.ticker,
logo: tokenInfo.logo,
lockLength
};
if (!state.tokens) {
state["tokens"] = [];
}
state.tokens.push(txObj);
}
if (input.function === "allow") {
target = input.target;
const quantity = input.qty;
if (!Number.isInteger(quantity) || quantity === void 0) {
throw new ContractError("Invalid value for quantity. Must be an integer.");
}
if (!target) {
throw new ContractError("No target specified.");
}
if (quantity <= 0 || caller === target) {
throw new ContractError("Invalid token transfer.");
}
if (balances[caller] < quantity) {
throw new ContractError("Caller balance not high enough to make claimable " + quantity + " token(s).");
}
balances[caller] -= quantity;
if (balances[caller] === null || balances[caller] === void 0) {
balances[caller] = 0;
}
state.claimable.push({
from: caller,
to: target,
qty: quantity,
txID: SmartWeave.transaction.id
});
}
if (input.function === "claim") {
const txID = input.txID;
const qty = input.qty;
if (!state.claimable.length) {
throw new ContractError("Contract has no claims available.");
}
let obj, index;
for (let i = 0; i < state.claimable.length; i++) {
if (state.claimable[i].txID === txID) {
index = i;
obj = state.claimable[i];
}
}
if (obj === void 0) {
throw new ContractError("Unable to find claim.");
}
if (obj.to !== caller) {
throw new ContractError("Claim not addressed to caller.");
}
if (obj.qty !== qty) {
throw new ContractError("Claiming incorrect quantity of tokens.");
}
for (let i = 0; i < state.claims.length; i++) {
if (state.claims[i] === txID) {
throw new ContractError("This claim has already been made.");
}
}
if (!balances[caller]) {
balances[caller] = 0;
}
balances[caller] += obj.qty;
state.claimable.splice(index, 1);
state.claims.push(txID);
}
if (input.function === "balance") {
let vaultBal = 0;
try {
for (let bal of state.vault[caller]) {
vaultBal += bal.balance;
}
} catch (e) {
}
return {result: {target, balance, vaultBal}};
} else {
return {state};
}
}
function isArweaveAddress(addy) {
const address = addy.toString().trim();
if (!/[a-z0-9_-]{43}/i.test(address)) {
throw new ContractError("Invalid Arweave address.");
}
return address;
}
async function getTokenInfo(contractId) {
const assetState = await SmartWeave.contracts.readContractState(contractId);
const settings = new Map(assetState.settings);
return {
name: currentTokenState.name,
ticker: currentTokenState.ticker,
logo: settings.get("communityLogo")
};
}

View File

@@ -49,7 +49,6 @@ export async function handle(state, action) {
return { result: value };
}
if (action.input.function === 'justThrow') {
console.log('called justThrow');
throw new ContractError('Error from justThrow function');
}
}

View File

@@ -9,7 +9,6 @@ export async function handle(state, action) {
}
if (action.input.function === 'writeContractAutoThrow') {
console.log('before calling justThrow');
await SmartWeave.contracts.write(action.input.contractId, {
function: 'justThrow',
});
@@ -17,7 +16,6 @@ export async function handle(state, action) {
state.errorCounter = 0;
}
state.errorCounter++;
console.log('after calling justThrow', state.errorCounter);
return { state };
}
if (action.input.function === 'writeContractForceAutoThrow') {

View File

@@ -281,7 +281,74 @@ describe('Testing internal writes', () => {
['communityLogo', '']
]
});
});
});
describe('AFTR test case - with an illegal read state after an internal write', () => {
it('should throw an Error if contract makes readContractState after write on the same contract', async () => {
const newWarpInstance = WarpFactory.forLocal(port);
const { jwk: wallet2 } = await newWarpInstance.testing.generateWallet();
const aftrBrokenContractSrc = fs.readFileSync(path.join(__dirname, '../data/aftr/sampleContractSrc_broken.js'), 'utf8');
const aftrBrokenContractInitialState = fs.readFileSync(path.join(__dirname, '../data/aftr/sampleContractInitState.json'), 'utf8');
const pst2InitState = fs.readFileSync(path.join(__dirname, '../data/aftr/pstInitState.json'), 'utf8');
const {contractTxId: aftrBrokenTxId, srcTxId: brokenSrcTxId} = await newWarpInstance.createContract.deploy({
wallet: wallet2,
initState: aftrBrokenContractInitialState,
src: aftrBrokenContractSrc
});
const {contractTxId: pst2TxId} = await newWarpInstance.createContract.deployFromSourceTx({
wallet: wallet2,
initState: pst2InitState,
srcTxId: brokenSrcTxId
});
const aftrBroken = newWarpInstance
.contract<any>(aftrBrokenTxId)
.setEvaluationOptions({
internalWrites: true
})
.connect(wallet2);
const pst2 = newWarpInstance
.contract<any>(pst2TxId)
.setEvaluationOptions({
internalWrites: true
})
.connect(wallet2);
await mineBlock(newWarpInstance);
// (o) mint 10000 tokens in pst contract
await pst2.writeInteraction({
function: "mint",
qty: 10000
});
const transferQty = 1;
// (o) set allowance on pst contract for aftr contract
const {originalTxId} = await pst2.writeInteraction({
function: "allow",
target: aftrBrokenTxId,
qty: transferQty,
});
// (o) make a deposit transaction on the AFTR contract
// note: this transaction makes internalWrite on PST contract
// and then makes a readContractState on a PST contract
// - such operation is not allowed.
await expect(aftrBroken.writeInteraction({
function: "deposit",
tokenId: pst2TxId,
qty: transferQty,
txID: originalTxId,
}, { strict: true })).rejects.toThrowError(
/Calling a readContractState after performing an inner write is wrong - instead use a state from the result of an internal write./
);
});
});
});

View File

@@ -279,7 +279,7 @@ describe('Testing internal writes', () => {
});
});
describe('with internal writes throwing exceptions', () => {
fdescribe('with internal writes throwing exceptions', () => {
beforeAll(async () => {
await deployContracts();
});

View File

@@ -63,6 +63,10 @@ export interface EvolveState {
evolve: string;
}
export type InnerCallType = 'read' | 'view' | 'write';
export type InnerCallData = { callingInteraction: GQLNodeInterface; callType: InnerCallType };
/**
* A base interface to be implemented by SmartWeave Contracts clients
* - contains "low-level" methods that allow to interact with any contract
@@ -91,8 +95,7 @@ export interface Contract<State = unknown> extends Source {
setEvaluationOptions(options: Partial<EvaluationOptions>): Contract<State>;
/**
* Returns state of the contract at required blockHeight.
* Similar to {@link readContract} from the current version.
* Returns state of the contract at required sortKey or blockHeight.
*
* @param sortKeyOrBlockHeight - either a sortKey or block height at which the contract should be read
*

View File

@@ -29,7 +29,8 @@ import {
SigningFunction,
CurrentTx,
WriteInteractionOptions,
WriteInteractionResponse
WriteInteractionResponse,
InnerCallData
} from './Contract';
import { Tags, ArTransfer, emptyTransfer, ArWallet } from './deploy/CreateContract';
import { SourceData, SourceImpl } from './deploy/impl/SourceImpl';
@@ -62,7 +63,7 @@ export class HandlerBasedContract<State> implements Contract<State> {
private readonly _contractTxId: string,
protected readonly warp: Warp,
private readonly _parentContract: Contract = null,
private readonly _callingInteraction: GQLNodeInterface = null
private readonly _innerCallData: InnerCallData = null
) {
this.waitForConfirmation = this.waitForConfirmation.bind(this);
this._arweaveWrapper = new ArweaveWrapper(warp.arweave);
@@ -70,7 +71,9 @@ export class HandlerBasedContract<State> implements Contract<State> {
if (_parentContract != null) {
this._evaluationOptions = _parentContract.evaluationOptions();
this._callDepth = _parentContract.callDepth() + 1;
const callingInteraction: InteractionCall = _parentContract.getCallStack().getInteraction(_callingInteraction.id);
const callingInteraction: InteractionCall = _parentContract
.getCallStack()
.getInteraction(_innerCallData.callingInteraction.id);
if (this._callDepth > this._evaluationOptions.maxCallDepth) {
throw Error(
@@ -79,14 +82,30 @@ export class HandlerBasedContract<State> implements Contract<State> {
)}`
);
}
this.logger.debug('Calling interaction', { id: _callingInteraction.id, sortKey: _callingInteraction.sortKey });
const callStack = new ContractCallStack(_contractTxId, this._callDepth);
this.logger.debug('Calling interaction', {
id: _innerCallData.callingInteraction.id,
sortKey: _innerCallData.callingInteraction.sortKey,
type: _innerCallData.callType
});
// if you're reading a state of the contract, on which you've just made a write - you're doing it wrong.
// the current state of the callee contract is always in the result of an internal write.
// following is a protection against naughty developers who might be doing such crazy things ;-)
if (
callingInteraction.interactionInput?.foreignContractCalls[_contractTxId]?.innerCallType === 'write' &&
_innerCallData.callType === 'read'
) {
throw new Error(
'Calling a readContractState after performing an inner write is wrong - instead use a state from the result of an internal write.'
);
}
const callStack = new ContractCallStack(_contractTxId, this._callDepth, _innerCallData?.callType);
callingInteraction.interactionInput.foreignContractCalls[_contractTxId] = callStack;
this._callStack = callStack;
this._rootSortKey = _parentContract.rootSortKey;
console.log('====CHILD constructor, parent callstack: ', _parentContract.getCallStack().print());
// console.log('==== CHILD constructor, parent callstack: ', _parentContract.getCallStack().print());
} else {
this._callDepth = 0;
this._callStack = new ContractCallStack(_contractTxId, 0);
@@ -318,7 +337,7 @@ export class HandlerBasedContract<State> implements Contract<State> {
const handlerResult = await this.callContract(input, undefined, undefined, tags, transfer, strict);
if ((input as any).function === 'deposit') {
console.log('====== handlerResult ======', handlerResult);
// console.log('====== handlerResult ======', handlerResult);
}
if (strict && handlerResult.type !== 'ok') {
throw Error(`Cannot create interaction: ${handlerResult.errorMessage}`);
@@ -329,8 +348,8 @@ export class HandlerBasedContract<State> implements Contract<State> {
this.logger.debug('Callstack', callStack.print());
if ((input as any).function === 'deposit') {
console.log('====== Call stack ======', callStack.print());
console.log('====== Inner Writes ======', innerWrites);
// console.log('====== Call stack ======', callStack.print());
// console.log('====== Inner Writes ======', innerWrites);
}
innerWrites.forEach((contractTxId) => {
@@ -624,13 +643,13 @@ export class HandlerBasedContract<State> implements Contract<State> {
// there could haven been some earlier, non-cached interactions - which will be added
// after eval in line 619. We need to clear them, as it is only the currently
// being added interaction that we're interested in.
if (this._parentContract) {
/*if (this._parentContract) {
console.log('======== CLEARING CALL STACK');
const callStack = new ContractCallStack(this.txId(), this._callDepth);
const callingInteraction = this._parentContract.getCallStack().getInteraction(this._callingInteraction.id);
callingInteraction.interactionInput.foreignContractCalls[this.txId()] = callStack;
this._callStack = callStack;
}
}*/
this.logger.debug('callContractForTx - evalStateResult', {
result: evalStateResult.cachedValue.state,
txId: this._contractTxId
@@ -647,7 +666,7 @@ export class HandlerBasedContract<State> implements Contract<State> {
currentTx
};
console.log('====== evalInteraction');
// console.log('====== evalInteraction');
const result = await this.evalInteraction<Input, View>(
interactionData,
executionContext,
@@ -664,15 +683,15 @@ export class HandlerBasedContract<State> implements Contract<State> {
executionContext: ExecutionContext<State, HandlerApi<State>>,
evalStateResult: EvalStateResult<State>
) {
console.log('====== addInteractionData to callStack', interactionData.interaction.input);
console.log('====== addInteractionData to callStack - callstack', this.getCallStack().print());
console.log('====== addInteractionData to callStack - parent callstack', this.parent()?.getCallStack().print());
// console.log('====== addInteractionData to callStack', interactionData.interaction.input);
// console.log('====== addInteractionData to callStack - callstack', this.getCallStack().print());
// console.log('====== addInteractionData to callStack - parent callstack', this.parent()?.getCallStack().print());
const interactionCall: InteractionCall = this.getCallStack().addInteractionData(interactionData);
console.log('====== AFTER ADDING ======');
console.log('====== addInteractionData to callStack - callstack', this.getCallStack().print());
console.log('====== addInteractionData to callStack - parent callstack', this.parent()?.getCallStack().print());
// console.log('====== AFTER ADDING ======');
// console.log('====== addInteractionData to callStack - callstack', this.getCallStack().print());
// console.log('====== addInteractionData to callStack - parent callstack', this.parent()?.getCallStack().print());
const benchmark = Benchmark.measure();
const result = await executionContext.handler.handle<Input, View>(
@@ -681,6 +700,8 @@ export class HandlerBasedContract<State> implements Contract<State> {
interactionData
);
// console.log('RESULT', result);
interactionCall.update({
cacheHit: false,
outputState: this._evaluationOptions.stackTrace.saveState ? result.state : undefined,
@@ -690,8 +711,8 @@ export class HandlerBasedContract<State> implements Contract<State> {
gasUsed: result.gasUsed
});
console.log('==== Callstack after interaction call', this.getCallStack().print());
console.log('==== PARENT Callstack after interaction call', this.parent()?.getCallStack().print());
// console.log('==== Callstack after interaction call', this.getCallStack().print());
// console.log('==== PARENT Callstack after interaction call', this.parent()?.getCallStack().print());
return result;
}
@@ -763,10 +784,6 @@ export class HandlerBasedContract<State> implements Contract<State> {
return srcTx.id;
}
get callingInteraction(): GQLNodeInterface | null {
return this._callingInteraction;
}
get rootSortKey(): string {
return this._rootSortKey;
}

View File

@@ -1,15 +1,14 @@
import { InteractionData } from './modules/impl/HandlerExecutorFactory';
import { randomUUID } from 'crypto';
import { InnerCallType } from '../contract/Contract';
export class ContractCallStack {
readonly interactions: { [key: string]: InteractionCall } = {};
readonly id: string;
constructor(
public readonly contractTxId: string,
public readonly depth: number,
public readonly label: string = '',
public readonly id = randomUUID()
) {}
constructor(readonly contractTxId: string, readonly depth: number, readonly innerCallType: InnerCallType = null) {
this.id = randomUUID();
}
addInteractionData(interactionData: InteractionData<any>): InteractionCall {
const { interaction, interactionTx } = interactionData;
@@ -38,7 +37,7 @@ export class ContractCallStack {
}
print(): string {
return JSON.stringify(this);
return JSON.stringify(this, null, 2);
}
}

View File

@@ -1,6 +1,6 @@
import Arweave from 'arweave';
import { LevelDbCache } from '../cache/impl/LevelDbCache';
import { Contract } from '../contract/Contract';
import {Contract, InnerCallData, InnerCallType} from '../contract/Contract';
import { CreateContract } from '../contract/deploy/CreateContract';
import { DefaultCreateContract } from '../contract/deploy/impl/DefaultCreateContract';
import { HandlerBasedContract } from '../contract/HandlerBasedContract';
@@ -61,9 +61,9 @@ export class Warp {
contract<State>(
contractTxId: string,
callingContract?: Contract,
callingInteraction?: GQLNodeInterface
innerCallData?: InnerCallData
): Contract<State> {
return new HandlerBasedContract<State>(contractTxId, this, callingContract, callingInteraction);
return new HandlerBasedContract<State>(contractTxId, this, callingContract, innerCallData);
}
/**

View File

@@ -107,11 +107,10 @@ export abstract class DefaultStateEvaluator implements StateEvaluator {
.addInteractionData({ interaction: null, interactionTx: missingInteraction, currentTx });
// creating a Contract instance for the "writing" contract
const writingContract = executionContext.warp.contract(
writingContractTxId,
executionContext.contract,
missingInteraction
);
const writingContract = executionContext.warp.contract(writingContractTxId, executionContext.contract, {
callingInteraction: missingInteraction,
callType: 'read'
});
await this.onContractCall(
missingInteraction,

View File

@@ -55,11 +55,10 @@ export abstract class AbstractContractHandler<State> implements HandlerApi<State
this.logger.debug('swGlobal.write call:', debugData);
// The contract that we want to call and modify its state
const calleeContract = executionContext.warp.contract(
contractTxId,
executionContext.contract,
this.swGlobal._activeTx
);
const calleeContract = executionContext.warp.contract(contractTxId, executionContext.contract, {
callingInteraction: this.swGlobal._activeTx,
callType: 'write'
});
const result = await calleeContract.dryWriteFromTx<Input>(input, this.swGlobal._activeTx, [
...(currentTx || []),
@@ -104,11 +103,10 @@ export abstract class AbstractContractHandler<State> implements HandlerApi<State
to: contractTxId,
input
});
const childContract = executionContext.warp.contract(
contractTxId,
executionContext.contract,
this.swGlobal._activeTx
);
const childContract = executionContext.warp.contract(contractTxId, executionContext.contract, {
callingInteraction: this.swGlobal._activeTx,
callType: 'view'
});
return await childContract.viewStateForTx(input, this.swGlobal._activeTx);
};
@@ -129,7 +127,10 @@ export abstract class AbstractContractHandler<State> implements HandlerApi<State
});
const { stateEvaluator } = executionContext.warp;
const childContract = executionContext.warp.contract(contractTxId, executionContext.contract, interactionTx);
const childContract = executionContext.warp.contract(contractTxId, executionContext.contract, {
callingInteraction: interactionTx,
callType: 'read'
});
await stateEvaluator.onContractCall(interactionTx, executionContext, currentResult);