Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
109f6ab
.
DSharifi May 26, 2026
db9e211
fix: update abi
DSharifi May 26, 2026
2a09d73
Merge remote-tracking branch 'origin/main' into dsharifi/upgrade-in-b…
DSharifi May 27, 2026
905548f
part of merge
DSharifi May 27, 2026
3e8b452
fix ci
DSharifi May 27, 2026
c9d42e7
merge from main
DSharifi May 28, 2026
be0c04e
.
DSharifi May 28, 2026
b9bc3fa
test: top up the account during chunked upload
DSharifi May 28, 2026
2f20744
cargot fmt
DSharifi May 28, 2026
9527cb6
remove old upload way
DSharifi May 29, 2026
9d71892
remove comments
DSharifi May 29, 2026
a3172db
keep proposal agnostic of chunks
DSharifi May 31, 2026
ff5c144
refactor: uploading chunked should just be a transport
DSharifi May 31, 2026
bf78fad
fix blocking comments
DSharifi May 31, 2026
c5dd7bf
Merge remote-tracking branch 'origin/main' into dsharifi/upgrade-in-b…
DSharifi May 31, 2026
bd3f16d
bump hard limit, still within 1,5 MiB
DSharifi Jun 1, 2026
6e550bd
remove claude comments
DSharifi Jun 1, 2026
c429ba6
update insta
DSharifi Jun 1, 2026
f860197
chore: remove logs
DSharifi Jun 5, 2026
9af8912
chore!: rename `propose_update` to `propose_config_update`
DSharifi Jun 5, 2026
baa80a6
fix: store data lengths and chunk sizes as usize to avoid conversions
DSharifi Jun 5, 2026
e4036a2
remove comment mentioning contract version for legacy upgrade path
DSharifi Jun 5, 2026
500eb46
chore: remove contract check limit
DSharifi Jun 5, 2026
51b5bc5
fix tests to use atomicusize
DSharifi Jun 5, 2026
9b23ea9
use rand for uncompressable bytes
DSharifi Jun 5, 2026
affcdf4
simplify comment, nothing compressed embedded data
DSharifi Jun 5, 2026
c831035
use NonZeroUsize in sandbox tests as well
DSharifi Jun 6, 2026
2336972
use legacy struct in common and fix usize
DSharifi Jun 6, 2026
d6fd524
Merge remote-tracking branch 'origin/main' into dsharifi/upgrade-in-b…
DSharifi Jun 6, 2026
7814180
fix: update the abi
DSharifi Jun 6, 2026
3dfa88e
refactor: needles as conversion
DSharifi Jun 6, 2026
05ed6a4
test(fix): code field comes before config
DSharifi Jun 7, 2026
f91211b
test: use legacy types in e2e tests and also move type to interface
DSharifi Jun 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ members = [
"crates/tee-authority",
"crates/tee-context",
"crates/tee-launcher",
"crates/test-large-contract",
"crates/test-migration-contract",
"crates/test-parallel-contract",
"crates/test-port-allocator",
Expand Down
6 changes: 5 additions & 1 deletion crates/contract/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,11 @@ These functions require the caller to be a participant or candidate.
| `vote_pk(key_event_id: KeyEventId, public_key: PublicKey)` | For Initializing state only. Votes for the public key for the given generation attempt; if enough votes are collected, transitions to the next domain to generate a key for, or if all domains are completed, transitions into Running. | `Result<(), Error>` | TBD | TBD |
| `vote_reshared(key_event_id: KeyEventId)` | For Resharing state only. Votes for the success of the given resharing attempt; if enough votes are collected, transitions to the next domain to reshare for, or if all domains are completed, transitions into Running. | `Result<(), Error>` | TBD | TBD |
| `vote_cancel_keygen(next_domain_id: u64)` | For Initializing state only. Votes to cancel the key generation (identified by the next_domain_id) and revert to the Running state. | `Result<(), Error>` | TBD | TBD |
| `propose_update(args: ProposeUpdateArgs)` | Proposes an update to the contract, requiring an attached deposit. | `Result<UpdateId, Error>` | TBD | TBD |
| `propose_update(args: ProposeUpdateArgs)` | Proposes a configuration update, requiring an attached deposit. Contract-code updates are proposed via the chunked upload flow below, not this method. | `Result<UpdateId, Error>` | TBD | TBD |
| `start_contract_upload(args: StartContractUploadArgs)` | Begins a chunked contract-code upload by declaring the total size. Each voter may have at most one open upload at a time. | `Result<(), Error>` | TBD | TBD |
| `upload_contract_chunk(args: UploadContractChunkArgs)` | Appends a chunk of contract code to the caller's in-progress upload, requiring a deposit covering the chunk's storage cost. | `Result<(), Error>` | TBD | TBD |
| `finalize_contract_upload()` | Finalizes a completed chunked upload and registers it as a contract-code proposal, returning its `UpdateId`. | `Result<UpdateId, Error>` | TBD | TBD |
| `clear_staged_contract()` | Abandons the caller's in-progress chunked upload and refunds the accumulated chunk-storage deposits. | `Result<(), Error>` | TBD | TBD |
| `vote_update(id: UpdateId)` | Votes on a proposed update. If the threshold is met, the update is executed. | `Result<bool, Error>` | TBD | TBD |
| `submit_participant_info(proposed_participant_attestation: Attestation, tls_public_key: Ed25519PublicKey)` | Submits the tee participant info for a potential candidate. c.f. TEE section | `Result<(), Error>` | TBD | TBD |

Expand Down
216 changes: 210 additions & 6 deletions crates/contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ use crate::{
state::ContractNotInitialized,
storage_keys::StorageKey,
tee::tee_state::{TeeQuoteStatus, TeeState},
update::{ProposeUpdateArgs, ProposedUpdates, Update, UpdateId},
update::{
ProposedConfigUpdateArgs, ProposedUpdates, StagedContractUpload, StartContractUploadArgs,
Update, UpdateId, UploadContractChunkArgs,
},
};
use config::Config;
use crypto_shared::{
Expand Down Expand Up @@ -151,6 +154,16 @@ pub struct MpcContract {
pending_ckd_requests: LookupMap<CKDRequest, Vec<YieldIndex>>,
pending_verify_foreign_tx_requests: LookupMap<VerifyForeignTransactionRequest, Vec<YieldIndex>>,
proposed_updates: ProposedUpdates,
/// Per-account metadata for in-progress chunked contract uploads. Each voter
/// may have at most one open upload at a time. See [`start_contract_upload`],
/// [`upload_contract_chunk`], [`finalize_contract_upload`],
/// [`clear_staged_contract`].
staged_uploads: IterableMap<AccountId, StagedContractUpload>,
/// Chunk bytes for in-progress uploads, keyed by `(uploader, chunk_index)`.
/// Lives in a separate `LookupMap` (rather than inside `StagedContractUpload`)
/// so appending a chunk only writes the new chunk's bytes — not the entire
/// accumulated blob.
staged_chunks: LookupMap<(AccountId, usize), Vec<u8>>,
node_foreign_chain_support: SupportedForeignChainsByNode,
config: Config,
tee_state: TeeState,
Expand Down Expand Up @@ -1195,16 +1208,15 @@ impl MpcContract {
.vote_abort_key_event_instance(key_event_id)
}

/// Propose update to either code or config, but not both of them at the same time.
/// Propose update to the config.
#[payable]
#[handle_result]
pub fn propose_update(
pub fn propose_config_update(
&mut self,
#[serializer(borsh)] args: ProposeUpdateArgs,
#[serializer(borsh)] args: ProposedConfigUpdateArgs,
) -> Result<UpdateId, Error> {
// Only voters can propose updates:
let proposer = self.voter_or_panic();
let update: Update = args.try_into()?;
let update: Update = args.into();

let attached = env::attached_deposit();
let required = ProposedUpdates::required_deposit(&update);
Expand Down Expand Up @@ -1234,6 +1246,169 @@ impl MpcContract {
Ok(id)
}

/// Begin a chunked contract-code upload.
#[payable]
#[handle_result]
pub fn start_contract_upload(
&mut self,
#[serializer(borsh)] args: StartContractUploadArgs,
) -> Result<(), Error> {
let caller = self.voter_or_panic();

if self.staged_uploads.contains_key(&caller) {
return Err(InvalidParameters::MalformedPayload {
reason:
"caller already has a staged upload; call clear_staged_contract to abandon it first"
.into(),
}
.into());
}

// Track the deposit attached to `start` so it is refunded on
// `clear_staged_contract`, matching `StagedContractUpload::deposited`'s
// documented contract (the sum of `start` + `upload_chunk` deposits).
let mut staged = StagedContractUpload::new(args.total_size);
staged.deposited = env::attached_deposit();
self.staged_uploads.insert(caller, staged);

Ok(())
}

/// Append a chunk of contract code to the caller's in-progress upload.
#[payable]
#[handle_result]
pub fn upload_contract_chunk(
&mut self,
#[serializer(borsh)] args: UploadContractChunkArgs,
) -> Result<(), Error> {
let caller = self.voter_or_panic();

let attached = env::attached_deposit();
let chunk_len = args.data.len();
let required = StagedContractUpload::required_deposit_for_bytes(chunk_len);
if attached < required {
return Err(InvalidParameters::InsufficientDeposit {
attached: attached.as_yoctonear(),
required: required.as_yoctonear(),
}
.into());
}

let staged =
self.staged_uploads
.get_mut(&caller)
.ok_or(InvalidParameters::MalformedPayload {
reason: "no staged upload found; call start_contract_upload first".into(),
})?;

let chunk_index = staged.record_chunk(chunk_len)?;
staged.deposited = staged.deposited.saturating_add(attached);

// Store chunk bytes in a separate map so the metadata write is small.
self.staged_chunks.insert((caller, chunk_index), args.data);

Ok(())
}

/// Finalize the caller's chunked upload and register it as a contract-code
/// proposal.
#[handle_result]
pub fn finalize_contract_upload(&mut self) -> Result<UpdateId, Error> {
let caller = self.voter_or_panic();

let staged =
self.staged_uploads
.remove(&caller)
.ok_or(InvalidParameters::MalformedPayload {
reason: "no staged upload found; call start_contract_upload first".into(),
})?;

if !staged.is_complete() {
// Reinstate the staged entry so the caller can resume or clear. This
// matches the contract invariant that a non-complete upload is either
// visible (via the staged_uploads map) or has been explicitly cleared.
let total_size = staged.total_size;
let received_bytes = staged.received_bytes;
self.staged_uploads.insert(caller, staged);
return Err(InvalidParameters::MalformedPayload {
reason: format!(
"upload incomplete: received_bytes={received_bytes}, total_size={total_size}"
),
}
.into());
}

let num_chunks = staged.num_chunks;
let total_size = staged.total_size;
let deposited = staged.deposited;

// Read (don't remove) the chunks first so the deposit can be validated before
// any storage is mutated: if the deposit is short we reinstate the staged entry
// and bail with the chunks still in place.
let mut code = Vec::with_capacity(total_size.get());
for i in 0..num_chunks {
let chunk = self
.staged_chunks
.get(&(caller.clone(), i))
.expect("chunk recorded in staged metadata must be present in staged_chunks");
code.extend_from_slice(chunk);
}

let update = Update::Contract(code);
// The proposal entry reserves storage for the code *plus* per-proposal
// vote-tracking overhead. The per-chunk deposit only backs the raw bytes, so
// require the accumulated deposit to cover the full proposal cost — matching
// the invariant `propose_update` enforces for config proposals.
let required = ProposedUpdates::required_deposit(&update);
if deposited < required {
self.staged_uploads.insert(caller, staged);
return Err(InvalidParameters::InsufficientDeposit {
attached: deposited.as_yoctonear(),
required: required.as_yoctonear(),
}
.into());
}

// Deposit is sufficient; free the staged chunk storage and register the proposal.
for i in 0..num_chunks {
self.staged_chunks.remove(&(caller.clone(), i));
}
let id = self.proposed_updates.propose(update);

// Refund any deposit beyond the proposal's storage cost.
if let Some(diff) = deposited.checked_sub(required)
&& diff > NearToken::from_yoctonear(0)
{
Promise::new(caller.clone()).transfer(diff).detach();
}

Ok(id)
}

/// Abandon the caller's in-progress chunked upload and refund the accumulated
/// chunk-storage deposits.
#[handle_result]
pub fn clear_staged_contract(&mut self) -> Result<(), Error> {
let caller = self.voter_or_panic();

let staged =
self.staged_uploads
.remove(&caller)
.ok_or(InvalidParameters::MalformedPayload {
reason: "no staged upload found".into(),
})?;

for i in 0..staged.num_chunks {
self.staged_chunks.remove(&(caller.clone(), i));
}

if staged.deposited > NearToken::from_yoctonear(0) {
Promise::new(caller).transfer(staged.deposited).detach();
}

Ok(())
}

/// Vote for a proposed update given the [`UpdateId`] of the update.
///
/// Returns `Ok(true)` if the amount of voters surpassed the threshold and the update was
Expand Down Expand Up @@ -1630,8 +1805,31 @@ impl MpcContract {
}
};

// An in-progress chunked upload owned by an account that is no longer a
// participant can never be finalized or cleared by its owner (both endpoints
// require `voter_or_panic`), so its chunk bytes and metadata would linger
// forever, locking the contract's balance behind storage staking. Drop them
// here and refund each owner's accumulated deposit.
let orphaned: Vec<(AccountId, usize, NearToken)> = self
.staged_uploads
.iter()
.filter(|(account, _)| !participants.is_participant_given_account_id(account))
.map(|(account, staged)| (account.clone(), staged.num_chunks, staged.deposited))
.collect();

self.proposed_updates
.remove_non_participant_votes(participants);

for (account, num_chunks, deposited) in orphaned {
for i in 0..num_chunks {
self.staged_chunks.remove(&(account.clone(), i));
}
self.staged_uploads.remove(&account);
if deposited > NearToken::from_yoctonear(0) {
Promise::new(account).transfer(deposited).detach();
}
}

Comment on lines +1822 to +1832
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how are we ensuring that this does not consume more gas than we are paying for it?

Ok(())
}

Expand Down Expand Up @@ -1757,6 +1955,8 @@ impl MpcContract {
StorageKey::PendingVerifyForeignTxRequestsV2,
),
proposed_updates: ProposedUpdates::default(),
staged_uploads: IterableMap::new(StorageKey::StagedContractUploads),
staged_chunks: LookupMap::new(StorageKey::StagedContractChunks),
config: init_config.map(Into::into).unwrap_or_default(),
tee_state,
accept_requests: true,
Expand Down Expand Up @@ -1826,6 +2026,8 @@ impl MpcContract {
StorageKey::PendingVerifyForeignTxRequestsV2,
),
proposed_updates: Default::default(),
staged_uploads: IterableMap::new(StorageKey::StagedContractUploads),
staged_chunks: LookupMap::new(StorageKey::StagedContractChunks),
tee_state,
accept_requests: true,
node_migrations: NodeMigrations::default(),
Expand Down Expand Up @@ -4036,6 +4238,8 @@ mod tests {
),
accept_requests: true,
proposed_updates: Default::default(),
staged_uploads: IterableMap::new(StorageKey::StagedContractUploads),
staged_chunks: LookupMap::new(StorageKey::StagedContractChunks),
node_foreign_chain_support: Default::default(),
config: Default::default(),
tee_state: Default::default(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,14 @@ BorshSchemaContainer {
"proposed_updates",
"ProposedUpdates",
),
(
"staged_uploads",
"IterableMap",
),
(
"staged_chunks",
"LookupMap",
),
(
"node_foreign_chain_support",
"SupportedForeignChainsByNode",
Expand Down
4 changes: 4 additions & 0 deletions crates/contract/src/storage_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@ pub enum StorageKey {
AllowedForeignChainProvidersV1,
ForeignChainProviderVotesByVoterV1,
ForeignChainProviderVotesByProposalV1,
/// Per-account `StagedContractUpload` metadata.
StagedContractUploads,
/// Chunk bytes belonging to in-progress uploads, keyed by `(AccountId, chunk_index)`.
StagedContractChunks,
}
Loading
Loading