Skip to content

Commit

Permalink
feat: new instruction type AssertBucketContains (#1106)
Browse files Browse the repository at this point in the history
Description
---
* New instruction `AssertBucketContains` and associated utils and errors
* New workspace action for the engine to handle the new instruction
* Updated protobuf definitions with the new instruction type
* Updated TypeScript bindings with the new instruction type
* Bumped TypeScript bindings package version to trigger a npm publish
* Made `tari_dan_wallet_web_ui` npm dependencies with bindings and
clients use the npm registry instead of local dependencies, to avoid
chicken-and-egg situations.

Motivation and Context
---
We want a new type of transaction instruction to execute asserts.
Asserts can be useful in a variety of ways, making transactions more
secure and easier to review by the user. For example, it could allow to
implement a maximum slippage in a decentralized exchange.

The main use case is to ensure that the return bucket of a component
call invocation contains the expected resource and amount of tokens,
hence the new instruction is called `AssertBucketContains`. The fields
of the new instruction type are:
* `key`: workspace reference of the bucket that we want to assert
* `resource_address`: the resource address that the bucket must contain
* `min_amount`: the minimum amount of tokens that the bucket must
contain

How Has This Been Tested?
---
New engine unit tests that cover all scenarios

What process can a PR reviewer use to test or verify this change?
---
Send a transaction with the new instruction type

Breaking Changes
---

- [x] None
- [ ] Requires data directory to be deleted
- [ ] Other - Please specify
  • Loading branch information
mrnaveira authored Aug 1, 2024
1 parent 5b0820e commit 0ff6cbf
Show file tree
Hide file tree
Showing 19 changed files with 1,339 additions and 587 deletions.
1,591 changes: 1,025 additions & 566 deletions applications/tari_dan_wallet_web_ui/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions applications/tari_dan_wallet_web_ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"@mui/x-data-grid": "^6.0.2",
"@tanstack/react-query": "^4.33.0",
"@tanstack/react-query-devtools": "^4.33.0",
"@tari-project/typescript-bindings": "file:../../bindings",
"@tari-project/wallet_daemon_client": "file:../../clients/javascript/wallet_daemon_client",
"@tari-project/typescript-bindings": "^1.0.3",
"@tari-project/wallet_jrpc_client": "^1.0.8",
"@walletconnect/core": "^2.13.3",
"@walletconnect/web3wallet": "^1.12.3",
"file-saver": "^2.0.5",
Expand Down
2 changes: 1 addition & 1 deletion applications/tari_dan_wallet_web_ui/src/utils/json_rpc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import type {
SubstatesListRequest,
SubstatesListResponse,
} from "@tari-project/typescript-bindings/wallet-daemon-client";
import { AccountGetDefaultRequest, TemplatesGetRequest, WalletDaemonClient } from "@tari-project/wallet_daemon_client";
import { AccountGetDefaultRequest, TemplatesGetRequest, WalletDaemonClient } from "@tari-project/wallet_jrpc_client";

let clientInstance: WalletDaemonClient | null = null;
let pendingClientInstance: Promise<WalletDaemonClient> | null = null;
Expand Down
2 changes: 1 addition & 1 deletion applications/tari_dan_wallet_web_ui/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "../../bindings/**/*"],
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
4 changes: 2 additions & 2 deletions bindings/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bindings/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tari-project/typescript-bindings",
"version": "1.0.3",
"version": "1.0.4",
"description": "",
"main": "index.ts",
"publishConfig": {
Expand Down
5 changes: 4 additions & 1 deletion bindings/src/types/Instruction.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Amount } from "./Amount";
import type { Arg } from "./Arg";
import type { ComponentAddress } from "./ComponentAddress";
import type { ConfidentialClaim } from "./ConfidentialClaim";
import type { LogLevel } from "./LogLevel";
import type { ResourceAddress } from "./ResourceAddress";

