Skip to content

Commit 1035de5

Browse files
authored
feat(Upgradable)!: enable batched fn call after deploy (#86)
* feat(Upgradable): enable batched fn call after deploy * test: update existing tests * test: add contract with state migration * test: deploy and call migration function * docs: describe new behavior
1 parent 094881b commit 1035de5

File tree

9 files changed

+284
-23
lines changed

9 files changed

+284
-23
lines changed

near-plugins-derive/src/upgradable.rs

+13-3
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ pub fn derive_upgradable(input: TokenStream) -> TokenStream {
180180
}
181181

182182
#[#cratename::access_control_any(roles(#(#acl_roles_code_deployers),*))]
183-
fn up_deploy_code(&mut self) -> near_sdk::Promise {
183+
fn up_deploy_code(&mut self, function_call_args: Option<#cratename::upgradable::FunctionCallArgs>) -> near_sdk::Promise {
184184
let staging_timestamp = self.up_get_timestamp(__UpgradableStorageKey::StagingTimestamp)
185185
.unwrap_or_else(|| ::near_sdk::env::panic_str("Upgradable: staging timestamp isn't set"));
186186

@@ -194,8 +194,18 @@ pub fn derive_upgradable(input: TokenStream) -> TokenStream {
194194
);
195195
}
196196

197-
near_sdk::Promise::new(near_sdk::env::current_account_id())
198-
.deploy_contract(self.up_staged_code().unwrap_or_else(|| ::near_sdk::env::panic_str("Upgradable: No staged code")))
197+
let code = self.up_staged_code().unwrap_or_else(|| ::near_sdk::env::panic_str("Upgradable: No staged code"));
198+
let promise = near_sdk::Promise::new(near_sdk::env::current_account_id())
199+
.deploy_contract(code);
200+
match function_call_args {
201+
None => promise,
202+
Some(args) => {
203+
// Execute the `DeployContract` and `FunctionCall` actions in a batch
204+
// transaction to make a failure of the function call roll back the code
205+
// deployment.
206+
promise.function_call(args.function_name, args.arguments, args.amount, args.gas)
207+
},
208+
}
199209
}
200210

201211
#[#cratename::access_control_any(roles(#(#acl_roles_duration_initializers),*))]

near-plugins-derive/tests/common/upgradable_contract.rs

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use near_plugins::upgradable::UpgradableDurationStatus;
1+
use near_plugins::upgradable::{FunctionCallArgs, UpgradableDurationStatus};
22

33
use near_sdk::serde_json::json;
44
use near_sdk::CryptoHash;
@@ -71,9 +71,13 @@ impl UpgradableContract {
7171
pub async fn up_deploy_code(
7272
&self,
7373
caller: &Account,
74+
function_call_args: Option<FunctionCallArgs>,
7475
) -> workspaces::Result<ExecutionFinalResult> {
7576
caller
7677
.call(self.contract.id(), "up_deploy_code")
78+
.args_json(json!({
79+
"function_call_args": function_call_args,
80+
}))
7781
.max_gas()
7882
.transact()
7983
.await

near-plugins-derive/tests/common/utils.rs

+5
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ where
4949
assert_eq!(actual, expected);
5050
}
5151

52+
/// Asserts transaction failure due `MethodNotFound` error.
53+
pub fn assert_method_not_found_failure(res: ExecutionFinalResult) {
54+
assert_failure_with(res, "Action #0: MethodResolveError(MethodNotFound)");
55+
}
56+
5257
/// Asserts transaction failure due to `method` being `#[private]`.
5358
pub fn assert_private_method_failure(res: ExecutionFinalResult, method: &str) {
5459
let err = res
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "upgradable_state_migration"
3+
version = "0.0.0"
4+
edition = "2018"
5+
6+
[lib]
7+
crate-type = ["cdylib", "rlib"]
8+
9+
[dependencies]
10+
near-plugins = { path = "../../../../near-plugins" }
11+
near-sdk = "4.1.0"
12+
13+
[profile.release]
14+
codegen-units = 1
15+
opt-level = "z"
16+
lto = true
17+
debug = false
18+
panic = "abort"
19+
overflow-checks = true
20+
21+
[workspace]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
build:
2+
cargo build --target wasm32-unknown-unknown --release
3+
4+
# Helpful for debugging. Requires `cargo-expand`.
5+
expand:
6+
cargo expand > expanded.rs
7+
8+
.PHONY: build expand
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[toolchain]
2+
channel = "1.66.1"
3+
components = ["clippy", "rustfmt"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//! A simple contract to be deployed via `Upgradable`. It requires [state migration].
2+
//!
3+
//! [state migration]: https://docs.near.org/develop/upgrade#migrating-the-state
4+
5+
use near_plugins::{access_control, AccessControlRole, AccessControllable, Upgradable};
6+
use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize};
7+
use near_sdk::serde::{Deserialize, Serialize};
8+
use near_sdk::{env, near_bindgen, PanicOnDefault};
9+
10+
/// Roles correspond to those defined in the initial contract `../upgradable`, to make permissions
11+
/// granted before the upgrade remain valid.
12+
#[derive(AccessControlRole, Deserialize, Serialize, Copy, Clone)]
13+
#[serde(crate = "near_sdk::serde")]
14+
pub enum Role {
15+
DAO,
16+
CodeStager,
17+
CodeDeployer,
18+
DurationManager,
19+
}
20+
21+
/// The struct differs from the one defined in the initial contract `../upgradable`, hence [state
22+
/// migration] is required.
23+
///
24+
/// [state migration]: https://docs.near.org/develop/upgrade#migrating-the-state
25+
#[access_control(role_type(Role))]
26+
#[near_bindgen]
27+
#[derive(Upgradable, PanicOnDefault, BorshDeserialize, BorshSerialize)]
28+
#[upgradable(access_control_roles(
29+
code_stagers(Role::CodeStager, Role::DAO),
30+
code_deployers(Role::CodeDeployer, Role::DAO),
31+
duration_initializers(Role::DurationManager, Role::DAO),
32+
duration_update_stagers(Role::DurationManager, Role::DAO),
33+
duration_update_appliers(Role::DurationManager, Role::DAO),
34+
))]
35+
pub struct Contract {
36+
is_migrated: bool,
37+
}
38+
39+
#[near_bindgen]
40+
impl Contract {
41+
/// Migrates state from [`OldContract`] to [`Contract`].
42+
///
43+
/// It follows the state migration pattern described [here].
44+
///
45+
/// [here]: https://docs.near.org/develop/upgrade#migrating-the-state
46+
#[private]
47+
#[init(ignore_state)]
48+
pub fn migrate() -> Self {
49+
// Ensure old state can be read and deserialized.
50+
let _: OldContract = env::state_read().expect("Should be able to load old state");
51+
52+
Self { is_migrated: true }
53+
}
54+
55+
/// A migration method that fails on purpose to test the rollback mechanism of
56+
/// `Upgradable::up_deploy_code`.
57+
#[private]
58+
#[init(ignore_state)]
59+
pub fn migrate_with_failure() -> Self {
60+
env::panic_str("Failing migration on purpose");
61+
}
62+
63+
/// This method is _not_ defined in the initial contract, so calling it successfully proves the
64+
/// contract defined in this file was deployed and the old state was migrated.
65+
pub fn is_migrated(&self) -> bool {
66+
self.is_migrated
67+
}
68+
}
69+
70+
/// Corresponds to the state defined in the initial `../upgradable` contract.
71+
#[derive(BorshDeserialize)]
72+
pub struct OldContract;

