Skip to content

Commit a49e925

Browse files
committed
feat: upgradable integration (#87)
Add self-upgrade capability to the smart contract.
1 parent 02d12c5 commit a49e925

6 files changed

Lines changed: 420 additions & 4 deletions

File tree

contract/src/lib.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ extern crate static_assertions;
44
use api::{Payout, RestrictionApi, SweatApi};
55
use event::Event;
66
use near_contract_standards::fungible_token::{events::FtBurn, FungibleToken};
7-
use near_plugins::{access_control, access_control_any, AccessControlRole, AccessControllable};
7+
use near_plugins::{access_control, access_control_any, AccessControlRole, AccessControllable, Upgradable};
8+
use near_sdk::borsh::BorshDeserialize;
89
use near_sdk::{
910
assert_one_yocto,
1011
collections::UnorderedSet,
@@ -35,11 +36,20 @@ pub enum Role {
3536
PauseManager,
3637
UnpauseManager,
3738
DenylistManager,
39+
StagingManager,
40+
UpgradeManager,
3841
}
3942

40-
#[near(contract_state)]
43+
#[derive(PanicOnDefault, Upgradable)]
4144
#[access_control(role_type(Role))]
42-
#[derive(PanicOnDefault)]
45+
#[upgradable(access_control_roles(
46+
code_stagers(Role::StagingManager),
47+
code_deployers(Role::UpgradeManager),
48+
duration_initializers(Role::UpgradeManager),
49+
duration_update_stagers(Role::UpgradeManager),
50+
duration_update_appliers(Role::UpgradeManager),
51+
))]
52+
#[near(contract_state)]
4353
pub struct Contract {
4454
token: FungibleToken,
4555
steps_since_tge: U64,

integration-tests/tests/common/prepare.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,19 @@ fn sweat_wasm_path() -> PathBuf {
210210
)
211211
}
212212

