templar_registry_contract/
lib.rs

1#![allow(clippy::needless_pass_by_value)]
2
3mod state;
4
5use std::ops::{Deref, DerefMut};
6
7use near_sdk::{
8    assert_one_yocto, env,
9    json_types::{Base58CryptoHash, Base64VecU8},
10    near, require, AccountId, CryptoHash, Gas, NearToken, PanicOnDefault, Promise, PromiseOrValue,
11};
12use near_sdk_contract_tools::{owner::Owner, Owner};
13use templar_common::{
14    contract::list,
15    registry::{Deployment, RegistryEntryView, VersionAvailability, VersionInfo, VersionSource},
16    self_ext,
17    upgrade::{UpgradeSource, MIGRATE_METHOD},
18    versioned_state::{impl_versioned_state, StateVersion, VersionedState},
19};
20
21type State = state::V1;
22
23#[derive(Debug, Clone)]
24#[near(serializers = [json, borsh])]
25pub enum VersionEntry {
26    Code {
27        hash: CryptoHash,
28        code: Option<Vec<u8>>,
29    },
30    GlobalHash(CryptoHash),
31}
32
33impl VersionEntry {
34    pub fn code_hash(&self) -> CryptoHash {
35        match self {
36            Self::Code { hash, .. } | Self::GlobalHash(hash) => *hash,
37        }
38    }
39
40    fn availability(&self) -> VersionAvailability {
41        match self {
42            Self::Code {
43                code: Some(code), ..
44            } => VersionAvailability::Stored {
45                // Unreachable: a stored value cannot exceed the 4 MiB storage-value limit.
46                code_len: u32::try_from(code.len()).unwrap_or(u32::MAX),
47            },
48            Self::Code { code: None, .. } => VersionAvailability::Removed,
49            Self::GlobalHash(_) => VersionAvailability::Global,
50        }
51    }
52}
53
54#[derive(Debug, Clone)]
55#[near(serializers = [borsh])]
56pub enum RegistryEntry {
57    Reserved,
58    Deployed(Deployment),
59}
60
61#[derive(PanicOnDefault, Owner)]
62#[near(contract_state)]
63pub struct Contract {
64    pub state: VersionedState<State>,
65}
66
67// Generates the private `migrate()` entrypoint plus the `get_stored_state_version` /
68// `get_target_state_version` / `needs_migration` views.
69impl_versioned_state!(Contract, State, crate::state::Migration);
70
71// `versions` and `registry` live on `State`; deref so the methods below keep reaching them
72// directly.
73impl Deref for Contract {
74    type Target = State;
75
76    fn deref(&self) -> &Self::Target {
77        &self.state
78    }
79}
80
81impl DerefMut for Contract {
82    fn deref_mut(&mut self) -> &mut Self::Target {
83        &mut self.state
84    }
85}
86
87#[near]
88impl Contract {
89    /// Gas reserved for the batched `migrate` in [`Self::upgrade`].
90    ///
91    /// Enough for any migration this method can be asked to run. The expensive one — rewriting
92    /// every stored version blob out of a pre-1.1.0 layout, several MB on the live registries —
93    /// is not among them: those releases have no `upgrade`, so they are migrated by a batch their
94    /// key holder signs, where the gas is on the transaction rather than reserved here.
95    pub const GAS_FOR_MIGRATE: Gas = Gas::from_tgas(250);
96
97    /// Most a single [`Self::get_version_code_chunk`] will return.
98    ///
99    /// A view's return value reaches the caller as a JSON array of one integer per byte, so the
100    /// response body runs several times the payload; this keeps it inside what RPC providers serve.
101    pub const MAX_CODE_CHUNK_LEN: u32 = 128 * 1024;
102
103    #[init]
104    pub fn new() -> Self {
105        let mut self_ = Self {
106            state: State::new(()),
107        };
108
109        self_.init(&env::predecessor_account_id());
110
111        self_
112    }
113
114    /// Atomically deploy new code and run its `migrate` in one receipt, so a failed migration
115    /// reverts the deploy with it. `migrate_args` selects the `state::Migration` matching the
116    /// layout this registry actually holds.
117    ///
118    /// The only way to replace the code of a registry whose full-access keys have been removed.
119    #[payable]
120    pub fn upgrade(&mut self, code: UpgradeSource, migrate_args: Base64VecU8) -> Promise {
121        assert_one_yocto();
122        self.assert_owner();
123
124        require!(!code.is_empty_code(), "Upgrade code must not be empty");
125
126        near_sdk::log!("Upgrading registry to {:?}", code.summary());
127
128        code.deploy_and_migrate(MIGRATE_METHOD, migrate_args, Self::GAS_FOR_MIGRATE)
129    }
130
131    pub fn list_versions(&self, count: Option<u32>, offset: Option<u32>) -> Vec<&String> {
132        list(self.versions.keys(), offset, count)
133    }
134
135    pub fn get_version_code_hash(&self, version_key: String) -> Option<Base58CryptoHash> {
136        self.versions
137            .get(&version_key)
138            .map(VersionEntry::code_hash)
139            .map(Into::into)
140    }
141
142    /// A registered version's hash and whether [`Self::deploy`] can still use it.
143    ///
144    /// `list_versions` keeps listing a version whose code `remove_version` cleared, and
145    /// `get_version_code_hash` keeps answering for it, so membership alone cannot tell a
146    /// deployable version from one that will panic partway through a deploy.
147    pub fn get_version(&self, version_key: String) -> Option<VersionInfo> {
148        self.versions.get(&version_key).map(|entry| VersionInfo {
149            code_hash: entry.code_hash().into(),
150            availability: entry.availability(),
151        })
152    }
153
154    /// Whether a name is taken, and by what.
155    ///
156    /// [`Self::deploy`] refuses any name already present, so a `Reserved` name is as unusable as a
157    /// deployed one — a state [`Self::get_deployment`] reports as absent.
158    pub fn get_registry_entry(&self, account_id: AccountId) -> Option<RegistryEntryView> {
159        self.registry.get(&account_id).map(|entry| match entry {
160            RegistryEntry::Reserved => RegistryEntryView::Reserved,
161            RegistryEntry::Deployed(deployment) => RegistryEntryView::Deployed(deployment.clone()),
162        })
163    }
164
165    /// Bytes `[offset, offset + len)` of a version's stored code, or `None` if it has none.
166    ///
167    /// Borsh-serialized and chunked: the JSON encoding would base64 the blob and the RPC would
168    /// then re-expand it to one integer per byte. Reading a whole contract back is the last
169    /// resort anyway — [`Self::get_version`] reports the hash, which usually resolves against a
170    /// released artifact without touching this at all.
171    ///
172    /// An `offset` past the end reads empty, so a caller can loop until it stops making progress.
173    #[result_serializer(borsh)]
174    pub fn get_version_code_chunk(
175        &self,
176        version_key: String,
177        offset: u32,
178        len: u32,
179    ) -> Option<Vec<u8>> {
180        require!(
181            len <= Self::MAX_CODE_CHUNK_LEN,
182            format!("len exceeds maximum of {}", Self::MAX_CODE_CHUNK_LEN),
183        );
184
185        let code = match self.versions.get(&version_key)? {
186            VersionEntry::Code { code, .. } => code.as_ref()?,
187            VersionEntry::GlobalHash(_) => return None,
188        };
189
190        let start = (offset as usize).min(code.len());
191        let end = start.saturating_add(len as usize).min(code.len());
192
193        Some(code[start..end].to_vec())
194    }
195
196    pub fn list_deployments(&self, count: Option<u32>, offset: Option<u32>) -> Vec<&AccountId> {
197        list(
198            self.registry
199                .iter()
200                .filter(|(_, e)| matches!(e, RegistryEntry::Deployed(_)))
201                .map(|(a, _)| a),
202            offset,
203            count,
204        )
205    }
206
207    pub fn get_deployment(&self, account_id: AccountId) -> Option<&Deployment> {
208        self.registry.get(&account_id).and_then(|e| match e {
209            RegistryEntry::Reserved => None,
210            RegistryEntry::Deployed(deployment) => Some(deployment),
211        })
212    }
213
214    /// Open a throwaway sub-account funded by the attached deposit, for a caller to act on and
215    /// then `delete_account` back into the registry.
216    ///
217    /// `prefix` separates the publish and probe accounts so a probe cannot land on an in-flight
218    /// publish.
219    fn scratch_account(prefix: &str) -> Promise {
220        let account_id: AccountId = format!("{prefix}.{}", env::current_account_id())
221            .parse()
222            .unwrap_or_else(|_| {
223                templar_common::panic_with_message("Failed to construct scratch account ID.")
224            });
225
226        Promise::new(account_id)
227            .create_account()
228            .transfer(env::attached_deposit())
229    }
230
231    #[payable]
232    pub fn add_version(
233        &mut self,
234        #[serializer(borsh)] version_key: String,
235        #[serializer(borsh)] source: VersionSource,
236    ) -> PromiseOrValue<()> {
237        self.assert_owner();
238        require!(
239            !self.versions.contains_key(&version_key),
240            "Version key already exists",
241        );
242
243        match source {
244            VersionSource::Stored(code) => {
245                assert_one_yocto();
246                let version_entry = VersionEntry::Code {
247                    hash: env::sha256_array(&code.0),
248                    code: Some(code.0),
249                };
250                self.versions.insert(version_key, version_entry);
251                PromiseOrValue::Value(())
252            }
253            VersionSource::PublishGlobal(code) => {
254                require!(
255                    !env::attached_deposit().is_zero(),
256                    "Deposit required to pay for global contract deployment",
257                );
258                let version_entry = VersionEntry::GlobalHash(env::sha256_array(&code.0));
259                self.versions.insert(version_key.clone(), version_entry);
260                PromiseOrValue::Promise(
261                    Self::scratch_account("deploy")
262                        .deploy_global_contract(code.0)
263                        .delete_account(env::current_account_id())
264                        .then(self_ext!(Gas::from_tgas(6)).add_version_01_finalize(version_key)),
265                )
266            }
267            // The hash is verified, not trusted: a hash with no global contract behind it fails
268            // this receipt and `add_version_01_finalize` frees the key again. Without the probe an
269            // unverified typo would burn the key permanently, since `remove_version` panics on a
270            // `GlobalHash` entry.
271            VersionSource::ExistingGlobal(hash) => {
272                // One yocto, as for `Stored`: the probe account is created and deleted inside a
273                // single receipt, so it never has to satisfy a storage-staking minimum, and
274                // `use_global_contract` copies no code to stake for. The cost here is gas.
275                assert_one_yocto();
276                self.versions
277                    .insert(version_key.clone(), VersionEntry::GlobalHash(hash.into()));
278                PromiseOrValue::Promise(
279                    Self::scratch_account("probe")
280                        .use_global_contract(hash)
281                        .delete_account(env::current_account_id())
282                        .then(self_ext!(Gas::from_tgas(6)).add_version_01_finalize(version_key)),
283                )
284            }
285        }
286    }
287
288    #[private]
289    pub fn add_version_01_finalize(&mut self, version_key: String) -> PromiseOrValue<()> {
290        let result = env::promise_result_checked(0, 0x1000);
291        if result.is_ok() {
292            PromiseOrValue::Value(())
293        } else {
294            self.versions.remove(&version_key);
295            PromiseOrValue::Promise(
296                self_ext!(Gas::from_tgas(1)).fail("Failed to deploy global contract".to_string()),
297            )
298        }
299    }
300
301    #[payable]
302    pub fn remove_version(&mut self, version_key: String) {
303        assert_one_yocto();
304        self.assert_owner();
305
306        self.versions.entry(version_key).and_modify(|e| match e {
307            VersionEntry::Code { code, .. } => {
308                *code = None;
309            }
310            VersionEntry::GlobalHash(_) => {
311                templar_common::panic_with_message("Global contract cannot be removed")
312            }
313        });
314    }
315
316    #[payable]
317    pub fn deploy(
318        &mut self,
319        name: String,
320        version_key: String,
321        init_args: Base64VecU8,
322        full_access_keys: Option<Vec<near_sdk::PublicKey>>,
323    ) -> Promise {
324        require!(!name.is_empty(), "Name must not be empty");
325        self.assert_owner();
326
327        // Through `Deref` every field access borrows the whole contract, so the read of `versions`
328        // held across the write to `registry` needs them named as the disjoint fields they are.
329        let state = &mut *self.state;
330
331        let Some(version) = state.versions.get(&version_key) else {
332            templar_common::panic_with_message("Version key does not exist");
333        };
334
335        let attached_deposit = env::attached_deposit();
336
337        let current_account_id = env::current_account_id();
338        let market_id = format!("{name}.{current_account_id}");
339
340        let market_id: AccountId = market_id.parse().unwrap_or_else(|_| {
341            templar_common::panic_with_message("New market ID is not a valid account ID")
342        });
343
344        require!(
345            market_id.is_sub_account_of(&current_account_id),
346            "Market ID cannot be created",
347        );
348
349        require!(
350            !state.registry.contains_key(&market_id),
351            "Market ID collision",
352        );
353
354        state
355            .registry
356            .insert(market_id.clone(), RegistryEntry::Reserved);
357
358        near_sdk::log!("Deploying market to {market_id}");
359
360        let mut promise = Promise::new(market_id.clone())
361            .create_account()
362            .transfer(env::attached_deposit());
363
364        match version {
365            VersionEntry::Code { code, .. } => {
366                let code = code.as_ref().unwrap_or_else(|| {
367                    templar_common::panic_with_message("Version code has been deleted")
368                });
369
370                let minimum_deposit = env::storage_byte_cost().saturating_mul(code.len() as u128);
371
372                require!(
373                    attached_deposit >= minimum_deposit,
374                    format!("Insufficient deposit to pay for storage (minimum: {minimum_deposit})"),
375                );
376
377                promise = promise.deploy_contract(code.clone());
378            }
379            VersionEntry::GlobalHash(hash) => promise = promise.use_global_contract(*hash),
380        }
381
382        for key in full_access_keys.unwrap_or_default() {
383            near_sdk::log!(
384                "WARNING: Deploying market with full-access key {}",
385                String::from(&key),
386            );
387            promise = promise.add_full_access_key(key);
388        }
389
390        promise
391            .function_call_weight(
392                "new".to_string(),
393                init_args.0,
394                NearToken::from_near(0),
395                Gas::from_tgas(2),
396                near_sdk::GasWeight(20),
397            )
398            .then(
399                Self::ext(env::current_account_id())
400                    .with_unused_gas_weight(1)
401                    .with_static_gas(Gas::from_tgas(2))
402                    .deploy_01_finalize(
403                        market_id,
404                        Deployment {
405                            version_key,
406                            code_hash: version.code_hash().into(),
407                            block_height: env::block_height().into(),
408                        },
409                    ),
410            )
411    }
412
413    #[private]
414    pub fn deploy_01_finalize(
415        &mut self,
416        market_id: AccountId,
417        deployment: Deployment,
418    ) -> PromiseOrValue<AccountId> {
419        let successful = env::promise_result_checked(0, 0x1000).is_ok();
420
421        if successful {
422            self.registry
423                .insert(market_id.clone(), RegistryEntry::Deployed(deployment));
424
425            PromiseOrValue::Value(market_id)
426        } else {
427            self.registry.remove(&market_id);
428
429            PromiseOrValue::Promise(
430                Self::ext(env::current_account_id()).fail("Market deployment failed".to_string()),
431            )
432        }
433    }
434
435    #[private]
436    pub fn fail(&self, message: String) {
437        templar_common::panic_with_message(&message);
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use near_sdk::{
444        mock::MockAction,
445        test_utils::{get_created_receipts, VMContextBuilder},
446        testing_env,
447    };
448    use rstest::rstest;
449
450    use super::*;
451
452    const STORED: &str = "market@1.5.0";
453    const REMOVED: &str = "market@1.0.0";
454    const GLOBAL: &str = "oracle@0.4.1";
455
456    fn contract() -> Contract {
457        testing_env!(VMContextBuilder::new().build());
458        let mut contract = Contract::new();
459        contract.versions.insert(
460            STORED.to_string(),
461            VersionEntry::Code {
462                hash: [1u8; 32],
463                code: Some(vec![0xau8; 300]),
464            },
465        );
466        contract.versions.insert(
467            REMOVED.to_string(),
468            VersionEntry::Code {
469                hash: [2u8; 32],
470                code: None,
471            },
472        );
473        contract
474            .versions
475            .insert(GLOBAL.to_string(), VersionEntry::GlobalHash([3u8; 32]));
476        contract
477    }
478
479    #[rstest]
480    #[case(STORED, Some(VersionAvailability::Stored { code_len: 300 }))]
481    #[case(REMOVED, Some(VersionAvailability::Removed))]
482    #[case(GLOBAL, Some(VersionAvailability::Global))]
483    #[case("nothing@0.0.0", None)]
484    fn get_version_separates_all_four_states(
485        #[case] key: &str,
486        #[case] expected: Option<VersionAvailability>,
487    ) {
488        let info = contract().get_version(key.to_string());
489        assert_eq!(info.map(|info| info.availability), expected);
490    }
491
492    /// `get_deployment` maps a reserved name to `None`, which reads as "free" — but `deploy`
493    /// refuses it just as firmly as a deployed one.
494    #[test]
495    fn get_registry_entry_reports_reserved_that_get_deployment_hides() {
496        let mut contract = contract();
497        let reserved: AccountId = "reserved.registry.near".parse().unwrap();
498        let deployed: AccountId = "deployed.registry.near".parse().unwrap();
499        let deployment = Deployment {
500            version_key: STORED.to_string(),
501            code_hash: [1u8; 32].into(),
502            block_height: 1.into(),
503        };
504        contract
505            .registry
506            .insert(reserved.clone(), RegistryEntry::Reserved);
507        contract.registry.insert(
508            deployed.clone(),
509            RegistryEntry::Deployed(deployment.clone()),
510        );
511
512        assert_eq!(contract.get_deployment(reserved.clone()), None);
513        assert_eq!(
514            contract.get_registry_entry(reserved),
515            Some(RegistryEntryView::Reserved),
516        );
517        assert_eq!(
518            contract.get_registry_entry(deployed),
519            Some(RegistryEntryView::Deployed(deployment)),
520        );
521        assert_eq!(
522            contract.get_registry_entry("free.registry.near".parse().unwrap()),
523            None,
524        );
525    }
526
527    #[rstest]
528    #[case::whole(0, 300, 300)]
529    #[case::prefix(0, 10, 10)]
530    #[case::tail(290, 64, 10)]
531    #[case::past_the_end_reads_empty(300, 64, 0)]
532    #[case::far_past_the_end_reads_empty(9_999, 64, 0)]
533    fn code_chunk_clamps_to_the_blob(
534        #[case] offset: u32,
535        #[case] len: u32,
536        #[case] expected: usize,
537    ) {
538        let chunk = contract()
539            .get_version_code_chunk(STORED.to_string(), offset, len)
540            .expect("a stored version yields bytes");
541        assert_eq!(chunk.len(), expected);
542        assert!(chunk.iter().all(|byte| *byte == 0xau8));
543    }
544
545    /// Reassembly is the point: chunks concatenated in order must equal the stored blob.
546    #[test]
547    fn code_chunks_reassemble_exactly() {
548        let contract = contract();
549        let mut reassembled = Vec::new();
550        let mut offset = 0;
551        loop {
552            let chunk = contract
553                .get_version_code_chunk(STORED.to_string(), offset, 128)
554                .expect("a stored version yields bytes");
555            if chunk.is_empty() {
556                break;
557            }
558            offset += u32::try_from(chunk.len()).unwrap();
559            reassembled.extend(chunk);
560        }
561        assert_eq!(reassembled, vec![0xau8; 300]);
562    }
563
564    /// Neither a global version nor one whose code was removed has bytes to read, so the caller
565    /// has to resolve those by hash instead.
566    #[rstest]
567    #[case(GLOBAL)]
568    #[case(REMOVED)]
569    #[case("nothing@0.0.0")]
570    fn code_chunk_is_absent_without_stored_code(#[case] key: &str) {
571        assert_eq!(
572            contract().get_version_code_chunk(key.to_string(), 0, 64),
573            None,
574        );
575    }
576
577    #[test]
578    #[should_panic(expected = "len exceeds maximum")]
579    fn code_chunk_refuses_an_oversized_read() {
580        contract().get_version_code_chunk(STORED.to_string(), 0, Contract::MAX_CODE_CHUNK_LEN + 1);
581    }
582
583    /// Deploy and `migrate` must share one receipt, which is what makes a failed migration revert
584    /// the code with it. Two receipts would leave the new code deployed over unmigrated state.
585    #[test]
586    fn upgrade_batches_deploy_then_migrate_into_one_self_receipt() {
587        testing_env!(VMContextBuilder::new()
588            .current_account_id("registry.near".parse().unwrap())
589            .predecessor_account_id("registry.near".parse().unwrap())
590            .attached_deposit(NearToken::from_yoctonear(1))
591            .build());
592        let mut contract = Contract::new();
593        let code = vec![0xde, 0xad, 0xbe, 0xef];
594        let migrate_args = br#"{"from_version":"pre_global_contracts"}"#.to_vec();
595
596        contract
597            .upgrade(
598                UpgradeSource::Code(Base64VecU8(code.clone())),
599                Base64VecU8(migrate_args.clone()),
600            )
601            .detach();
602
603        let receipts = get_created_receipts();
604        assert_eq!(receipts.len(), 1, "the upgrade must not fan out");
605        let receipt = &receipts[0];
606        assert_eq!(receipt.receiver_id.as_str(), "registry.near");
607        assert_eq!(receipt.actions.len(), 2);
608
609        let receipt_index = match &receipt.actions[0] {
610            MockAction::DeployContract {
611                receipt_index,
612                code: deployed,
613            } => {
614                assert_eq!(deployed, &code);
615                *receipt_index
616            }
617            action => panic!("expected the deploy first, got {action:?}"),
618        };
619        match &receipt.actions[1] {
620            MockAction::FunctionCallWeight {
621                receipt_index: migrate_index,
622                method_name,
623                args,
624                prepaid_gas,
625                ..
626            } => {
627                assert_eq!(
628                    *migrate_index, receipt_index,
629                    "migrate must ride the deploy"
630                );
631                assert_eq!(method_name, b"migrate");
632                assert_eq!(args, &migrate_args);
633                assert_eq!(*prepaid_gas, Contract::GAS_FOR_MIGRATE);
634            }
635            action => panic!("expected the migrate second, got {action:?}"),
636        }
637    }
638
639    #[test]
640    #[should_panic(expected = "Requires attached deposit of exactly 1 yoctoNEAR")]
641    fn upgrade_requires_one_yocto() {
642        testing_env!(VMContextBuilder::new()
643            .current_account_id("registry.near".parse().unwrap())
644            .predecessor_account_id("registry.near".parse().unwrap())
645            .build());
646        let mut contract = Contract::new();
647        contract
648            .upgrade(
649                UpgradeSource::Code(Base64VecU8(vec![1, 2, 3])),
650                Base64VecU8(Vec::new()),
651            )
652            .detach();
653    }
654
655    #[test]
656    #[should_panic(expected = "Upgrade code must not be empty")]
657    fn upgrade_refuses_an_empty_blob() {
658        testing_env!(VMContextBuilder::new()
659            .current_account_id("registry.near".parse().unwrap())
660            .predecessor_account_id("registry.near".parse().unwrap())
661            .attached_deposit(NearToken::from_yoctonear(1))
662            .build());
663        let mut contract = Contract::new();
664        contract
665            .upgrade(
666                UpgradeSource::Code(Base64VecU8(Vec::new())),
667                Base64VecU8(Vec::new()),
668            )
669            .detach();
670    }
671}
672
673#[cfg(target_arch = "wasm32")]
674mod custom_getrandom {
675    #![allow(clippy::no_mangle_with_rust_abi)]
676
677    use getrandom::{register_custom_getrandom, Error};
678    use near_sdk::env;
679
680    register_custom_getrandom!(custom_getrandom);
681
682    #[allow(clippy::unnecessary_wraps)]
683    pub fn custom_getrandom(buf: &mut [u8]) -> Result<(), Error> {
684        buf.copy_from_slice(&env::random_seed_array());
685        Ok(())
686    }
687}