near-plugins-derive/tests/upgradable.rs

+120-13
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,21 @@ use anyhow::Ok;
66
use common::access_controllable_contract::AccessControllableContract;
77
use common::upgradable_contract::UpgradableContract;
88
use common::utils::{
9-
assert_failure_with, assert_insufficient_acl_permissions, assert_success_with,
10-
assert_success_with_unit_return, fast_forward_beyond, get_transaction_block,
11-
sdk_duration_from_secs,
9+
assert_failure_with, assert_insufficient_acl_permissions, assert_method_not_found_failure,
10+
assert_success_with, assert_success_with_unit_return, fast_forward_beyond,
11+
get_transaction_block, sdk_duration_from_secs,
1212
};
13+
use near_plugins::upgradable::FunctionCallArgs;
1314
use near_sdk::serde_json::json;
14-
use near_sdk::{CryptoHash, Duration, Timestamp};
15+
use near_sdk::{CryptoHash, Duration, Gas, Timestamp};
1516
use std::path::Path;
1617
use workspaces::network::Sandbox;
1718
use workspaces::result::ExecutionFinalResult;
1819
use workspaces::{Account, AccountId, Contract, Worker};
1920

2021
const PROJECT_PATH: &str = "./tests/contracts/upgradable";
2122
const PROJECT_PATH_2: &str = "./tests/contracts/upgradable_2";
23+
const PROJECT_PATH_STATE_MIGRATION: &str = "./tests/contracts/upgradable_state_migration";
2224

2325
const ERR_MSG_NO_STAGING_TS: &str = "Upgradable: staging timestamp isn't set";
2426
const ERR_MSG_DEPLOY_CODE_TOO_EARLY: &str = "Upgradable: Deploy code too early: staging ends on";
@@ -169,6 +171,18 @@ impl Setup {
169171
.await
170172
}
171173