export type Instruction =
| { CreateAccount: { owner_public_key: string; workspace_bucket: string | null } }
Expand All @@ -12,4 +14,5 @@ export type Instruction =
| { EmitLog: { level: LogLevel; message: string } }
| { ClaimBurn: { claim: ConfidentialClaim } }
| { ClaimValidatorFees: { epoch: number; validator_public_key: string } }
| "DropAllProofsInWorkspace";
| "DropAllProofsInWorkspace"
| { AssertBucketContains: { key: Array<number>; resource_address: ResourceAddress; min_amount: Amount } };
14 changes: 3 additions & 11 deletions clients/javascript/wallet_daemon_client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions dan_layer/engine/src/runtime/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ pub enum RuntimeError {
NumericConversionError { details: String },
#[error("Auth callback MUST return null, but it returned non-null")]
UnexpectedNonNullInAuthHookReturn,

#[error("Assert error: {0}")]
AssertError(#[from] AssertError),
}

impl RuntimeError {
Expand All @@ -274,6 +277,19 @@ impl IsNotFoundError for RuntimeError {
}
}

#[derive(Debug, thiserror::Error)]
pub enum AssertError {
#[error("The workspace value is not a bucket")]
InvalidBucket,
#[error("Assert expected bucket to have resource {expected} but has {got}")]
InvalidResource {
expected: ResourceAddress,
got: ResourceAddress,
},
#[error("Assert expected bucket to have at least {expected} tokens but only has {got}")]
InvalidAmount { expected: Amount, got: Amount },
}

#[derive(Debug, thiserror::Error)]
pub enum TransactionCommitError {
#[error("{count} dangling buckets remain after transaction execution")]
Expand Down
36 changes: 36 additions & 0 deletions dan_layer/engine/src/runtime/impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ use tari_template_lib::{
NonFungible,
NonFungibleAddress,
NotAuthorized,
ResourceAddress,
VaultId,
VaultRef,
},
Expand All @@ -105,6 +106,7 @@ use super::{working_state::WorkingState, Runtime};
use crate::{
runtime::{
engine_args::EngineArgs,
error::AssertError,
locking::{LockError, LockedSubstate},
scope::PushCallFrame,
tracker::StateTracker,
Expand Down Expand Up @@ -2025,6 +2027,40 @@ impl<TTemplateProvider: TemplateProvider<Template = LoadedTemplate>> RuntimeInte
Ok(InvokeResult::unit())
})
},
WorkspaceAction::AssertBucketContains => {
let key: Vec<u8> = args.get(0)?;
let resource_address: ResourceAddress = args.get(1)?;
let min_amount: Amount = args.get(2)?;

// get the bucket from the workspace
let value = self.tracker.get_from_workspace(&key)?;
let bucket_id = value
.bucket_ids()
.first()
.ok_or_else(|| RuntimeError::AssertError(AssertError::InvalidBucket))?;

self.tracker.read_with(|state| {
let bucket = state.get_bucket(*bucket_id)?;

// validate the bucket resource
if *bucket.resource_address() != resource_address {
return Err(RuntimeError::AssertError(AssertError::InvalidResource {
expected: resource_address,
got: *bucket.resource_address(),
}));
}

// validate the bucket amount
if bucket.amount() < min_amount {
return Err(RuntimeError::AssertError(AssertError::InvalidAmount {
expected: min_amount,
got: bucket.amount(),
}));
}

Ok(InvokeResult::unit())
})
},
}
}

Expand Down
2 changes: 1 addition & 1 deletion dan_layer/engine/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ mod engine_args;
pub use crate::runtime::engine_args::EngineArgs;

mod error;
pub use error::{RuntimeError, TransactionCommitError};
pub use error::{AssertError, RuntimeError, TransactionCommitError};

mod actions;
pub use actions::*;
Expand Down
11 changes: 11 additions & 0 deletions dan_layer/engine/src/transaction/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,17 @@ impl<TTemplateProvider: TemplateProvider<Template = LoadedTemplate> + 'static> T
.claim_validator_fees(Epoch(epoch), validator_public_key)?;
Ok(InstructionResult::empty())
},
Instruction::AssertBucketContains {
key,
resource_address,
min_amount,
} => {
runtime.interface().workspace_invoke(
WorkspaceAction::AssertBucketContains,
invoke_args![key, resource_address, min_amount].into(),
)?;
Ok(InstructionResult::empty())
},
}
}

Expand Down
176 changes: 176 additions & 0 deletions dan_layer/engine/tests/asserts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Copyright 2024 The Tari Project
// SPDX-License-Identifier: BSD-3-Clause

use std::vec;

use tari_crypto::ristretto::RistrettoSecretKey;
use tari_dan_engine::runtime::{AssertError, RuntimeError};
use tari_template_lib::{
args,
models::{Amount, ComponentAddress, NonFungibleAddress, ResourceAddress},
prelude::XTR,
};
use tari_template_test_tooling::{support::assert_error::assert_reject_reason, TemplateTest};
use tari_transaction::{Instruction, Transaction};

const FAUCET_WITHDRAWAL_AMOUNT: Amount = Amount::new(1000);

struct AssertTest {
template_test: TemplateTest,
faucet_component: ComponentAddress,
faucet_resource: ResourceAddress,
account: ComponentAddress,
account_proof: NonFungibleAddress,
account_key: RistrettoSecretKey,
}