213+
/// Raw bytes of the sweat contract WASM the sandbox deploys. Exposed so upgrade
214+
/// tests can stage and re-deploy the contract over itself.
215+
pub fn sweat_wasm_bytes() -> Result<Vec<u8>> {
216+
let path = sweat_wasm_path();
217+
std::fs::read(&path).map_err(|e| {
218+
anyhow!(
219+
"failed to read sweat WASM at {} — did you run `make build-integration`? \
220+
Override the path with the {SWEAT_WASM_ENV} env var. ({e})",
221+
path.display()
222+
)
223+
})
224+
}
225+
213226
fn claim_wasm_path() -> PathBuf {
214227
wasm_path(
215228
CLAIM_WASM_ENV,

integration-tests/tests/upgrade.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
use serde_json::json;
2+
use tracing::info;
3+
4+
mod common;
5+
use common::{panic::PanicFinder, prepare::sweat_wasm_bytes, prepare::Context};
6+
7+
/// `up_stage_code` is restricted to `StagingManager` (the `code_stagers` role)
8+
/// and `up_deploy_code` to `UpgradeManager` (`code_deployers`). The two roles
9+
/// are distinct: holding the stager role does not grant the deployer one.
10+
#[tokio::test]
11+
#[tracing::instrument]
12+
async fn test_upgrade_access_control() -> anyhow::Result<()> {
13+
let context = Context::builder().build().await?;
14+
let code = sweat_wasm_bytes()?;
15+
16+
info!("call up_stage_code [signer=alice, unauthorized]");
17+
let result = context
18+
.alice
19+
.call(context.sweat.id(), "up_stage_code")
20+
.args(code.clone())
21+
.max_gas()
22+
.transact()
23+
.await?
24+
.into_result();
25+
assert!(result.has_panic("Insufficient permissions for method up_stage_code restricted by access control."));
26+
27+
info!("view up_staged_code_hash — nothing should be staged yet");
28+
let staged: Option<String> = context.sweat.view("up_staged_code_hash").await?.json()?;
29+
assert_eq!(staged, None, "unauthorized staging must not store any code");
30+
31+
info!("call up_deploy_code [signer=alice, unauthorized]");
32+
let result = context
33+
.alice
34+
.call(context.sweat.id(), "up_deploy_code")
35+
.args_json(json!({ "hash": "ignored", "function_call_args": null }))
36+
.max_gas()
37+
.transact()
38+
.await?
39+
.into_result();
40+
assert!(result.has_panic("Insufficient permissions for method up_deploy_code restricted by access control."));
41+
42+
info!("call acl_grant_role(StagingManager, alice) [signer=contract, super-admin]");
43+
let granted: Option<bool> = context
44+
.sweat
45+
.call("acl_grant_role")
46+
.args_json(json!({ "role": "StagingManager", "account_id": context.alice.id() }))
47+
.transact()
48+
.await?
49+
.json()?;
50+
assert_eq!(granted, Some(true));
51+
52+
info!("call up_stage_code [signer=alice, authorized as StagingManager]");
53+
let result = context
54+
.alice
55+
.call(context.sweat.id(), "up_stage_code")
56+
.args(code.clone())
57+
.max_gas()
58+
.transact()
59+
.await?
60+
.into_result()?;
61+
assert!(result.outcome().is_success());
62+
63+
info!("view up_staged_code_hash — code is now staged");
64+
let staged: Option<String> = context.sweat.view("up_staged_code_hash").await?.json()?;
65+
assert!(staged.is_some(), "staged code hash should be set after staging");
66+
67+
info!("call up_deploy_code [signer=alice, StagingManager but not UpgradeManager]");
68+
let result = context
69+
.alice
70+
.call(context.sweat.id(), "up_deploy_code")
71+
.args_json(json!({ "hash": staged.unwrap(), "function_call_args": null }))
72+
.max_gas()
73+
.transact()
74+
.await?
75+
.into_result();
76+
assert!(
77+
result.has_panic("Insufficient permissions for method up_deploy_code restricted by access control."),
78+
"the stager role must not grant deploy permission"
79+
);
80+
81+
Ok(())
82+
}
83+
84+
/// Full stage → deploy flow: an `UpgradeManager` re-deploys the contract over
85+
/// itself and the existing state survives the upgrade.
86+
#[tokio::test]
87+
#[tracing::instrument]
88+
async fn test_upgrade_deploy() -> anyhow::Result<()> {
89+
let context = Context::builder().with_oracle().with_claim().build().await?;
90+
let code = sweat_wasm_bytes()?;
91+
92+
info!("record a batch so there is pre-upgrade state to preserve");
93+
context
94+
.oracle()
95+
.call(context.sweat.id(), "defer_batch")
96+
.args_json(json!({ "steps_batch": [[context.alice.id(), 10_000]] }))
97+
.max_gas()
98+
.transact()
99+
.await?
100+
.into_result()?;
101+
let steps_before: String = context.sweat.view("get_steps_since_tge").await?.json()?;
102+
assert_ne!(steps_before, "0", "steps should be recorded before the upgrade");
103+
104+
info!("grant the upgrade roles to alice [signer=contract, super-admin]");
105+
for role in ["StagingManager", "UpgradeManager"] {
106+
let granted: Option<bool> = context
107+
.sweat
108+
.call("acl_grant_role")
109+
.args_json(json!({ "role": role, "account_id": context.alice.id() }))
110+
.transact()
111+
.await?
112+
.json()?;
113+
assert_eq!(granted, Some(true), "{role} grant should succeed");
114+
}
115+
116+
info!("stage the contract code [signer=alice, StagingManager]");
117+
context
118+
.alice
119+
.call(context.sweat.id(), "up_stage_code")
120+
.args(code)
121+
.max_gas()
122+
.transact()
123+
.await?
124+
.into_result()?;
125+
126+
info!("read back the staged code hash to feed the deploy");
127+
let staged_hash: Option<String> = context.sweat.view("up_staged_code_hash").await?.json()?;
128+
let staged_hash = staged_hash.expect("code must be staged before deploy");
129+
130+
info!("deploy the staged code [signer=alice, UpgradeManager]");
131+
let result = context
132+
.alice
133+
.call(context.sweat.id(), "up_deploy_code")
134+
.args_json(json!({ "hash": staged_hash, "function_call_args": null }))
135+
.max_gas()
136+
.transact()
137+
.await?
138+
.into_result()?;
139+
assert!(result.outcome().is_success(), "deploy should succeed");
140+
141+
info!("verify state survived the upgrade");
142+
let steps_after: String = context.sweat.view("get_steps_since_tge").await?.json()?;
143+
assert_eq!(steps_after, steps_before, "steps counter must be preserved across the upgrade");
144+
145+
info!("verify the upgraded contract still serves authorized calls");
146+
context
147+
.oracle()
148+
.call(context.sweat.id(), "defer_batch")
149+
.args_json(json!({ "steps_batch": [[context.alice.id(), 10_000]] }))
150+
.max_gas()
151+
.transact()
152+
.await?
153+
.into_result()?;
154+
let steps_final: String = context.sweat.view("get_steps_since_tge").await?.json()?;
155+
assert_eq!(steps_final, "20000", "the upgraded contract should keep recording steps");
156+
157+
Ok(())
158+
}

res/contract.wasm

27.2 KB
Binary file not shown.

0 commit comments

Comments
 (0)