174+
async fn call_is_migrated(&self, caller: &Account) -> workspaces::Result<ExecutionFinalResult> {
175+
// `is_migrated` could be called via `view`, however here it is called via `transact` so we
176+
// get an `ExecutionFinalResult` that can be passed to `assert_*` methods from
177+
// `common::utils`. It is acceptable since all we care about is whether the method exists
178+
// and can be called successfully.
179+
caller
180+
.call(self.contract.id(), "is_migrated")
181+
.max_gas()
182+
.transact()
183+
.await
184+
}
185+
172186
/// Calls the contract's `is_set_up` method and asserts it returns `true`. Panics on failure.
173187
async fn assert_is_set_up(&self, caller: &Account) {
174188
let res = caller
@@ -419,14 +433,15 @@ async fn test_deploy_code_without_delay() -> anyhow::Result<()> {
419433
setup.assert_staged_code(Some(code)).await;
420434

421435
// Deploy staged code.
422-
let res = setup.upgradable_contract.up_deploy_code(&dao).await?;
436+
let res = setup.upgradable_contract.up_deploy_code(&dao, None).await?;
423437
assert_success_with_unit_return(res);
424438

425439
Ok(())
426440
}
427441

428442
/// Verifies the upgrade was successful by calling a method that's available only on the upgraded
429-
/// contract. Ensures the new contract can be deployed and state migration succeeds.
443+
/// contract. Ensures the new contract can be deployed and state remains valid without
444+
/// explicit state migration.
430445
#[tokio::test]
431446
async fn test_deploy_code_and_call_method() -> anyhow::Result<()> {
432447
let worker = workspaces::sandbox().await?;
@@ -435,7 +450,7 @@ async fn test_deploy_code_and_call_method() -> anyhow::Result<()> {
435450

436451
// Verify function `is_upgraded` is not defined in the initial contract.
437452
let res = setup.call_is_upgraded(&setup.unauth_account).await?;
438-
assert_failure_with(res, "Action #0: MethodResolveError(MethodNotFound)");
453+
assert_method_not_found_failure(res);
439454

440455
// Compile the other version of the contract and stage its code.
441456
let code = common::repo::compile_project(Path::new(PROJECT_PATH_2), "upgradable_2").await?;
@@ -447,7 +462,7 @@ async fn test_deploy_code_and_call_method() -> anyhow::Result<()> {
447462
setup.assert_staged_code(Some(code)).await;
448463

449464
// Deploy staged code.
450-
let res = setup.upgradable_contract.up_deploy_code(&dao).await?;
465+
let res = setup.upgradable_contract.up_deploy_code(&dao, None).await?;
451466
assert_success_with_unit_return(res);
452467

453468
// The newly deployed contract defines the function `is_upgraded`. Calling it successfully
@@ -458,6 +473,94 @@ async fn test_deploy_code_and_call_method() -> anyhow::Result<()> {
458473
Ok(())
459474
}
460475

476+
/// Deploys a new version of the contract that requires state migration and verifies the migration
477+
/// succeeded.
478+
#[tokio::test]
479+
async fn test_deploy_code_with_migration() -> anyhow::Result<()> {
480+
let worker = workspaces::sandbox().await?;
481+
let dao = worker.dev_create_account().await?;
482+
let setup = Setup::new(worker.clone(), Some(dao.id().clone()), None).await?;
483+
484+
// Verify function `is_migrated` is not defined in the initial contract.
485+
let res = setup.call_is_migrated(&setup.unauth_account).await?;
486+
assert_method_not_found_failure(res);
487+
488+
// Compile the other version of the contract and stage its code.
489+
let code = common::repo::compile_project(
490+
Path::new(PROJECT_PATH_STATE_MIGRATION),
491+
"upgradable_state_migration",
492+
)
493+
.await?;
494+
let res = setup
495+
.upgradable_contract
496+
.up_stage_code(&dao, code.clone())
497+
.await?;
498+
assert_success_with_unit_return(res);
499+
setup.assert_staged_code(Some(code)).await;
500+
501+
// Deploy staged code and call the new contract's `migrate` method.
502+
let function_call_args = FunctionCallArgs {
503+
function_name: "migrate".to_string(),
504+
arguments: Vec::new(),
505+
amount: 0,
506+
gas: Gas::ONE_TERA,
507+
};
508+
let res = setup
509+
.upgradable_contract
510+
.up_deploy_code(&dao, Some(function_call_args))
511+
.await?;
512+
assert_success_with_unit_return(res);
513+
514+
// The newly deployed contract defines the function `is_migrated`. Calling it successfully
515+
// verifies the staged contract is deployed and state migration succeeded.
516+
let res = setup.call_is_migrated(&setup.unauth_account).await?;
517+
assert_success_with(res, true);
518+
519+
Ok(())
520+
}
521+
522+
/// Deploys a new version of the contract and, batched with the `DeployContractAction`, calls a
523+
/// migration method that fails. Verifies the failure rolls back the deployment, i.e. the initial
524+
/// code remains active.
525+
#[tokio::test]
526+
async fn test_deploy_code_with_migration_failure_rollback() -> anyhow::Result<()> {
527+
let worker = workspaces::sandbox().await?;
528+
let dao = worker.dev_create_account().await?;
529+
let setup = Setup::new(worker.clone(), Some(dao.id().clone()), None).await?;
530+
531+
// Compile the other version of the contract and stage its code.
532+
let code = common::repo::compile_project(
533+
Path::new(PROJECT_PATH_STATE_MIGRATION),
534+
"upgradable_state_migration",
535+
)
536+
.await?;
537+
let res = setup
538+
.upgradable_contract
539+
.up_stage_code(&dao, code.clone())
540+
.await?;
541+
assert_success_with_unit_return(res);
542+
setup.assert_staged_code(Some(code)).await;
543+
544+
// Deploy staged code and call the new contract's `migrate_with_failure` method.
545+
let function_call_args = FunctionCallArgs {
546+
function_name: "migrate_with_failure".to_string(),
547+
arguments: Vec::new(),
548+
amount: 0,
549+
gas: Gas::ONE_TERA,
550+
};
551+
let res = setup
552+
.upgradable_contract
553+
.up_deploy_code(&dao, Some(function_call_args))
554+
.await?;
555+
assert_failure_with(res, "Failing migration on purpose");
556+
557+
// Verify `code` wasn't deployed by calling a function that is defined only in the initial
558+
// contract but not in the contract contract corresponding to `code`.
559+
setup.assert_is_set_up(&setup.unauth_account).await;
560+
561+
Ok(())
562+
}
563+
461564
#[tokio::test]
462565
async fn test_deploy_code_with_delay() -> anyhow::Result<()> {
463566
let worker = workspaces::sandbox().await?;
@@ -483,7 +586,7 @@ async fn test_deploy_code_with_delay() -> anyhow::Result<()> {
483586
fast_forward_beyond(&worker, staging_duration).await;
484587

485588
// Deploy staged code.
486-
let res = setup.upgradable_contract.up_deploy_code(&dao).await?;
589+
let res = setup.upgradable_contract.up_deploy_code(&dao, None).await?;
487590
assert_success_with_unit_return(res);
488591

489592
Ok(())
@@ -513,9 +616,13 @@ async fn test_deploy_code_with_delay_failure_too_early() -> anyhow::Result<()> {
513616
fast_forward_beyond(&worker, sdk_duration_from_secs(1)).await;
514617

515618
// Verify trying to deploy staged code fails.
516-
let res = setup.upgradable_contract.up_deploy_code(&dao).await?;
619+
let res = setup.upgradable_contract.up_deploy_code(&dao, None).await?;
517620
assert_failure_with(res, ERR_MSG_DEPLOY_CODE_TOO_EARLY);
518621

622+
// Verify `code` wasn't deployed by calling a function that is defined only in the initial
623+
// contract but not in the contract contract corresponding to `code`.
624+
setup.assert_is_set_up(&setup.unauth_account).await;
625+
519626
Ok(())
520627
}
521628

@@ -538,7 +645,7 @@ async fn test_deploy_code_permission_failure() -> anyhow::Result<()> {
538645
// call this method.
539646
let res = setup
540647
.upgradable_contract
541-
.up_deploy_code(&setup.unauth_account)
648+
.up_deploy_code(&setup.unauth_account, None)
542649
.await?;
543650
assert_insufficient_acl_permissions(
544651
res,
@@ -577,7 +684,7 @@ async fn test_deploy_code_empty_failure() -> anyhow::Result<()> {
577684
// The staging timestamp is set when staging code and removed when unstaging code. So when there
578685
// is no code staged, there is no staging timestamp. Hence the error message regarding a missing
579686
// staging timestamp is expected.
580-
let res = setup.upgradable_contract.up_deploy_code(&dao).await?;
687+
let res = setup.upgradable_contract.up_deploy_code(&dao, None).await?;
581688
assert_failure_with(res, ERR_MSG_NO_STAGING_TS);
582689

583690
Ok(())
@@ -825,7 +932,7 @@ async fn test_acl_permission_scope() -> anyhow::Result<()> {
825932
// deploy code.
826933
let res = setup
827934
.upgradable_contract
828-
.up_deploy_code(&setup.unauth_account)
935+
.up_deploy_code(&setup.unauth_account, None)
829936
.await?;
830937
assert_insufficient_acl_permissions(
831938
res,

0 commit comments

Comments
 (0)