fn setup() -> AssertTest {
let mut template_test = TemplateTest::new(vec!["tests/templates/tariswap"]);

let faucet_template = template_test.get_template_address("TestFaucet");

let initial_supply = Amount(1_000_000_000_000);
let result = template_test
.execute_and_commit(
vec![Instruction::CallFunction {
template_address: faucet_template,
function: "mint".to_string(),
args: args![initial_supply],
}],
vec![template_test.get_test_proof()],
)
.unwrap();

let faucet_component: ComponentAddress = result.finalize.execution_results[0].decode().unwrap();

let faucet_resource = result
.finalize
.result
.expect("Faucet mint failed")
.up_iter()
.find_map(|(address, _)| address.as_resource_address())
.unwrap();

// Create user account to receive faucet tokens
let (account, account_proof, account_key) = template_test.create_funded_account();

AssertTest {
template_test,
faucet_component,
faucet_resource,
account,
account_proof,
account_key,
}
}

#[test]
fn successful_assert() {
let mut test: AssertTest = setup();

test.template_test.execute_expect_success(
Transaction::builder()
.call_method(test.faucet_component, "take_free_coins", args![])
.put_last_instruction_output_on_workspace("faucet_bucket")
.assert_bucket_contains("faucet_bucket", test.faucet_resource, FAUCET_WITHDRAWAL_AMOUNT)
.call_method(test.account, "deposit", args![Workspace("faucet_bucket")])
.sign(&test.account_key)
.build(),
vec![test.account_proof.clone()],
);
}

#[test]
fn it_fails_with_invalid_resource() {
let mut test: AssertTest = setup();

// we are going to assert a different resource than the faucet resource
let invalid_resource_address = XTR;

let reason = test.template_test.execute_expect_failure(
Transaction::builder()
.call_method(test.faucet_component, "take_free_coins", args![])
.put_last_instruction_output_on_workspace("faucet_bucket")
.assert_bucket_contains("faucet_bucket", invalid_resource_address, FAUCET_WITHDRAWAL_AMOUNT)
.call_method(test.account, "deposit", args![Workspace("faucet_bucket")])
.sign(&test.account_key)
.build(),
vec![test.account_proof.clone()],
);

assert_reject_reason(
reason,
RuntimeError::AssertError(AssertError::InvalidResource {
expected: invalid_resource_address,
got: test.faucet_resource,
}),
);
}

#[test]
fn it_fails_with_invalid_amount() {
let mut test: AssertTest = setup();

// we are going to assert that the faucet bucket has more tokens that it really has
let min_amount = FAUCET_WITHDRAWAL_AMOUNT + 1;

let reason = test.template_test.execute_expect_failure(
Transaction::builder()
.call_method(test.faucet_component, "take_free_coins", args![])
.put_last_instruction_output_on_workspace("faucet_bucket")
.assert_bucket_contains("faucet_bucket", test.faucet_resource, min_amount)
.call_method(test.account, "deposit", args![Workspace("faucet_bucket")])
.sign(&test.account_key)
.build(),
vec![test.account_proof.clone()],
);

assert_reject_reason(
reason,
RuntimeError::AssertError(AssertError::InvalidAmount {
expected: min_amount,
got: FAUCET_WITHDRAWAL_AMOUNT,
}),
);
}

#[test]
fn it_fails_with_invalid_bucket() {
let mut test: AssertTest = setup();

let reason = test.template_test.execute_expect_failure(
Transaction::builder()
.call_method(test.faucet_component, "take_free_coins", args![])
// we are going to assert a workspace value that is NOT a bucket
.call_method(test.account, "get_balances", args![])
.put_last_instruction_output_on_workspace("invalid_bucket")
.assert_bucket_contains("invalid_bucket", test.faucet_resource, FAUCET_WITHDRAWAL_AMOUNT)
.call_method(test.account, "deposit", args![Workspace("faucet_bucket")])
.sign(&test.account_key)
.build(),
vec![test.account_proof.clone()],
);

assert_reject_reason(reason, RuntimeError::AssertError(AssertError::InvalidBucket {}));
}

#[test]
fn it_fails_with_invalid_workspace_key() {
let mut test: AssertTest = setup();

let reason = test.template_test.execute_expect_failure(
Transaction::builder()
.call_method(test.faucet_component, "take_free_coins", args![])
.put_last_instruction_output_on_workspace("faucet_bucket")
// we are going to assert a key that does not exist in the workspace
.assert_bucket_contains("invalid_key", test.faucet_resource, FAUCET_WITHDRAWAL_AMOUNT)
.call_method(test.account, "deposit", args![Workspace("faucet_bucket")])
.sign(&test.account_key)
.build(),
vec![test.account_proof.clone()],
);

assert_reject_reason(reason, RuntimeError::ItemNotOnWorkspace {
key: "invalid_key".to_string(),
});
}
Loading

0 comments on commit 0ff6cbf

Please sign in to comment.