diff --git a/.metadata/polymesh_dev/7003000.meta b/.metadata/polymesh_dev/7003000.meta index 2c2164776d..cb18cb43fe 100644 Binary files a/.metadata/polymesh_dev/7003000.meta and b/.metadata/polymesh_dev/7003000.meta differ diff --git a/.metadata/polymesh_mainnet/7003000.meta b/.metadata/polymesh_mainnet/7003000.meta index a7db9ecfd9..f052c427d8 100644 Binary files a/.metadata/polymesh_mainnet/7003000.meta and b/.metadata/polymesh_mainnet/7003000.meta differ diff --git a/.metadata/polymesh_testnet/7003000.meta b/.metadata/polymesh_testnet/7003000.meta index 496d24301c..59424d29e2 100644 Binary files a/.metadata/polymesh_testnet/7003000.meta and b/.metadata/polymesh_testnet/7003000.meta differ diff --git a/integration/Cargo.lock b/integration/Cargo.lock index dcc40e87e0..c8d5eb3989 100644 --- a/integration/Cargo.lock +++ b/integration/Cargo.lock @@ -2324,6 +2324,7 @@ dependencies = [ "anyhow", "async-trait", "log", + "parity-scale-codec", "polymesh-api", "polymesh-api-client-extras", "polymesh-api-tester", diff --git a/integration/Cargo.toml b/integration/Cargo.toml index 166ae325ff..78c3bb03df 100644 --- a/integration/Cargo.toml +++ b/integration/Cargo.toml @@ -30,6 +30,7 @@ sp-core = "36.1" sp-runtime = "41.1" sp-keyring = "41.0" sp-weights = "31.1" +codec = { package = "parity-scale-codec", version = "3.0.0", features = ["derive", "max-encoded-len"] } polymesh-api = { version = "3.11.0", features = ["download_metadata"] } polymesh-api-client-extras = { version = "3.6.0" } diff --git a/integration/src/asset_helper.rs b/integration/src/asset_helper.rs new file mode 100644 index 0000000000..b17b3e7f27 --- /dev/null +++ b/integration/src/asset_helper.rs @@ -0,0 +1,218 @@ +use anyhow::Result; +use std::collections::BTreeSet; + +use polymesh_api::types::polymesh_primitives::{ + asset::{AssetName, AssetType}, + identity_id::{PortfolioId, PortfolioKind}, + settlement::{Leg, SettlementType, VenueDetails, VenueId, VenueType}, +}; + +use crate::*; + +/// Asset Helper. +pub struct AssetHelper { + pub api: Api, + pub asset_id: AssetId, + pub issuer: User, + pub issuer_venue_id: VenueId, + pub issuer_did: IdentityId, +} + +impl AssetHelper { + /// Create a new asset, mint some tokens, and pause compliance rules. + pub async fn new( + api: &Api, + issuer: &mut User, + name: &str, + mint: u128, + signers: Vec, + ) -> Result { + // Create a new venue. + let mut venue_res = api + .call() + .settlement() + .create_venue( + VenueDetails(format!("Venue for {name}").into()), + signers, + VenueType::Other, + )? + .submit_and_watch(issuer) + .await?; + + // Create a new asset. + let mut asset_res = api + .call() + .asset() + .create_asset( + AssetName(name.into()), + true, // Divisible token. + AssetType::EquityCommon, + vec![], + None, + )? + .submit_and_watch(issuer) + .await?; + + // Get the asset ID from the response. + let asset_id = get_asset_id(&mut asset_res) + .await? + .expect("Asset ID not found"); + + // Mint some tokens. + let mut mint_res = api + .call() + .asset() + .issue(asset_id, mint, PortfolioKind::Default)? + .submit_and_watch(issuer) + .await?; + + // Pause compliance rules to allow transfers. + let mut pause_res = api + .call() + .compliance_manager() + .pause_asset_compliance(asset_id)? + .submit_and_watch(issuer) + .await?; + + // Wait for mint and pause to complete. + mint_res.ok().await?; + pause_res.ok().await?; + + // Get the venue ID from the response. + let issuer_venue_id = get_venue_id(&mut venue_res) + .await? + .expect("Venue ID not found"); + + Ok(Self { + api: api.clone(), + asset_id, + issuer: issuer.clone(), + issuer_venue_id, + issuer_did: issuer.did.expect("Issuer DID"), + }) + } + + /// Mint some more tokens. + pub async fn mint(&mut self, amount: u128) -> Result<()> { + // Mint some tokens. + let mut mint_res = self + .api + .call() + .asset() + .issue(self.asset_id, amount, PortfolioKind::Default)? + .submit_and_watch(&mut self.issuer) + .await?; + + // Wait for mint to complete. + mint_res.ok().await?; + + Ok(()) + } + + /// Fund the investors portfolio with some tokens. + pub async fn fund_investors( + &mut self, + investors: &mut [&mut User], + amount: u128, + ) -> Result<()> { + // Make sure the asset issuer has enough tokens. + let total = amount * investors.len() as u128; + self.mint(total).await?; + + // Issuer portfolios. + let issuer_portfolio = PortfolioId { + did: self.issuer_did, + kind: PortfolioKind::Default, + }; + let issuer_portfolios = [issuer_portfolio].into_iter().collect::>(); + + let mut pending_settlements = Vec::new(); + for batch in investors.chunks_mut(10) { + let mut legs = Vec::new(); + for investor in batch.iter() { + // Get the investor DID. + let investor_did = investor.did.expect("Investor DID"); + + // User portfolios. + let investor_portfolio = PortfolioId { + did: investor_did, + kind: PortfolioKind::Default, + }; + + // Create a simple Settlement to transfer tokens from the issuer to the investor. + legs.push(Leg::Fungible { + sender: issuer_portfolio, + receiver: investor_portfolio, + asset_id: self.asset_id, + amount, + }); + } + let leg_count = legs.len() as u32; + + // Create a simple Settlement to transfer tokens from the issuer to the investors. + let mut settlement_res = self + .api + .call() + .settlement() + .add_and_affirm_instruction( + Some(self.issuer_venue_id), + SettlementType::SettleManual(0), + None, + None, + legs.clone(), + issuer_portfolios.clone(), + None, + )? + .submit_and_watch(&mut self.issuer) + .await?; + + // Get the settlement ID from the response. + let settlement_id = get_instruction_id(&mut settlement_res) + .await? + .expect("Settlement ID not found"); + + // The investors need to affirm the settlement. + let mut pending_affirms = Vec::new(); + for investor in batch.iter_mut() { + let affirm_res = self + .api + .call() + .settlement() + .affirm_instruction( + settlement_id, + vec![PortfolioId { + did: investor.did.expect("Investor DID"), + kind: PortfolioKind::Default, + }] + .into_iter() + .collect(), + )? + .submit_and_watch(*investor) + .await?; + pending_affirms.push(affirm_res); + } + + // Wait for investors affirmations to complete. + for mut affirm_res in pending_affirms { + affirm_res.ok().await?; + } + + // Execute the settlement + let execute_res = self + .api + .call() + .settlement() + .execute_manual_instruction(settlement_id, None, leg_count, 0, 0, None)? + .submit_and_watch(&mut self.issuer) + .await?; + pending_settlements.push(execute_res); + } + + // Wait for the settlements to complete. + for mut execute_res in pending_settlements { + execute_res.ok().await?; + } + + Ok(()) + } +} diff --git a/integration/src/lib.rs b/integration/src/lib.rs index 67b6d38d06..8ac590d911 100644 --- a/integration/src/lib.rs +++ b/integration/src/lib.rs @@ -14,6 +14,14 @@ use polymesh_api::*; use anyhow::{anyhow, Result}; +mod asset_helper; +pub use asset_helper::*; + +#[cfg(any(feature = "previous_release", feature = "current_release"))] +mod sto; +#[cfg(any(feature = "previous_release", feature = "current_release"))] +pub use sto::*; + pub async fn get_batch_results(res: &mut TransactionResults) -> Result> { let events = res .events() diff --git a/integration/src/sto.rs b/integration/src/sto.rs new file mode 100644 index 0000000000..c5bdd3bb0c --- /dev/null +++ b/integration/src/sto.rs @@ -0,0 +1,40 @@ +#[cfg(feature = "previous_release")] +use polymesh_api::types::pallet_sto::FundraiserId; +#[cfg(feature = "current_release")] +use polymesh_api::types::polymesh_primitives::sto::FundraiserId; + +use crate::*; + +/// Get Fundraiser ID from the transaction results. +#[cfg(feature = "previous_release")] +pub async fn get_fundraiser_id(res: &mut TransactionResults) -> Result> { + if let Some(events) = res.events().await? { + for rec in &events.0 { + match &rec.event { + RuntimeEvent::Sto(StoEvent::FundraiserCreated(_, fundraiser, ..)) => { + return Ok(Some(fundraiser.clone())); + } + _ => (), + } + } + } + Ok(None) +} + +/// Get Fundraiser ID from the transaction results. +#[cfg(feature = "current_release")] +pub async fn get_fundraiser_id( + res: &mut TransactionResults, +) -> Result> { + if let Some(events) = res.events().await? { + for rec in &events.0 { + match &rec.event { + RuntimeEvent::Sto(StoEvent::FundraiserCreated { offering_asset, fundraiser_id, .. }) => { + return Ok(Some((*offering_asset, fundraiser_id.clone()))); + } + _ => (), + } + } + } + Ok(None) +} diff --git a/integration/tests/settlements.rs b/integration/tests/settlements.rs index 4b25904fa3..23c0aa66e4 100644 --- a/integration/tests/settlements.rs +++ b/integration/tests/settlements.rs @@ -1,223 +1,12 @@ use anyhow::Result; -use std::collections::BTreeSet; use integration::*; use polymesh_api::types::polymesh_primitives::{ asset::{AssetName, AssetType}, identity_id::{PortfolioId, PortfolioKind}, - settlement::{Leg, LegId, ReceiptDetails, SettlementType, VenueDetails, VenueId, VenueType}, + settlement::{Leg, LegId, ReceiptDetails, SettlementType, VenueDetails, VenueType}, }; -/// Asset Helper. -pub struct AssetHelper { - pub api: Api, - pub asset_id: AssetId, - pub issuer: User, - pub issuer_venue_id: VenueId, - pub issuer_did: IdentityId, -} - -impl AssetHelper { - /// Create a new asset, mint some tokens, and pause compliance rules. - pub async fn new( - api: &Api, - issuer: &mut User, - name: &str, - mint: u128, - signers: Vec, - ) -> Result { - // Create a new venue. - let mut venue_res = api - .call() - .settlement() - .create_venue( - VenueDetails(format!("Venue for {name}").into()), - signers, - VenueType::Other, - )? - .submit_and_watch(issuer) - .await?; - - // Create a new asset. - let mut asset_res = api - .call() - .asset() - .create_asset( - AssetName(name.into()), - true, // Divisible token. - AssetType::EquityCommon, - vec![], - None, - )? - .submit_and_watch(issuer) - .await?; - - // Get the asset ID from the response. - let asset_id = get_asset_id(&mut asset_res) - .await? - .expect("Asset ID not found"); - - // Mint some tokens. - let mut mint_res = api - .call() - .asset() - .issue(asset_id, mint * ONE_POLYX, PortfolioKind::Default)? - .submit_and_watch(issuer) - .await?; - - // Pause compliance rules to allow transfers. - let mut pause_res = api - .call() - .compliance_manager() - .pause_asset_compliance(asset_id)? - .submit_and_watch(issuer) - .await?; - - // Wait for mint and pause to complete. - mint_res.ok().await?; - pause_res.ok().await?; - - // Get the venue ID from the response. - let issuer_venue_id = get_venue_id(&mut venue_res) - .await? - .expect("Venue ID not found"); - - Ok(Self { - api: api.clone(), - asset_id, - issuer: issuer.clone(), - issuer_venue_id, - issuer_did: issuer.did.expect("Issuer DID"), - }) - } - - /// Mint some more tokens. - pub async fn mint(&mut self, amount: u128) -> Result<()> { - // Mint some tokens. - let mut mint_res = self - .api - .call() - .asset() - .issue(self.asset_id, amount * ONE_POLYX, PortfolioKind::Default)? - .submit_and_watch(&mut self.issuer) - .await?; - - // Wait for mint to complete. - mint_res.ok().await?; - - Ok(()) - } - - /// Fund the investors portfolio with some tokens. - pub async fn fund_investors( - &mut self, - investors: &mut [&mut User], - amount: u128, - ) -> Result<()> { - let amount: u128 = amount * ONE_POLYX; - - // Make sure the asset issuer has enough tokens. - let total = amount * investors.len() as u128; - self.mint(total).await?; - - // Issuer portfolios. - let issuer_portfolio = PortfolioId { - did: self.issuer_did, - kind: PortfolioKind::Default, - }; - let issuer_portfolios = [issuer_portfolio].into_iter().collect::>(); - - let mut pending_settlements = Vec::new(); - for batch in investors.chunks_mut(10) { - let mut legs = Vec::new(); - for investor in batch.iter() { - // Get the investor DID. - let investor_did = investor.did.expect("Investor DID"); - - // User portfolios. - let investor_portfolio = PortfolioId { - did: investor_did, - kind: PortfolioKind::Default, - }; - - // Create a simple Settlement to transfer tokens from the issuer to the investor. - legs.push(Leg::Fungible { - sender: issuer_portfolio, - receiver: investor_portfolio, - asset_id: self.asset_id, - amount, - }); - } - let leg_count = legs.len() as u32; - - // Create a simple Settlement to transfer tokens from the issuer to the investors. - let mut settlement_res = self - .api - .call() - .settlement() - .add_and_affirm_instruction( - Some(self.issuer_venue_id), - SettlementType::SettleManual(0), - None, - None, - legs.clone(), - issuer_portfolios.clone(), - None, - )? - .submit_and_watch(&mut self.issuer) - .await?; - - // Get the settlement ID from the response. - let settlement_id = get_instruction_id(&mut settlement_res) - .await? - .expect("Settlement ID not found"); - - // The investors need to affirm the settlement. - let mut pending_affirms = Vec::new(); - for investor in batch.iter_mut() { - let affirm_res = self - .api - .call() - .settlement() - .affirm_instruction( - settlement_id, - vec![PortfolioId { - did: investor.did.expect("Investor DID"), - kind: PortfolioKind::Default, - }] - .into_iter() - .collect(), - )? - .submit_and_watch(*investor) - .await?; - pending_affirms.push(affirm_res); - } - - // Wait for investors affirmations to complete. - for mut affirm_res in pending_affirms { - affirm_res.ok().await?; - } - - // Execute the settlement - let execute_res = self - .api - .call() - .settlement() - .execute_manual_instruction(settlement_id, None, leg_count, 0, 0, None)? - .submit_and_watch(&mut self.issuer) - .await?; - pending_settlements.push(execute_res); - } - - // Wait for the settlements to complete. - for mut execute_res in pending_settlements { - execute_res.ok().await?; - } - - Ok(()) - } -} - /// Test the asset helper. #[tokio::test] async fn asset_helper() -> Result<()> { @@ -412,7 +201,7 @@ async fn offchain_settlement() -> Result<()> { &tester.api, &mut venue, "TestAsset", - 1_000_000, + 1_000_000_000, vec![signer1.account()], ) .await?; diff --git a/integration/tests/sto.rs b/integration/tests/sto.rs new file mode 100644 index 0000000000..74e4ba5d91 --- /dev/null +++ b/integration/tests/sto.rs @@ -0,0 +1,496 @@ +// <=v7.2 +#[cfg(feature = "previous_release")] +mod sto_tests { + use anyhow::Result; + use codec::{Decode, Encode}; + + use integration::*; + use polymesh_api::types::pallet_sto::{FundraiserId, FundraiserName, PriceTier}; + use polymesh_api::types::polymesh_primitives::{ + identity_id::{PortfolioId, PortfolioKind}, + settlement::{VenueDetails, VenueType}, + }; + + /// An offchain fundraiser receipt. + #[derive(Encode, Decode, Clone, Debug)] + pub struct FundraiserReceipt { + uid: u64, + fundraiser_id: FundraiserId, + sender_identity: IdentityId, + receiver_identity: IdentityId, + ticker: Ticker, + amount: u128, + } + + /// Test a STO with onchain asset funding. + #[tokio::test] + async fn sto_onchain_funding() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["VenueUser1", "Investor1"]) + .await? + .into_iter(); + let mut venue = users.next().expect("Venue user"); + let mut investor1 = users.next().expect("Investor1"); + + // Create a new venue. + let mut v = venue.clone(); + let mut sto_venue_res = tester + .api + .call() + .settlement() + .create_venue( + VenueDetails(format!("Venue for STO").into()), + vec![], + VenueType::Sto, + )? + .submit_and_watch(&mut v) + .await?; + + // Create two assets one as the offering asset and one as the funding asset. + let mut v = venue.clone(); + let api = tester.api.clone(); + let offering_asset = tokio::spawn(async move { + // Mint 10,000.0 tokens. + AssetHelper::new(&api, &mut v, "TestOfferingAsset", 10_000_000_000, vec![]).await + }); + let mut v = venue.clone(); + let api = tester.api.clone(); + let mut investor = investor1.clone(); + let funding_asset = tokio::spawn(async move { + let mut funding_asset = + AssetHelper::new(&api, &mut v, "TestFundingCoin", 1_000_000, vec![]).await?; + + // Give 1,000.0 funds to the investor. + funding_asset + .fund_investors(&mut [&mut investor], 1_000_000_000) + .await?; + + Ok::<_, anyhow::Error>(funding_asset) + }); + + // Wait for the assets to be created. + let offering_asset = offering_asset.await??; + let funding_asset = funding_asset.await??; + let venue_did = offering_asset.issuer_did; + + // Get the DIDs of the users. + let investor1_did = investor1.did.expect("Investor 1 DID"); + + // STO fundraiser portfolios. + let fundraiser_portfolio = PortfolioId { + did: venue_did, + kind: PortfolioKind::Default, + }; + let investor_portfolio = PortfolioId { + did: investor1_did, + kind: PortfolioKind::Default, + }; + + // Get the venue ID from the response. + let sto_venue_id = get_venue_id(&mut sto_venue_res) + .await? + .expect("STO Venue ID not found"); + + // Create the fundraiser using the STO pallet. + let mut fundraiser_res = tester + .api + .call() + .sto() + .create_fundraiser( + fundraiser_portfolio, + offering_asset.asset_id, + fundraiser_portfolio, + funding_asset.asset_id, + vec![ + PriceTier { + total: 1_000_000_000, // 1,000.0 tokens. + price: 800_000, // 1 offering token = 0.8 funding token + }, + PriceTier { + total: 1_000_000_000, // 1,000.0 tokens. + price: 1_600_000, // 1 offering token = 1.6 funding token + }, + PriceTier { + total: 1_000_000_000, // 1,000.0 tokens. + price: 2_400_000, // 1 offering token = 2.4 funding token + }, + ], + sto_venue_id, + None, + None, + 1_000_000u128, + FundraiserName("TestFundraiser".into()), + )? + .submit_and_watch(&mut venue) + .await?; + + // Get the fundraiser ID from the response. + let fundraiser_id = get_fundraiser_id(&mut fundraiser_res) + .await? + .expect("Fundraiser ID not found"); + + // Invest in the fundraiser using onchain asset funding. + let mut invest_res = tester + .api + .call() + .sto() + .invest( + investor_portfolio, + investor_portfolio, + offering_asset.asset_id, + fundraiser_id, + 1_050_000_000, // 1,050.0 tokens (avg price 0.838095 funding tokens per offering token). + Some(1_000_000), // pay a maximum of 1.0 funding tokens per offering token. + None, + )? + .submit_and_watch(&mut investor1) + .await?; + + // Wait for the investment to be processed. + invest_res.ok().await?; + + Ok(()) + } +} + +// >=v7.3 +#[cfg(feature = "current_release")] +mod sto_tests { + use anyhow::Result; + use codec::{Decode, Encode}; + + use integration::*; + use polymesh_api::types::pallet_sto::{FundingMethod, FundraiserName, PriceTier}; + use polymesh_api::types::polymesh_primitives::{ + identity_id::{PortfolioId, PortfolioKind}, + settlement::{VenueDetails, VenueType}, + sto::{FundraiserId, FundraiserReceiptDetails}, + }; + + /// An offchain fundraiser receipt. + #[derive(Encode, Decode, Clone, Debug)] + pub struct FundraiserReceipt { + uid: u64, + fundraiser_id: FundraiserId, + sender_identity: IdentityId, + receiver_identity: IdentityId, + ticker: Ticker, + amount: u128, + } + + /// Test a STO with onchain asset funding. + #[tokio::test] + async fn sto_onchain_funding() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["VenueUser1", "Investor1"]) + .await? + .into_iter(); + let mut venue = users.next().expect("Venue user"); + let mut investor1 = users.next().expect("Investor1"); + + // Create a new venue. + let mut v = venue.clone(); + let mut sto_venue_res = tester + .api + .call() + .settlement() + .create_venue( + VenueDetails(format!("Venue for STO").into()), + vec![], + VenueType::Sto, + )? + .submit_and_watch(&mut v) + .await?; + + // Create two assets one as the offering asset and one as the funding asset. + let mut v = venue.clone(); + let api = tester.api.clone(); + let offering_asset = tokio::spawn(async move { + // Mint 10,000.0 tokens. + AssetHelper::new(&api, &mut v, "TestOfferingAsset", 10_000_000_000, vec![]).await + }); + let mut v = venue.clone(); + let api = tester.api.clone(); + let mut investor = investor1.clone(); + let funding_asset = tokio::spawn(async move { + let mut funding_asset = + AssetHelper::new(&api, &mut v, "TestFundingCoin", 1_000_000, vec![]).await?; + + // Give 1,000.0 funds to the investor. + funding_asset + .fund_investors(&mut [&mut investor], 1_000_000_000) + .await?; + + Ok::<_, anyhow::Error>(funding_asset) + }); + + // Wait for the assets to be created. + let offering_asset = offering_asset.await??; + let funding_asset = funding_asset.await??; + let venue_did = offering_asset.issuer_did; + + // Get the DIDs of the users. + let investor1_did = investor1.did.expect("Investor 1 DID"); + + // STO fundraiser portfolios. + let fundraiser_portfolio = PortfolioId { + did: venue_did, + kind: PortfolioKind::Default, + }; + let investor_portfolio = PortfolioId { + did: investor1_did, + kind: PortfolioKind::Default, + }; + + // Get the venue ID from the response. + let sto_venue_id = get_venue_id(&mut sto_venue_res) + .await? + .expect("STO Venue ID not found"); + + // Create the fundraiser using the STO pallet. + let mut fundraiser_res = tester + .api + .call() + .sto() + .create_fundraiser( + fundraiser_portfolio, + offering_asset.asset_id, + fundraiser_portfolio, + funding_asset.asset_id, + vec![ + PriceTier { + total: 1_000_000_000, // 1,000.0 tokens. + price: 800_000, // 1 offering token = 0.8 funding token + }, + PriceTier { + total: 1_000_000_000, // 1,000.0 tokens. + price: 1_600_000, // 1 offering token = 1.6 funding token + }, + PriceTier { + total: 1_000_000_000, // 1,000.0 tokens. + price: 2_400_000, // 1 offering token = 2.4 funding token + }, + ], + sto_venue_id, + None, + None, + 1_000_000u128, + FundraiserName("TestFundraiser".into()), + )? + .submit_and_watch(&mut venue) + .await?; + + // Get the fundraiser ID from the response. + let (_, fundraiser_id) = get_fundraiser_id(&mut fundraiser_res) + .await? + .expect("Fundraiser ID not found"); + + // Invest in the fundraiser using onchain asset funding. + let mut invest_res = tester + .api + .call() + .sto() + .invest( + offering_asset.asset_id, + fundraiser_id, + investor_portfolio, + FundingMethod::OnChain(investor_portfolio), + 1_050_000_000, // 1,050.0 tokens (avg price 0.838095 funding tokens per offering token). + Some(900_000), // pay a maximum of 0.9 funding tokens per offering token. + )? + .submit_and_watch(&mut investor1) + .await?; + + // Wait for the investment to be processed. + invest_res.ok().await?; + + Ok(()) + } + + /// Test STO with offchain asset funding. + #[tokio::test] + async fn sto_offchain_funding() -> Result<()> { + let mut tester = PolymeshTester::new().await?; + let mut users = tester + .users(&["VenueUser1", "VenueSigner1", "Investor1"]) + .await? + .into_iter(); + let mut venue = users.next().expect("Venue user"); + let signer1 = users.next().expect("Venue signer 1"); + let mut investor1 = users.next().expect("Investor1"); + + // Create a new venue. + let mut v = venue.clone(); + let mut sto_venue_res = tester + .api + .call() + .settlement() + .create_venue( + VenueDetails(format!("Venue for STO").into()), + vec![signer1.account()], + VenueType::Sto, + )? + .submit_and_watch(&mut v) + .await?; + + // Create two assets one as the offering asset and one as the funding asset. + let mut v = venue.clone(); + let api = tester.api.clone(); + let offering_asset = tokio::spawn(async move { + // Mint 10,000.0 tokens. + AssetHelper::new(&api, &mut v, "TestOfferingAsset", 10_000_000_000, vec![]).await + }); + let mut v = venue.clone(); + let api = tester.api.clone(); + let mut investor = investor1.clone(); + let funding_asset = tokio::spawn(async move { + let mut funding_asset = + AssetHelper::new(&api, &mut v, "TestFundingCoin", 1_000_000, vec![]).await?; + + // Give 3,000.0 funds to the investor. + funding_asset + .fund_investors(&mut [&mut investor], 3_000_000_000) + .await?; + + Ok::<_, anyhow::Error>(funding_asset) + }); + + // Wait for the assets to be created. + let offering_asset = offering_asset.await??; + let funding_asset = funding_asset.await??; + let venue_did = offering_asset.issuer_did; + + // A ticker for the offchain asset. + let ticker = Ticker(*b"OFFCHAIN0000"); + + // Get the DIDs of the users. + let investor1_did = investor1.did.expect("Investor 1 DID"); + + // STO fundraiser portfolios. + let fundraiser_portfolio = PortfolioId { + did: venue_did, + kind: PortfolioKind::Default, + }; + let investor_portfolio = PortfolioId { + did: investor1_did, + kind: PortfolioKind::Default, + }; + + // Get the venue ID from the response. + let sto_venue_id = get_venue_id(&mut sto_venue_res) + .await? + .expect("STO Venue ID not found"); + + // Create the fundraiser using the STO pallet. + let mut fundraiser_res = tester + .api + .call() + .sto() + .create_fundraiser( + fundraiser_portfolio, + offering_asset.asset_id, + fundraiser_portfolio, + funding_asset.asset_id, + vec![ + PriceTier { + total: 1_000_000_000, // 1,000.0 tokens. + price: 800_000, // 1 offering token = 0.8 funding token + }, + PriceTier { + total: 1_000_000_000, // 1,000.0 tokens. + price: 1_600_000, // 1 offering token = 1.6 funding token + }, + PriceTier { + total: 1_000_000_000, // 1,000.0 tokens. + price: 2_400_000, // 1 offering token = 2.4 funding token + }, + ], + sto_venue_id, + None, + None, + 1_000_000u128, + FundraiserName("TestFundraiser".into()), + )? + .submit_and_watch(&mut venue) + .await?; + + // Get the fundraiser ID from the response. + let (_, fundraiser_id) = get_fundraiser_id(&mut fundraiser_res) + .await? + .expect("Fundraiser ID not found"); + + // Enable offchain asset funding for the venue. + let mut enable_offchain_funding_res = tester + .api + .call() + .sto() + .enable_offchain_funding(offering_asset.asset_id, fundraiser_id.clone(), ticker)? + .submit_and_watch(&mut venue) + .await?; + + let offchain_purchase_amount = 2_000_000_000u128; // 2,000 tokens (avg price 1.2 funding tokens per offering token). + let offchain_funding_amount = 2_400_000_000u128; + let onchain_purchase_amount = 1_000_000_000u128; // 1,000 tokens (avg price 2.4 funding tokens per offering token). + + // Create a receipt for the offchain asset funding. + let uid = 0u64; + let receipt = FundraiserReceipt { + uid, + fundraiser_id: fundraiser_id.clone(), + sender_identity: investor1_did, + receiver_identity: venue_did, + ticker, + amount: offchain_funding_amount, + }; + let sig = sign_with_key(&signer1, &receipt, false).await?; + let receipt_details = FundraiserReceiptDetails { + uid, + signer: signer1.account(), + signature: sig, + metadata: None, + }; + + // Ensure the venue has the offchain asset funding enabled. + enable_offchain_funding_res.ok().await?; + + // Invest in the fundraiser using offchain asset funding. + let mut offchain_invest_res = tester + .api + .call() + .sto() + .invest( + offering_asset.asset_id, + fundraiser_id.clone(), + investor_portfolio, + FundingMethod::OffChain(receipt_details), + offchain_purchase_amount, + Some(1_300_000), // pay a maximum of 1.3 funding tokens per offering token. + )? + .submit_and_watch(&mut investor1) + .await?; + + // Also invest in the fundraiser using onchain asset funding. + let mut invest_res = tester + .api + .call() + .sto() + .invest( + offering_asset.asset_id, + fundraiser_id, + investor_portfolio, + FundingMethod::OnChain(investor_portfolio), + onchain_purchase_amount, + Some(2_400_000), // pay a maximum of 2.4 funding tokens per offering token. + )? + .submit_and_watch(&mut investor1) + .await?; + + // Wait for the investments to be processed. + offchain_invest_res.ok().await?; + invest_res.ok().await?; + + Ok(()) + } +} diff --git a/pallets/runtime/tests/src/sto_test.rs b/pallets/runtime/tests/src/sto_test.rs index fc362d686d..c5ebec262d 100644 --- a/pallets/runtime/tests/src/sto_test.rs +++ b/pallets/runtime/tests/src/sto_test.rs @@ -5,12 +5,13 @@ use sp_runtime::DispatchError; use pallet_asset::BalanceOf; use pallet_settlement::{InstructionCounter, InstructionStatuses, VenueCounter}; use pallet_sto::{ - Fundraiser, FundraiserCount, FundraiserId, FundraiserName, FundraiserNames, FundraiserStatus, + FundingMethod, Fundraiser, FundraiserCount, FundraiserName, FundraiserNames, FundraiserStatus, FundraiserTier, Fundraisers, PriceTier, MAX_TIERS, }; use polymesh_primitives::asset::AssetId; use polymesh_primitives::checked_inc::CheckedInc; use polymesh_primitives::settlement::{InstructionStatus, VenueDetails, VenueId, VenueType}; +use polymesh_primitives::sto::FundraiserId; use polymesh_primitives::{IdentityId, PortfolioId, WeightMeter}; use crate::asset_pallet::setup::create_and_issue_sample_asset; @@ -209,13 +210,12 @@ fn raise_happy_path() { exec_noop!( Sto::invest( bob.origin(), - bob_portfolio, - bob_portfolio, offering_asset, fundraiser_id, + bob_portfolio, + FundingMethod::OnChain(bob_portfolio), purchase_amount, max_price, - None, ), err ); @@ -233,13 +233,12 @@ fn raise_happy_path() { // Bob invests in Alice's fundraiser exec_ok!(Sto::invest( bob.origin(), - bob_portfolio, - bob_portfolio, offering_asset, fundraiser_id, + bob_portfolio, + FundingMethod::OnChain(bob_portfolio), amount.into(), Some(1_000_000u128), - None, )); check_fundraiser(1_000_000u128 - amount); assert_eq!( @@ -544,13 +543,12 @@ fn fundraiser_expired() { assert_noop!( Sto::invest( bob.origin(), - bob_portfolio, - bob_portfolio, offering_asset, fundraiser_id, + bob_portfolio, + FundingMethod::OnChain(bob_portfolio), 1000, None, - None ), Error::FundraiserExpired ); diff --git a/pallets/settlement/src/benchmarking.rs b/pallets/settlement/src/benchmarking.rs index af1606d98c..62ff9851ae 100644 --- a/pallets/settlement/src/benchmarking.rs +++ b/pallets/settlement/src/benchmarking.rs @@ -17,7 +17,6 @@ pub use frame_benchmarking::{account, benchmarks}; use frame_support::traits::{Get, TryCollect}; use frame_system::RawOrigin; use scale_info::prelude::format; -use sp_core::sr25519::Signature; use sp_runtime::MultiSignature; use sp_std::prelude::*; @@ -297,8 +296,10 @@ fn setup_receipt_details( Ticker::from_slice_truncated(format!("OFFTICKER{}", leg_id).as_bytes()), amount, ); - let raw_signature: [u8; 64] = signer.sign(&receipt.encode()).unwrap().0; - let encoded_signature = MultiSignature::from(Signature::from_raw(raw_signature)).encode(); + let signature = signer + .sign(&receipt.encode()) + .expect("Failed to sign receipt"); + let encoded_signature = MultiSignature::from(signature).encode(); let signature = T::OffChainSignature::decode(&mut &encoded_signature[..]).unwrap(); ReceiptDetails::new( leg_id as u64, diff --git a/pallets/settlement/src/lib.rs b/pallets/settlement/src/lib.rs index e66f10e250..429074e087 100644 --- a/pallets/settlement/src/lib.rs +++ b/pallets/settlement/src/lib.rs @@ -2197,6 +2197,11 @@ impl Pallet { let (did, secondary_key, instruction_details) = Self::ensure_origin_perm_and_instruction_validity(origin, instruction_id, false)?; + // The settlement must have a venue to use off-chain receipts. + let venue_id = instruction_details + .venue_id + .ok_or(Error::::OffChainAssetsMustHaveAVenue)?; + // Verify portfolio custodianship and check if it is a counter party with a pending affirmation. Self::ensure_portfolios_and_affirmation_status( instruction_id, @@ -2206,11 +2211,7 @@ impl Pallet { &[AffirmationStatus::Pending], )?; - Self::ensure_valid_receipts_details( - instruction_details.venue_id, - instruction_id, - &receipts_details, - )?; + Self::ensure_valid_receipts_details(venue_id, instruction_id, &receipts_details)?; // Lock tokens for all legs that are not of type [`Leg::OffChain`] let filtered_legs = Self::filtered_legs(instruction_id, &portfolios); @@ -2856,11 +2857,38 @@ impl Pallet { }) } + /// Ensure a valid venue signer and unused receipt uid. + /// The function checks that the signer is allowed by the venue, that the receipt has not been used before. + fn ensure_valid_receipt(venue_id: VenueId, signer: &T::AccountId, uid: u64) -> DispatchResult { + ensure!( + VenueSigners::::get(venue_id, signer), + Error::::UnauthorizedSigner + ); + ensure!( + !ReceiptsUsed::::get(signer, &uid), + Error::::ReceiptAlreadyClaimed + ); + Ok(()) + } + + /// Mark a receipt as used for a given venue signer. + pub fn mark_receipt_as_used( + venue_id: VenueId, + signer: &T::AccountId, + uid: u64, + ) -> DispatchResult { + // Ensure the receipt is valid. + Self::ensure_valid_receipt(venue_id, signer, uid)?; + + ReceiptsUsed::::insert(signer, uid, true); + Ok(()) + } + /// Ensures the all receipts are valid. A receipt is considered valid if the signer is allowed by the venue, /// if the receipt has not been used before, if the receipt's `leg_id` and `instruction_id` are referencing the /// correct instruction/leg and if its signature is valid. fn ensure_valid_receipts_details( - venue_id: Option, + venue_id: VenueId, instruction_id: InstructionId, receipts_details: &[ReceiptDetails], ) -> DispatchResult { @@ -2881,17 +2909,7 @@ impl Pallet { Error::::MultipleReceiptsForOneLeg ); - if let Some(venue_id) = venue_id { - ensure!( - VenueSigners::::get(venue_id, receipt_details.signer()), - Error::::UnauthorizedSigner - ); - } - - ensure!( - !ReceiptsUsed::::get(receipt_details.signer(), &receipt_details.uid()), - Error::::ReceiptAlreadyClaimed - ); + Self::ensure_valid_receipt(venue_id, receipt_details.signer(), receipt_details.uid())?; let leg = InstructionLegs::::get(&instruction_id, &receipt_details.leg_id()) .ok_or(Error::::LegNotFound)?; diff --git a/pallets/sto/src/benchmarking.rs b/pallets/sto/src/benchmarking.rs index a540067605..21fcf3ad9e 100644 --- a/pallets/sto/src/benchmarking.rs +++ b/pallets/sto/src/benchmarking.rs @@ -1,13 +1,15 @@ use frame_benchmarking::benchmarks; use frame_support::dispatch::DispatchError; use scale_info::prelude::format; +use sp_runtime::MultiSignature; use pallet_asset::benchmarking::setup_asset_transfer; use pallet_asset::BalanceOf; use pallet_identity::benchmarking::{User, UserBuilder}; use pallet_settlement::VenueCounter; +use polymesh_primitives::crypto::BytesWrapped; use polymesh_primitives::settlement::VenueDetails; -use polymesh_primitives::TrustedIssuer; +use polymesh_primitives::{Ticker, TrustedIssuer}; use crate::*; @@ -125,6 +127,36 @@ where setup_portfolios } +fn sign_receipt( + signer: &User, + uid: u64, + fundraiser_id: FundraiserId, + fundraiser_did: IdentityId, + investor_did: IdentityId, + ticker: Ticker, + amount: u128, +) -> FundraiserReceiptDetails { + let receipt = FundraiserReceipt::new( + uid, + fundraiser_id, + investor_did, + fundraiser_did, + ticker, + amount, + ); + let signature = signer + .sign(&BytesWrapped(&receipt).encode()) + .expect("Failed to sign receipt"); + let encoded_signature = MultiSignature::from(signature).encode(); + let signature = T::OffChainSignature::decode(&mut &encoded_signature[..]).unwrap(); + FundraiserReceiptDetails { + uid, + signer: signer.account(), + signature: signature.into(), + metadata: None, + } +} + benchmarks! { create_fundraiser { // Number of tiers @@ -153,19 +185,54 @@ benchmarks! { assert!(FundraiserCount::::get(setup_portfolios.offering_asset_id) > FundraiserId(0), "create_fundraiser"); } - invest { + invest_onchain { let alice = >::default().generate_did().build("Alice"); let bob = >::default().generate_did().build("Bob"); let setup_portfolios = setup_fundraiser::(&alice, &bob, MAX_TIERS as u32); - }: _( + }: invest( bob.origin(), + setup_portfolios.offering_asset_id, + FundraiserId(0), setup_portfolios.investor_offering_portfolio, - setup_portfolios.investor_raising_portfolio, + FundingMethod::OnChain(setup_portfolios.investor_raising_portfolio), + 100, + Some(1_000_000u128.into()) + ) + verify { + assert!(BalanceOf::::get(&setup_portfolios.offering_asset_id, bob.did()) > 0u32.into(), "invest"); + } + + invest_offchain { + let id = FundraiserId(0); + let alice = >::default().generate_did().build("Alice"); + let bob = >::default().generate_did().build("Bob"); + let ticker = Ticker::from_slice_truncated(b"TEST"); + + let setup_portfolios = setup_fundraiser::(&alice, &bob, MAX_TIERS as u32); + Sto::::enable_offchain_funding( + alice.origin().into(), + setup_portfolios.offering_asset_id, + id, + ticker + ).unwrap(); + + let receipt = sign_receipt( + &alice, + 0, + id, + alice.did(), + bob.did(), + ticker, + 10 + ); + }: invest( + bob.origin(), setup_portfolios.offering_asset_id, FundraiserId(0), + setup_portfolios.investor_offering_portfolio, + FundingMethod::OffChain(receipt), 100, - Some(1_000_000u128.into()), - None + Some(1_000_000u128.into()) ) verify { assert!(BalanceOf::::get(&setup_portfolios.offering_asset_id, bob.did()) > 0u32.into(), "invest"); @@ -211,4 +278,12 @@ benchmarks! { verify { assert!(>::get(setup_portfolios.offering_asset_id, id).unwrap().is_closed(), "stop"); } + + enable_offchain_funding { + let id = FundraiserId(0); + let alice = >::default().generate_did().build("Alice"); + let bob = >::default().generate_did().build("Bob"); + let ticker = Ticker::from_slice_truncated(b"TEST"); + let setup_portfolios = setup_fundraiser::(&alice, &bob, 1); + }: _(alice.origin(), setup_portfolios.offering_asset_id, id, ticker) } diff --git a/pallets/sto/src/lib.rs b/pallets/sto/src/lib.rs index c70654f015..70e6566f81 100644 --- a/pallets/sto/src/lib.rs +++ b/pallets/sto/src/lib.rs @@ -1,24 +1,52 @@ // Copyright (c) 2020 Polymesh Association -//! # Sto Module +//! # Security Token Offering (STO) Pallet //! -//! Sto module creates and manages security token offerings +//! The STO pallet enables the creation and management of security token offerings on Polymesh. +//! It provides a comprehensive framework for conducting compliant fundraising activities +//! through tokenized securities. //! //! ## Overview //! -//! Sufficiently permissioned external agent's can create and manage fundraisers of assets. -//! Fundraisers are of fixed supply, with optional expiry and tiered pricing. -//! Fundraisers allow a single payment asset, known as the raising asset. -//! Investors can invest through on-chain balance or off-chain receipts. +//! This pallet allows authorized external agents to create and manage fundraisers for assets. +//! Fundraisers support sophisticated pricing models with multiple tiers, optional time windows, +//! and flexible payment methods including both on-chain and off-chain funding mechanisms. +//! +//! ### Key Features +//! +//! - **Tiered Pricing**: Support for up to 10 price tiers per fundraiser with different token amounts and prices +//! - **Flexible Timing**: Optional start and end times for fundraising periods +//! - **Multiple Payment Methods**: Accept payments via on-chain portfolios or off-chain receipts +//! - **Venue Integration**: Leverage Polymesh's settlement infrastructure for secure token transfers +//! - **Fine-grained Control**: Freeze, unfreeze, modify, and stop fundraisers as needed +//! - **Minimum Investment**: Enforce minimum investment thresholds per transaction +//! - **Price Protection**: Optional maximum price limits to protect investors +//! +//! ### Use Cases +//! +//! - **Primary Offerings**: Initial public offerings (IPOs) and private placements +//! - **Secondary Fundraising**: Follow-on offerings and rights issues +//! - **Tokenized Asset Sales**: Real estate, commodities, or other asset-backed tokens +//! - **Compliance-First Fundraising**: KYC/AML compliant token distributions +//! - **Institutional Investment**: Professional investor participation through off-chain receipts //! //! ## Dispatchable Functions //! -//! - `create_fundraiser` - Create a new fundraiser. -//! - `invest` - Invest in a fundraiser. -//! - `freeze_fundraiser` - Freeze a fundraiser. -//! - `unfreeze_fundraiser` - Unfreeze a fundraiser. -//! - `modify_fundraiser_window` - Modify the time window a fundraiser is active. -//! - `stop` - stop a fundraiser. +//! ### Fundraiser Management +//! - [`create_fundraiser`] - Create a new fundraiser with tiered pricing and time windows +//! - [`stop`] - Permanently stop a fundraiser and unlock remaining tokens +//! - [`freeze_fundraiser`] - Temporarily freeze a fundraiser to prevent new investments +//! - [`unfreeze_fundraiser`] - Resume a frozen fundraiser +//! - [`modify_fundraiser_window`] - Update the active time window for a fundraiser +//! - [`enable_offchain_funding`] - Enable off-chain payment support for a fundraiser +//! +//! ### Investment +//! - [`invest`] - Invest in a fundraiser using on-chain or off-chain funding +//! +//! ## Permissions +//! +//! All fundraiser management functions require appropriate asset permissions through the +//! External Agents pallet. Investment functions require portfolio custody permissions. #![cfg_attr(not(feature = "std"), no_std)] #![recursion_limit = "256"] @@ -30,6 +58,8 @@ use codec::{Decode, Encode, MaxEncodedLen}; use frame_support::dispatch::DispatchResult; use frame_support::ensure; use frame_support::weights::Weight; +use frame_system::pallet_prelude::OriginFor; +use polymesh_primitives::crypto::verify_signature; use scale_info::TypeInfo; use sp_runtime::DispatchError; use sp_std::collections::btree_set::BTreeSet; @@ -39,10 +69,11 @@ use pallet_base::try_next_post; use pallet_identity::PermissionedCallOriginData; use pallet_settlement::VenueInfo; use polymesh_primitives::asset::AssetId; -use polymesh_primitives::impl_checked_inc; -use polymesh_primitives::settlement::{Leg, ReceiptDetails, SettlementType, VenueId, VenueType}; +use polymesh_primitives::settlement::{Leg, SettlementType, VenueId, VenueType}; +use polymesh_primitives::sto::{FundraiserId, FundraiserReceipt, FundraiserReceiptDetails}; use polymesh_primitives::{ storage_migration_ver, traits::PortfolioSubTrait, Balance, EventDid, IdentityId, PortfolioId, + Ticker, }; use polymesh_primitives_derive::VecU8StrongTyped; @@ -56,12 +87,6 @@ type Portfolio = pallet_portfolio::Pallet; type Settlement = pallet_settlement::Pallet; type Timestamp = pallet_timestamp::Pallet; -/// The per-AssetId ID of a fundraiser. -#[derive(Encode, Decode, TypeInfo, MaxEncodedLen)] -#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)] -pub struct FundraiserId(pub u64); -impl_checked_inc!(FundraiserId); - /// Status of a Fundraiser. #[derive( Clone, @@ -92,6 +117,34 @@ impl Default for FundraiserStatus { } } +/// Funding method. On-chain asset or off-chain receipt. +#[derive(Encode, Decode, TypeInfo)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +#[cfg_attr(feature = "std", derive(Debug))] +pub enum FundingMethod { + /// On-chain asset. + OnChain(PortfolioId), + /// Off-chain receipt. + OffChain(FundraiserReceiptDetails), +} + +impl FundingMethod { + pub fn is_onchain(&self) -> bool { + matches!(self, FundingMethod::OnChain(_)) + } +} + +/// Which funding asset was used to invest in the fundraiser. +#[derive(Encode, Decode, TypeInfo)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +#[cfg_attr(feature = "std", derive(Debug))] +pub enum FundingAsset { + /// On-chain asset. + OnChain(AssetId), + /// Off-chain receipt. + OffChain(Ticker), +} + /// Details about the Fundraiser. #[derive(Encode, Decode, TypeInfo)] #[derive(Default, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -170,11 +223,21 @@ pub struct FundraiserName(Vec); pub trait WeightInfo { fn create_fundraiser(i: u32) -> Weight; - fn invest() -> Weight; + fn invest_onchain() -> Weight; + fn invest_offchain() -> Weight; fn freeze_fundraiser() -> Weight; fn unfreeze_fundraiser() -> Weight; fn modify_fundraiser_window() -> Weight; fn stop() -> Weight; + fn enable_offchain_funding() -> Weight; + + fn invest(onchain: bool) -> Weight { + if onchain { + Self::invest_onchain() + } else { + Self::invest_offchain() + } + } } // re-export pallet types. @@ -182,7 +245,7 @@ pub use pallet::*; #[frame_support::pallet] pub mod pallet { - use super::{Identity, *}; + use super::*; use frame_support::pallet_prelude::{ValueQuery, *}; use frame_system::pallet_prelude::*; @@ -202,63 +265,136 @@ pub mod pallet { #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { /// A new fundraiser has been created. - /// (Agent DID, fundraiser id, fundraiser name, fundraiser details) - FundraiserCreated( - IdentityId, - FundraiserId, - FundraiserName, - Fundraiser, - ), - /// An investor invested in the fundraiser. - /// (Investor, fundraiser_id, offering token, raise token, offering_token_amount, raise_token_amount) - Invested(IdentityId, FundraiserId, AssetId, AssetId, Balance, Balance), - /// A fundraiser has been frozen. - /// (Agent DID, fundraiser id) - FundraiserFrozen(IdentityId, FundraiserId), - /// A fundraiser has been unfrozen. - /// (Agent DID, fundraiser id) - FundraiserUnfrozen(IdentityId, FundraiserId), - /// A fundraiser window has been modified. - /// (Agent DID, fundraiser id, old_start, old_end, new_start, new_end) - FundraiserWindowModified( - EventDid, - FundraiserId, - T::Moment, - Option, - T::Moment, - Option, - ), - /// A fundraiser has been stopped. - /// (Agent DID, fundraiser id) - FundraiserClosed(IdentityId, FundraiserId), + /// + /// [agent_did, offering_asset, raising_asset, fundraiser_id, fundraiser_name, fundraiser] + FundraiserCreated { + /// Identity of the external agent who created the fundraiser. + agent_did: IdentityId, + /// Asset being offered for sale in the fundraiser. + offering_asset: AssetId, + /// Asset being accepted as payment in the fundraiser. + raising_asset: AssetId, + /// Unique identifier for the fundraiser. + fundraiser_id: FundraiserId, + /// Human-readable name of the fundraiser. + fundraiser_name: FundraiserName, + /// Complete fundraiser configuration. + fundraiser: Fundraiser, + }, + /// An investor successfully invested in the fundraiser. + /// + /// [investor_did, offering_asset, fundraiser_id, funding_asset, offering_amount, raise_amount] + Invested { + /// Identity of the investor. + investor_did: IdentityId, + /// Asset being purchased. + offering_asset: AssetId, + /// Fundraiser that was invested in. + fundraiser_id: FundraiserId, + /// Type of funding used (on-chain or off-chain). + funding_asset: FundingAsset, + /// Amount of offering asset purchased. + offering_amount: Balance, + /// Amount of raising asset spent. + raise_amount: Balance, + }, + /// A fundraiser has been frozen, preventing new investments. + /// + /// [agent_did, offering_asset, fundraiser_id] + FundraiserFrozen { + /// Identity of the external agent who froze the fundraiser. + agent_did: IdentityId, + /// Asset associated with the fundraiser. + offering_asset: AssetId, + /// Fundraiser that was frozen. + fundraiser_id: FundraiserId, + }, + /// A fundraiser has been unfrozen, allowing new investments. + /// + /// [agent_did, offering_asset, fundraiser_id] + FundraiserUnfrozen { + /// Identity of the external agent who unfroze the fundraiser. + agent_did: IdentityId, + /// Asset associated with the fundraiser. + offering_asset: AssetId, + /// Fundraiser that was unfrozen. + fundraiser_id: FundraiserId, + }, + /// A fundraiser's time window has been modified. + /// + /// [agent_did, offering_asset, fundraiser_id, old_start, old_end, new_start, new_end] + FundraiserWindowModified { + /// Identity of the external agent who modified the window. + agent_did: EventDid, + /// Asset associated with the fundraiser. + offering_asset: AssetId, + /// Fundraiser whose window was modified. + fundraiser_id: FundraiserId, + /// Previous start time. + old_start: T::Moment, + /// Previous end time (if any). + old_end: Option, + /// New start time. + new_start: T::Moment, + /// New end time (if any). + new_end: Option, + }, + /// A fundraiser has been permanently closed. + /// + /// [agent_did, offering_asset, fundraiser_id] + FundraiserClosed { + /// Identity of the external agent who closed the fundraiser. + agent_did: IdentityId, + /// Asset associated with the fundraiser. + offering_asset: AssetId, + /// Fundraiser that was closed. + fundraiser_id: FundraiserId, + }, + /// Off-chain funding has been enabled for a fundraiser. + /// + /// [agent_did, offering_asset, fundraiser_id, ticker] + FundraiserOffchainFundingEnabled { + /// Identity of the external agent who enabled off-chain funding. + agent_did: IdentityId, + /// Asset associated with the fundraiser. + offering_asset: AssetId, + /// Fundraiser for which off-chain funding was enabled. + fundraiser_id: FundraiserId, + /// Ticker symbol of the off-chain asset. + ticker: Ticker, + }, } #[pallet::error] pub enum Error { - /// Sender does not have required permissions. + /// Sender does not have required permissions for the requested operation. Unauthorized, - /// An arithmetic operation overflowed. + /// An arithmetic operation resulted in overflow or underflow. Overflow, - /// Not enough tokens left for sale. + /// The fundraiser does not have enough tokens remaining to fulfil the investment. InsufficientTokensRemaining, - /// Fundraiser not found. + /// The specified fundraiser does not exist for the given asset. FundraiserNotFound, - /// Fundraiser is either frozen or stopped. + /// The fundraiser is not in a live state (either frozen or stopped). FundraiserNotLive, - /// Fundraiser has been closed/stopped already. + /// The fundraiser has been permanently closed or stopped. FundraiserClosed, - /// Interacting with a fundraiser past the end `Moment`. + /// Attempting to interact with a fundraiser after its end time has passed. FundraiserExpired, - /// An invalid venue provided. + /// The provided venue is invalid (does not exist, wrong type, or wrong creator). InvalidVenue, - /// An individual price tier was invalid or a set of price tiers was invalid. + /// One or more price tiers have invalid parameters (zero total, too many tiers, etc.). InvalidPriceTiers, - /// Window (start time, end time) has invalid parameters, e.g start time is after end time. + /// The fundraiser time window has invalid parameters (start time after end time). InvalidOfferingWindow, - /// Price of the investment exceeded the max price. + /// The calculated price per token exceeds the maximum price specified by the investor. MaxPriceExceeded, - /// Investment amount is lower than minimum investment amount. + /// The investment amount is below the minimum investment threshold for this fundraiser. InvestmentAmountTooLow, + /// The off-chain receipt signature is invalid or could not be verified. + InvalidSignature, + /// Off-chain funding has not been enabled for this fundraiser. + OffchainFundingNotAllowed, } #[pallet::pallet] @@ -297,6 +433,18 @@ pub mod pallet { OptionQuery, >; + /// If the fundraiser supports off-chain funding payments using receipts. + #[pallet::storage] + pub type FundraiserOffchainAsset = StorageDoubleMap< + _, + Blake2_128Concat, + AssetId, + Twox64Concat, + FundraiserId, + Ticker, + OptionQuery, + >; + /// Storage migration version. #[pallet::storage] pub(super) type StorageVersion = StorageValue<_, Version, ValueQuery>; @@ -321,22 +469,34 @@ pub mod pallet { #[pallet::call] impl Pallet { - /// Create a new fundraiser. - /// - /// * `offering_portfolio` - Portfolio containing the `offering_asset`. - /// * `offering_asset` - Asset being offered. - /// * `raising_portfolio` - Portfolio containing the `raising_asset`. - /// * `raising_asset` - Asset being exchanged for `offering_asset` on investment. - /// * `tiers` - Price tiers to charge investors on investment. - /// * `venue_id` - Venue to handle settlement. - /// * `start` - Fundraiser start time, if `None` the fundraiser will start immediately. - /// * `end` - Fundraiser end time, if `None` the fundraiser will never expire. - /// * `minimum_investment` - Minimum amount of `raising_asset` that an investor needs to spend to invest in this raise. - /// * `fundraiser_name` - Fundraiser name, only used in the UIs. - /// - /// # Permissions - /// * Asset - /// * Portfolio + /// Create a new fundraiser for a security token offering. + /// + /// This function creates a tiered pricing fundraiser where investors can purchase + /// tokens at different price points. The fundraiser uses Polymesh's settlement + /// infrastructure to ensure compliant and secure token transfers. + /// + /// # Parameters + /// * `offering_portfolio` - Portfolio containing the tokens being offered for sale + /// * `offering_asset` - Asset ID of the security token being sold + /// * `raising_portfolio` - Portfolio that will receive the raised funds + /// * `raising_asset` - Asset ID of the payment token (e.g., POLYX, stablecoin) + /// * `tiers` - Vector of price tiers (1-10 tiers), each with total amount and price per unit + /// * `venue_id` - STO venue ID for handling settlements (must be owned by caller) + /// * `start` - Optional start time; if `None`, fundraiser begins immediately + /// * `end` - Optional end time; if `None`, fundraiser runs indefinitely + /// * `minimum_investment` - Minimum amount of `raising_asset` required per investment + /// * `fundraiser_name` - Human-readable name for UI display (length limited) + /// + /// # Permissions Required + /// * **Asset Agent**: Caller must be an authorized external agent for `offering_asset` + /// * **Portfolio Custody**: Caller must have custody of both `offering_portfolio` and `raising_portfolio` + /// * **Venue Ownership**: The specified `venue_id` must be an STO venue owned by the caller + /// + /// # Errors + /// * `InvalidVenue` - Venue doesn't exist, wrong type, or not owned by caller + /// * `InvalidPriceTiers` - Invalid tier configuration (0 tiers, >10 tiers, zero amounts) + /// * `InvalidOfferingWindow` - Start time is after end time + /// * `Overflow` - Total offering amount calculation overflowed #[pallet::weight(::WeightInfo::create_fundraiser(tiers.len() as u32))] #[pallet::call_index(0)] pub fn create_fundraiser( @@ -396,7 +556,7 @@ pub mod pallet { // Get the next fundraiser ID. let mut seq = FundraiserCount::::get(&offering_asset); - let id = try_next_post::(&mut seq)?; + let fundraiser_id = try_next_post::(&mut seq)?; >::lock_tokens(&offering_portfolio, &offering_asset, offering_amount)?; @@ -415,265 +575,165 @@ pub mod pallet { }; FundraiserCount::::insert(offering_asset, seq); - Fundraisers::::insert(offering_asset, id, fundraiser.clone()); - FundraiserNames::::insert(offering_asset, id, fundraiser_name.clone()); + Fundraisers::::insert(offering_asset, fundraiser_id, fundraiser.clone()); + FundraiserNames::::insert(offering_asset, fundraiser_id, fundraiser_name.clone()); - Self::deposit_event(Event::FundraiserCreated( - did, - id, + Self::deposit_event(Event::FundraiserCreated { + agent_did: did, + offering_asset, + raising_asset, + fundraiser_id, fundraiser_name, fundraiser, - )); + }); - Ok(().into()) + Ok(()) } - /// Invest in a fundraiser. + /// Invest in a fundraiser using on-chain or off-chain funding. + /// + /// This function allows investors to purchase tokens from an active fundraiser. + /// The investment is processed through multiple price tiers in order, starting + /// with the lowest-priced tier. The purchase creates a settlement instruction + /// that transfers tokens and payment between the appropriate portfolios. /// - /// * `investment_portfolio` - Portfolio that `offering_asset` will be deposited in. - /// * `funding_portfolio` - Portfolio that will fund the investment. - /// * `offering_asset` - Asset to invest in. - /// * `id` - ID of the fundraiser to invest in. - /// * `purchase_amount` - Amount of `offering_asset` to purchase. - /// * `max_price` - Maximum price to pay per unit of `offering_asset`, If `None`there are no constraints on price. - /// * `receipt` - Off-chain receipt to use instead of on-chain balance in `funding_portfolio`. + /// # Parameters + /// * `offering_asset` - Asset ID of the security token being purchased + /// * `fundraiser_id` - Unique identifier of the fundraiser to invest in + /// * `investment_portfolio` - Portfolio where purchased tokens will be deposited + /// * `funding` - Payment method: either `OnChain(portfolio_id)` for on-chain assets + /// or `OffChain(receipt_details)` for off-chain receipts with signature verification + /// * `purchase_amount` - Number of `offering_asset` tokens to purchase + /// * `max_price` - Optional maximum price per token; if specified, investment fails + /// if the blended price across tiers exceeds this limit /// - /// # Permissions - /// * Portfolio - #[pallet::weight(::WeightInfo::invest())] + /// # Permissions Required + /// * **Portfolio Custody**: Caller must have custody of `investment_portfolio` + /// * **Funding Portfolio**: If using on-chain funding, caller must have custody + /// of the funding portfolio specified in the `FundingMethod` + /// + /// # Errors + /// * `FundraiserNotFound` - Specified fundraiser doesn't exist + /// * `FundraiserNotLive` - Fundraiser is frozen or closed + /// * `FundraiserExpired` - Current time is outside fundraiser's active window + /// * `InsufficientTokensRemaining` - Not enough tokens available across all tiers + /// * `InvestmentAmountTooLow` - Total cost is below minimum investment threshold + /// * `MaxPriceExceeded` - Blended price exceeds investor's maximum price limit + /// * `OffchainFundingNotAllowed` - Off-chain funding not enabled for this fundraiser + /// * `InvalidSignature` - Off-chain receipt signature verification failed + #[pallet::weight(::WeightInfo::invest(funding.is_onchain()))] #[pallet::call_index(1)] pub fn invest( origin: OriginFor, - investment_portfolio: PortfolioId, - funding_portfolio: PortfolioId, offering_asset: AssetId, - id: FundraiserId, + fundraiser_id: FundraiserId, + investment_portfolio: PortfolioId, + funding: FundingMethod, purchase_amount: Balance, max_price: Option, - receipt: Option>, ) -> DispatchResult { - let PermissionedCallOriginData { - primary_did: did, - secondary_key, - .. - } = Identity::::ensure_origin_call_permissions(origin.clone())?; - - >::ensure_portfolio_custody_and_permission( - investment_portfolio, - did, - secondary_key.as_ref(), - )?; - >::ensure_portfolio_custody_and_permission( - funding_portfolio, - did, - secondary_key.as_ref(), - )?; - - let mut fundraiser = Self::ensure_fundraiser(offering_asset, id)?; - - ensure!( - fundraiser.status == FundraiserStatus::Live, - Error::::FundraiserNotLive - ); - - let now = Timestamp::::get(); - ensure!( - fundraiser.start <= now && fundraiser.end.filter(|e| now >= *e).is_none(), - Error::::FundraiserExpired - ); - - // Remaining tokens to fulfil the investment amount - let mut remaining = purchase_amount; - // Total cost to to fulfil the investment amount. - // Primary use is to calculate the blended price (offering_token_amount / cost). - // Blended price must be <= to max_price or the investment will fail. - let mut cost = Balance::from(0u32); - - // Price is entered as a multiple of 1_000_000 - // i.e. a price of 1 unit is 1_000_000 - // a price of 1.5 units is 1_500_00 - let price_divisor = Balance::from(1_000_000u32); - // Individual purchases from each tier that accumulate to fulfil the investment amount. - // Tuple of (tier_id, amount to purchase from that tier). - let mut purchases = Vec::new(); - - for (id, tier) in fundraiser - .tiers - .iter() - .enumerate() - .filter(|(_, tier)| tier.remaining > 0u32.into()) - { - // fulfilled the investment amount - if remaining == 0u32.into() { - break; - } - - // Check if this tier can fulfil the remaining investment amount. - // If it can, purchase the remaining amount. - // If it can't, purchase what's remaining in the tier. - let purchase_amount = if tier.remaining >= remaining { - remaining - } else { - tier.remaining - }; - - remaining -= purchase_amount; - purchases.push((id, purchase_amount)); - cost = purchase_amount - .checked_mul(tier.price) - .ok_or(Error::::Overflow)? - .checked_div(price_divisor) - .and_then(|pa| cost.checked_add(pa)) - .ok_or(Error::::Overflow)?; - } - - ensure!( - remaining == 0u32.into(), - Error::::InsufficientTokensRemaining - ); - ensure!( - cost >= fundraiser.minimum_investment, - Error::::InvestmentAmountTooLow - ); - ensure!( - max_price - .map(|max_price| cost - <= max_price.saturating_mul(purchase_amount) / price_divisor) - .unwrap_or(true), - Error::::MaxPriceExceeded - ); - - let legs = vec![ - Leg::Fungible { - sender: fundraiser.offering_portfolio, - receiver: investment_portfolio, - asset_id: fundraiser.offering_asset, - amount: purchase_amount, - }, - Leg::Fungible { - sender: funding_portfolio, - receiver: fundraiser.raising_portfolio, - asset_id: fundraiser.raising_asset, - amount: cost, - }, - ]; - - >::unlock_tokens( - &fundraiser.offering_portfolio, - &fundraiser.offering_asset, - purchase_amount, - )?; - - let instruction_id = Settlement::::base_add_instruction( - fundraiser.creator, - Some(fundraiser.venue_id), - SettlementType::SettleOnAffirmation, - None, - None, - legs, - None, - None, - )?; - - let portfolios = [fundraiser.offering_portfolio, fundraiser.raising_portfolio] - .iter() - .copied() - .collect::>(); - Settlement::::unsafe_affirm_instruction( - fundraiser.creator, - instruction_id, - portfolios, - None, - None, - )?; - - let portfolios = [investment_portfolio, funding_portfolio] - .iter() - .copied() - .collect::>(); - Settlement::::affirm_and_execute_instruction( + Self::base_invest( origin, - instruction_id, - receipt, - portfolios, - did, - )?; - - for (id, amount) in purchases { - fundraiser.tiers[id].remaining -= amount; - } - - Self::deposit_event(Event::Invested( - did, - id, offering_asset, - fundraiser.raising_asset, + fundraiser_id, + investment_portfolio, + funding, purchase_amount, - cost, - )); - - >::insert(offering_asset, id, fundraiser); - - Ok(().into()) + max_price, + ) } - /// Freeze a fundraiser. + /// Temporarily freeze a fundraiser to prevent new investments. + /// + /// When a fundraiser is frozen, it cannot accept new investments but remains + /// otherwise intact. This is useful for pausing activity while resolving + /// issues or during maintenance periods. The fundraiser can be unfrozen + /// later to resume normal operations. + /// + /// # Parameters + /// * `offering_asset` - Asset ID associated with the fundraiser to freeze + /// * `fundraiser_id` - Unique identifier of the fundraiser to freeze /// - /// * `offering_asset` - Asset to freeze. - /// * `id` - ID of the fundraiser to freeze. + /// # Permissions Required + /// * **Asset Agent**: Caller must be an authorized external agent for `offering_asset` /// - /// # Permissions - /// * Asset + /// # Errors + /// * `FundraiserNotFound` - Specified fundraiser doesn't exist + /// * `FundraiserClosed` - Fundraiser has already been permanently closed + /// * `Unauthorized` - Caller lacks required asset agent permissions #[pallet::weight(::WeightInfo::freeze_fundraiser())] #[pallet::call_index(2)] pub fn freeze_fundraiser( origin: OriginFor, offering_asset: AssetId, - id: FundraiserId, + fundraiser_id: FundraiserId, ) -> DispatchResult { - Self::set_frozen(origin, offering_asset, id, true)?; - Ok(().into()) + Self::set_frozen(origin, offering_asset, fundraiser_id, true)?; + Ok(()) } - /// Unfreeze a fundraiser. + /// Resume a frozen fundraiser to allow new investments. /// - /// * `offering_asset` - Asset to unfreeze. - /// * `id` - ID of the fundraiser to unfreeze. + /// This function unfreezes a previously frozen fundraiser, returning it to + /// the Live status where it can accept new investments. The fundraiser + /// must not be permanently closed for this operation to succeed. /// - /// # Permissions - /// * Asset + /// # Parameters + /// * `offering_asset` - Asset ID associated with the fundraiser to unfreeze + /// * `fundraiser_id` - Unique identifier of the fundraiser to unfreeze + /// + /// # Permissions Required + /// * **Asset Agent**: Caller must be an authorized external agent for `offering_asset` + /// + /// # Errors + /// * `FundraiserNotFound` - Specified fundraiser doesn't exist + /// * `FundraiserClosed` - Fundraiser has been permanently closed and cannot be unfrozen + /// * `Unauthorized` - Caller lacks required asset agent permissions #[pallet::weight(::WeightInfo::unfreeze_fundraiser())] #[pallet::call_index(3)] pub fn unfreeze_fundraiser( origin: OriginFor, offering_asset: AssetId, - id: FundraiserId, + fundraiser_id: FundraiserId, ) -> DispatchResult { - Self::set_frozen(origin, offering_asset, id, false)?; - Ok(().into()) + Self::set_frozen(origin, offering_asset, fundraiser_id, false)?; + Ok(()) } - /// Modify the time window a fundraiser is active + /// Modify the time window when a fundraiser is active for investments. + /// + /// This function allows authorized agents to update the start and end times + /// of an active fundraiser. This can be useful for extending fundraising + /// periods, adjusting launch timing, or responding to market conditions. + /// The fundraiser must not be permanently closed to modify its window. /// - /// * `offering_asset` - Asset to modify. - /// * `id` - ID of the fundraiser to modify. - /// * `start` - New start of the fundraiser. - /// * `end` - New end of the fundraiser to modify. + /// # Parameters + /// * `offering_asset` - Asset ID associated with the fundraiser to modify + /// * `fundraiser_id` - Unique identifier of the fundraiser to modify + /// * `start` - New start time for the fundraiser (can be in the past or future) + /// * `end` - New optional end time; if `None`, the fundraiser runs indefinitely /// - /// # Permissions - /// * Asset + /// # Permissions Required + /// * **Asset Agent**: Caller must be an authorized external agent for `offering_asset` + /// + /// # Errors + /// * `FundraiserNotFound` - Specified fundraiser doesn't exist + /// * `FundraiserClosed` - Fundraiser has been permanently closed + /// * `FundraiserExpired` - Fundraiser has already expired (past its original end time) + /// * `InvalidOfferingWindow` - New start time is after new end time + /// * `Unauthorized` - Caller lacks required asset agent permissions #[pallet::weight(::WeightInfo::modify_fundraiser_window())] #[pallet::call_index(4)] pub fn modify_fundraiser_window( origin: OriginFor, offering_asset: AssetId, - id: FundraiserId, + fundraiser_id: FundraiserId, start: T::Moment, end: Option, ) -> DispatchResult { let did = >::ensure_perms(origin, offering_asset)?.for_event(); - >::try_mutate(offering_asset, id, |fundraiser| { + >::try_mutate(offering_asset, fundraiser_id, |fundraiser| { let fundraiser = fundraiser.as_mut().ok_or(Error::::FundraiserNotFound)?; ensure!(!fundraiser.is_closed(), Error::::FundraiserClosed); if let Some(end) = fundraiser.end { @@ -682,41 +742,55 @@ pub mod pallet { if let Some(end) = end { ensure!(start < end, Error::::InvalidOfferingWindow); } - Self::deposit_event(Event::FundraiserWindowModified( - did, - id, - fundraiser.start, - fundraiser.end, - start, - end, - )); + Self::deposit_event(Event::FundraiserWindowModified { + agent_did: did, + offering_asset, + fundraiser_id, + old_start: fundraiser.start, + old_end: fundraiser.end, + new_start: start, + new_end: end, + }); fundraiser.start = start; fundraiser.end = end; Ok::<_, DispatchError>(()) })?; - Ok(().into()) + Ok(()) } - /// Stop a fundraiser. + /// Permanently stop a fundraiser and unlock remaining tokens. + /// + /// This function permanently closes a fundraiser, preventing any further + /// investments. Any remaining tokens that haven't been sold are unlocked + /// and returned to the offering portfolio. Once stopped, a fundraiser + /// cannot be restarted. /// - /// * `offering_asset` - Asset to stop. - /// * `id` - ID of the fundraiser to stop. + /// # Parameters + /// * `offering_asset` - Asset ID associated with the fundraiser to stop + /// * `fundraiser_id` - Unique identifier of the fundraiser to stop /// - /// # Permissions - /// * Asset + /// # Permissions Required + /// * **Asset Agent**: Caller must be an authorized external agent for `offering_asset` + /// OR be the original creator of the fundraiser + /// + /// # Errors + /// * `FundraiserNotFound` - Specified fundraiser doesn't exist + /// * `FundraiserClosed` - Fundraiser has already been permanently closed + /// * `Unauthorized` - Caller lacks required permissions #[pallet::weight(::WeightInfo::stop())] #[pallet::call_index(5)] pub fn stop( origin: OriginFor, offering_asset: AssetId, - id: FundraiserId, + fundraiser_id: FundraiserId, ) -> DispatchResult { - let mut fundraiser = Self::ensure_fundraiser(offering_asset, id)?; + let mut fundraiser = Self::ensure_fundraiser(offering_asset, fundraiser_id)?; - let did = >::ensure_asset_perms(origin, offering_asset)?.primary_did; - if fundraiser.creator != did { - >::ensure_agent_permissioned(&offering_asset, did)?; + let agent_did = + >::ensure_asset_perms(origin, offering_asset)?.primary_did; + if fundraiser.creator != agent_did { + >::ensure_agent_permissioned(&offering_asset, agent_did)?; } ensure!(!fundraiser.is_closed(), Error::::FundraiserClosed); @@ -725,7 +799,7 @@ pub mod pallet { .tiers .iter() .map(|t| t.remaining) - .fold(0u32.into(), |remaining, x| remaining + x); + .fold(0, |remaining, x| remaining + x); >::unlock_tokens( &fundraiser.offering_portfolio, @@ -736,32 +810,295 @@ pub mod pallet { Some(end) if end > Timestamp::::get() => FundraiserStatus::ClosedEarly, _ => FundraiserStatus::Closed, }; - >::insert(offering_asset, id, fundraiser); - Self::deposit_event(Event::FundraiserClosed(did, id)); + >::insert(offering_asset, fundraiser_id, fundraiser); + Self::deposit_event(Event::FundraiserClosed { + agent_did, + offering_asset, + fundraiser_id, + }); - Ok(().into()) + Ok(()) + } + + /// Enable off-chain funding support for a fundraiser. + /// + /// This function allows a fundraiser to accept off-chain payments through + /// cryptographically signed receipts. Once enabled, investors can use the + /// `invest` function with `FundingMethod::OffChain` to provide payment + /// receipts instead of on-chain portfolio transfers. + /// + /// # Parameters + /// * `offering_asset` - Asset ID associated with the fundraiser + /// * `fundraiser_id` - Unique identifier of the fundraiser to enable off-chain funding for + /// * `ticker` - Ticker symbol of the off-chain asset that will be accepted as payment + /// + /// # Permissions Required + /// * **Asset Agent**: Caller must be an authorized external agent for `offering_asset` + /// OR be the original creator of the fundraiser + /// + /// # Errors + /// * `FundraiserNotFound` - Specified fundraiser doesn't exist + /// * `FundraiserClosed` - Fundraiser has been permanently closed + /// * `Unauthorized` - Caller lacks required permissions + #[pallet::weight(::WeightInfo::enable_offchain_funding())] + #[pallet::call_index(6)] + pub fn enable_offchain_funding( + origin: OriginFor, + offering_asset: AssetId, + fundraiser_id: FundraiserId, + ticker: Ticker, + ) -> DispatchResult { + let agent_did = + >::ensure_asset_perms(origin, offering_asset)?.primary_did; + + let fundraiser = Self::ensure_fundraiser(offering_asset, fundraiser_id)?; + if fundraiser.creator != agent_did { + >::ensure_agent_permissioned(&offering_asset, agent_did)?; + } + ensure!(!fundraiser.is_closed(), Error::::FundraiserClosed); + + FundraiserOffchainAsset::::insert(offering_asset, fundraiser_id, ticker); + + Self::deposit_event(Event::FundraiserOffchainFundingEnabled { + agent_did, + offering_asset, + fundraiser_id, + ticker, + }); + + Ok(()) } } } impl Pallet { + fn base_invest( + origin: OriginFor, + offering_asset: AssetId, + fundraiser_id: FundraiserId, + investment_portfolio: PortfolioId, + funding: FundingMethod, + purchase_amount: Balance, + max_price: Option, + ) -> DispatchResult { + let PermissionedCallOriginData { + primary_did: investor_did, + secondary_key, + .. + } = Identity::::ensure_origin_call_permissions(origin.clone())?; + + >::ensure_portfolio_custody_and_permission( + investment_portfolio, + investor_did, + secondary_key.as_ref(), + )?; + + let mut fundraiser = Self::ensure_fundraiser(offering_asset, fundraiser_id)?; + + ensure!( + fundraiser.status == FundraiserStatus::Live, + Error::::FundraiserNotLive + ); + + let now = Timestamp::::get(); + ensure!( + fundraiser.start <= now && fundraiser.end.filter(|e| now >= *e).is_none(), + Error::::FundraiserExpired + ); + + // Remaining tokens to fulfil the investment amount + let mut remaining = purchase_amount; + // Total cost to to fulfil the investment amount. + // Primary use is to calculate the blended price (offering_amount / cost). + // Blended price must be <= to max_price or the investment will fail. + let mut cost = Balance::from(0u32); + + // Price is entered as a multiple of 1_000_000 + // i.e. a price of 1 unit is 1_000_000 + // a price of 1.5 units is 1_500_00 + let price_divisor = Balance::from(1_000_000u32); + // Individual purchases from each tier that accumulate to fulfil the investment amount. + // Tuple of (tier_id, amount to purchase from that tier). + let mut purchases = Vec::new(); + + for (id, tier) in fundraiser + .tiers + .iter() + .enumerate() + .filter(|(_, tier)| tier.remaining > 0) + { + // fulfilled the investment amount + if remaining == 0 { + break; + } + + // Check if this tier can fulfil the remaining investment amount. + // If it can, purchase the remaining amount. + // If it can't, purchase what's remaining in the tier. + let purchase_amount = if tier.remaining >= remaining { + remaining + } else { + tier.remaining + }; + + remaining -= purchase_amount; + purchases.push((id, purchase_amount)); + cost = purchase_amount + .checked_mul(tier.price) + .ok_or(Error::::Overflow)? + .checked_div(price_divisor) + .and_then(|pa| cost.checked_add(pa)) + .ok_or(Error::::Overflow)?; + } + + ensure!(remaining == 0, Error::::InsufficientTokensRemaining); + ensure!( + cost >= fundraiser.minimum_investment, + Error::::InvestmentAmountTooLow + ); + ensure!( + max_price + .map(|max_price| cost <= max_price.saturating_mul(purchase_amount) / price_divisor) + .unwrap_or(true), + Error::::MaxPriceExceeded + ); + + let mut fundraiser_portfolios = [fundraiser.offering_portfolio] + .iter() + .copied() + .collect::>(); + let mut investor_portfolios = [investment_portfolio] + .iter() + .copied() + .collect::>(); + let mut legs = vec![Leg::Fungible { + sender: fundraiser.offering_portfolio, + receiver: investment_portfolio, + asset_id: fundraiser.offering_asset, + amount: purchase_amount, + }]; + let funding_asset = match funding { + FundingMethod::OnChain(funding_portfolio) => { + >::ensure_portfolio_custody_and_permission( + funding_portfolio, + investor_did, + secondary_key.as_ref(), + )?; + fundraiser_portfolios.insert(fundraiser.raising_portfolio); + investor_portfolios.insert(funding_portfolio); + legs.push(Leg::Fungible { + sender: funding_portfolio, + receiver: fundraiser.raising_portfolio, + asset_id: fundraiser.raising_asset, + amount: cost, + }); + FundingAsset::OnChain(fundraiser.raising_asset) + } + FundingMethod::OffChain(receipt_details) => { + let ticker = FundraiserOffchainAsset::::get(&offering_asset, fundraiser_id) + .ok_or(Error::::OffchainFundingNotAllowed)?; + Settlement::::mark_receipt_as_used( + fundraiser.venue_id, + &receipt_details.signer, + receipt_details.uid, + )?; + let receipt = FundraiserReceipt::new( + receipt_details.uid, + fundraiser_id, + investor_did, + fundraiser.raising_portfolio.did, + ticker, + cost, + ); + ensure!( + verify_signature::( + &receipt_details.signer, + &receipt_details.signature, + &receipt, + true, + ), + Error::::InvalidSignature + ); + FundingAsset::OffChain(ticker) + } + }; + + >::unlock_tokens( + &fundraiser.offering_portfolio, + &fundraiser.offering_asset, + purchase_amount, + )?; + + let instruction_id = Settlement::::base_add_instruction( + fundraiser.creator, + Some(fundraiser.venue_id), + SettlementType::SettleOnAffirmation, + None, + None, + legs, + None, + None, + )?; + + Settlement::::unsafe_affirm_instruction( + fundraiser.creator, + instruction_id, + fundraiser_portfolios, + None, + None, + )?; + + Settlement::::affirm_and_execute_instruction( + origin, + instruction_id, + None, + investor_portfolios, + investor_did, + )?; + + for (id, amount) in purchases { + fundraiser.tiers[id].remaining -= amount; + } + + Self::deposit_event(Event::Invested { + investor_did, + offering_asset, + fundraiser_id, + funding_asset, + offering_amount: purchase_amount, + raise_amount: cost, + }); + + >::insert(offering_asset, fundraiser_id, fundraiser); + + Ok(()) + } + fn set_frozen( origin: T::RuntimeOrigin, offering_asset: AssetId, - id: FundraiserId, + fundraiser_id: FundraiserId, frozen: bool, ) -> DispatchResult { - let did = >::ensure_perms(origin, offering_asset)?; - let mut fundraiser = Self::ensure_fundraiser(offering_asset, id)?; + let agent_did = >::ensure_perms(origin, offering_asset)?; + let mut fundraiser = Self::ensure_fundraiser(offering_asset, fundraiser_id)?; ensure!(!fundraiser.is_closed(), Error::::FundraiserClosed); if frozen { fundraiser.status = FundraiserStatus::Frozen; - Self::deposit_event(Event::FundraiserFrozen(did, id)); + Self::deposit_event(Event::FundraiserFrozen { + agent_did, + offering_asset, + fundraiser_id, + }); } else { fundraiser.status = FundraiserStatus::Live; - Self::deposit_event(Event::FundraiserUnfrozen(did, id)); + Self::deposit_event(Event::FundraiserUnfrozen { + agent_did, + offering_asset, + fundraiser_id, + }); } - >::insert(offering_asset, id, fundraiser); + >::insert(offering_asset, fundraiser_id, fundraiser); Ok(()) } @@ -769,6 +1106,6 @@ impl Pallet { asset_id: AssetId, id: FundraiserId, ) -> Result, DispatchError> { - Fundraisers::::get(asset_id, id).ok_or_else(|| Error::::FundraiserNotFound.into()) + Ok(Fundraisers::::get(asset_id, id).ok_or_else(|| Error::::FundraiserNotFound)?) } } diff --git a/pallets/weights/src/pallet_sto.rs b/pallets/weights/src/pallet_sto.rs index 3cfd41ad70..71b160b97d 100644 --- a/pallets/weights/src/pallet_sto.rs +++ b/pallets/weights/src/pallet_sto.rs @@ -89,86 +89,172 @@ impl pallet_sto::WeightInfo for SubstrateWeight { .saturating_add(DbWeight::get().writes(4)) } // Storage: Identity KeyRecords (r:1 w:0) - // Proof Skipped: Identity KeyRecords (max_values: None, max_size: None, mode: Measured) + // Proof: Identity KeyRecords (max_values: None, max_size: Some(73), added: 2548, mode: MaxEncodedLen) // Storage: Portfolio PortfolioCustodian (r:4 w:0) - // Proof Skipped: Portfolio PortfolioCustodian (max_values: None, max_size: None, mode: Measured) + // Proof: Portfolio PortfolioCustodian (max_values: None, max_size: Some(81), added: 2556, mode: MaxEncodedLen) // Storage: Sto Fundraisers (r:1 w:1) // Proof Skipped: Sto Fundraisers (max_values: None, max_size: None, mode: Measured) // Storage: Timestamp Now (r:1 w:0) // Proof: Timestamp Now (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) // Storage: Portfolio PortfolioLockedAssets (r:2 w:2) - // Proof Skipped: Portfolio PortfolioLockedAssets (max_values: None, max_size: None, mode: Measured) + // Proof: Portfolio PortfolioLockedAssets (max_values: None, max_size: Some(97), added: 2572, mode: MaxEncodedLen) // Storage: Settlement VenueInfo (r:1 w:0) - // Proof Skipped: Settlement VenueInfo (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement VenueInfo (max_values: None, max_size: Some(49), added: 2524, mode: MaxEncodedLen) // Storage: Asset Assets (r:2 w:0) // Proof Skipped: Asset Assets (max_values: None, max_size: None, mode: Measured) // Storage: Settlement VenueFiltering (r:2 w:0) - // Proof Skipped: Settlement VenueFiltering (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement VenueFiltering (max_values: None, max_size: Some(33), added: 2508, mode: MaxEncodedLen) // Storage: Identity DidRecords (r:2 w:0) - // Proof Skipped: Identity DidRecords (max_values: None, max_size: None, mode: Measured) + // Proof: Identity DidRecords (max_values: None, max_size: Some(65), added: 2540, mode: MaxEncodedLen) // Storage: Portfolio Portfolios (r:4 w:0) // Proof Skipped: Portfolio Portfolios (max_values: None, max_size: None, mode: Measured) // Storage: Asset AssetsExemptFromAffirmation (r:2 w:0) - // Proof Skipped: Asset AssetsExemptFromAffirmation (max_values: None, max_size: None, mode: Measured) + // Proof: Asset AssetsExemptFromAffirmation (max_values: None, max_size: Some(33), added: 2508, mode: MaxEncodedLen) // Storage: Asset PreApprovedAsset (r:2 w:0) - // Proof Skipped: Asset PreApprovedAsset (max_values: None, max_size: None, mode: Measured) + // Proof: Asset PreApprovedAsset (max_values: None, max_size: Some(65), added: 2540, mode: MaxEncodedLen) // Storage: Portfolio PreApprovedPortfolios (r:2 w:0) - // Proof Skipped: Portfolio PreApprovedPortfolios (max_values: None, max_size: None, mode: Measured) + // Proof: Portfolio PreApprovedPortfolios (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) // Storage: Asset MandatoryMediators (r:2 w:0) - // Proof Skipped: Asset MandatoryMediators (max_values: None, max_size: None, mode: Measured) + // Proof: Asset MandatoryMediators (max_values: None, max_size: Some(161), added: 2636, mode: MaxEncodedLen) // Storage: Settlement InstructionCounter (r:1 w:1) - // Proof Skipped: Settlement InstructionCounter (max_values: Some(1), max_size: None, mode: Measured) + // Proof: Settlement InstructionCounter (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) // Storage: Settlement InstructionLegs (r:3 w:2) // Proof Skipped: Settlement InstructionLegs (max_values: None, max_size: None, mode: Measured) // Storage: Portfolio PortfolioAssetBalances (r:4 w:4) - // Proof Skipped: Portfolio PortfolioAssetBalances (max_values: None, max_size: None, mode: Measured) + // Proof: Portfolio PortfolioAssetBalances (max_values: None, max_size: Some(97), added: 2572, mode: MaxEncodedLen) // Storage: Settlement InstructionMediatorsAffirmations (r:1 w:0) - // Proof Skipped: Settlement InstructionMediatorsAffirmations (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement InstructionMediatorsAffirmations (max_values: None, max_size: Some(58), added: 2533, mode: MaxEncodedLen) // Storage: Settlement InstructionMemos (r:1 w:0) - // Proof Skipped: Settlement InstructionMemos (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement InstructionMemos (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) // Storage: Asset BalanceOf (r:4 w:4) - // Proof Skipped: Asset BalanceOf (max_values: None, max_size: None, mode: Measured) + // Proof: Asset BalanceOf (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) // Storage: Asset Frozen (r:2 w:0) - // Proof Skipped: Asset Frozen (max_values: None, max_size: None, mode: Measured) - // Storage: Instance2Group ActiveMembers (r:1 w:0) - // Proof Skipped: Instance2Group ActiveMembers (max_values: Some(1), max_size: None, mode: Measured) + // Proof: Asset Frozen (max_values: None, max_size: Some(33), added: 2508, mode: MaxEncodedLen) + // Storage: CddServiceProviders ActiveMembers (r:1 w:0) + // Proof Skipped: CddServiceProviders ActiveMembers (max_values: Some(1), max_size: None, mode: Measured) // Storage: Identity Claims (r:46 w:0) // Proof Skipped: Identity Claims (max_values: None, max_size: None, mode: Measured) // Storage: Statistics AssetTransferCompliances (r:2 w:0) - // Proof Skipped: Statistics AssetTransferCompliances (max_values: None, max_size: None, mode: Measured) + // Proof: Statistics AssetTransferCompliances (max_values: None, max_size: Some(246), added: 2721, mode: MaxEncodedLen) // Storage: Statistics AssetStats (r:28 w:20) - // Proof Skipped: Statistics AssetStats (max_values: None, max_size: None, mode: Measured) + // Proof: Statistics AssetStats (max_values: None, max_size: Some(107), added: 2582, mode: MaxEncodedLen) // Storage: ComplianceManager AssetCompliances (r:2 w:0) // Proof Skipped: ComplianceManager AssetCompliances (max_values: None, max_size: None, mode: Measured) // Storage: Checkpoint CachedNextCheckpoints (r:2 w:0) // Proof Skipped: Checkpoint CachedNextCheckpoints (max_values: None, max_size: None, mode: Measured) // Storage: Checkpoint CheckpointIdSequence (r:2 w:0) - // Proof Skipped: Checkpoint CheckpointIdSequence (max_values: None, max_size: None, mode: Measured) + // Proof: Checkpoint CheckpointIdSequence (max_values: None, max_size: Some(40), added: 2515, mode: MaxEncodedLen) // Storage: Portfolio PortfolioAssetCount (r:2 w:2) - // Proof Skipped: Portfolio PortfolioAssetCount (max_values: None, max_size: None, mode: Measured) + // Proof: Portfolio PortfolioAssetCount (max_values: None, max_size: Some(57), added: 2532, mode: MaxEncodedLen) // Storage: Statistics ActiveAssetStats (r:2 w:0) - // Proof Skipped: Statistics ActiveAssetStats (max_values: None, max_size: None, mode: Measured) + // Proof: Statistics ActiveAssetStats (max_values: None, max_size: Some(423), added: 2898, mode: MaxEncodedLen) // Storage: Settlement UserAffirmations (r:0 w:4) - // Proof Skipped: Settlement UserAffirmations (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement UserAffirmations (max_values: None, max_size: Some(66), added: 2541, mode: MaxEncodedLen) // Storage: Settlement InstructionAffirmsPending (r:0 w:1) - // Proof Skipped: Settlement InstructionAffirmsPending (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement InstructionAffirmsPending (max_values: None, max_size: Some(24), added: 2499, mode: MaxEncodedLen) // Storage: Settlement InstructionStatuses (r:0 w:1) - // Proof Skipped: Settlement InstructionStatuses (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement InstructionStatuses (max_values: None, max_size: Some(21), added: 2496, mode: MaxEncodedLen) // Storage: Settlement InstructionDetails (r:0 w:1) - // Proof Skipped: Settlement InstructionDetails (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement InstructionDetails (max_values: None, max_size: Some(65), added: 2540, mode: MaxEncodedLen) // Storage: Settlement VenueInstructions (r:0 w:1) - // Proof Skipped: Settlement VenueInstructions (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement VenueInstructions (max_values: None, max_size: Some(32), added: 2507, mode: MaxEncodedLen) // Storage: Settlement AffirmsReceived (r:0 w:4) - // Proof Skipped: Settlement AffirmsReceived (max_values: None, max_size: None, mode: Measured) + // Proof: Settlement AffirmsReceived (max_values: None, max_size: Some(66), added: 2541, mode: MaxEncodedLen) // Storage: Settlement InstructionLegStatus (r:0 w:2) - // Proof Skipped: Settlement InstructionLegStatus (max_values: None, max_size: None, mode: Measured) - fn invest() -> Weight { - // Minimum execution time: 1_051_755 nanoseconds. - Weight::from_ref_time(1_059_396_000) + // Proof: Settlement InstructionLegStatus (max_values: None, max_size: Some(73), added: 2548, mode: MaxEncodedLen) + fn invest_onchain() -> Weight { + // Minimum execution time: 286_101 nanoseconds. + Weight::from_ref_time(301_972_000) .saturating_add(DbWeight::get().reads(131)) .saturating_add(DbWeight::get().writes(50)) } // Storage: Identity KeyRecords (r:1 w:0) + // Proof: Identity KeyRecords (max_values: None, max_size: Some(73), added: 2548, mode: MaxEncodedLen) + // Storage: Portfolio PortfolioCustodian (r:2 w:0) + // Proof: Portfolio PortfolioCustodian (max_values: None, max_size: Some(81), added: 2556, mode: MaxEncodedLen) + // Storage: Sto Fundraisers (r:1 w:1) + // Proof Skipped: Sto Fundraisers (max_values: None, max_size: None, mode: Measured) + // Storage: Timestamp Now (r:1 w:0) + // Proof: Timestamp Now (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) + // Storage: Sto FundraiserOffchainAsset (r:1 w:0) + // Proof: Sto FundraiserOffchainAsset (max_values: None, max_size: Some(60), added: 2535, mode: MaxEncodedLen) + // Storage: Settlement VenueSigners (r:1 w:0) + // Proof: Settlement VenueSigners (max_values: None, max_size: Some(57), added: 2532, mode: MaxEncodedLen) + // Storage: Settlement ReceiptsUsed (r:1 w:1) + // Proof: Settlement ReceiptsUsed (max_values: None, max_size: Some(65), added: 2540, mode: MaxEncodedLen) + // Storage: Portfolio PortfolioLockedAssets (r:1 w:1) + // Proof: Portfolio PortfolioLockedAssets (max_values: None, max_size: Some(97), added: 2572, mode: MaxEncodedLen) + // Storage: Settlement VenueInfo (r:1 w:0) + // Proof: Settlement VenueInfo (max_values: None, max_size: Some(49), added: 2524, mode: MaxEncodedLen) + // Storage: Asset Assets (r:1 w:0) + // Proof Skipped: Asset Assets (max_values: None, max_size: None, mode: Measured) + // Storage: Settlement VenueFiltering (r:1 w:0) + // Proof: Settlement VenueFiltering (max_values: None, max_size: Some(33), added: 2508, mode: MaxEncodedLen) + // Storage: Identity DidRecords (r:2 w:0) + // Proof: Identity DidRecords (max_values: None, max_size: Some(65), added: 2540, mode: MaxEncodedLen) + // Storage: Portfolio Portfolios (r:2 w:0) + // Proof Skipped: Portfolio Portfolios (max_values: None, max_size: None, mode: Measured) + // Storage: Asset AssetsExemptFromAffirmation (r:1 w:0) + // Proof: Asset AssetsExemptFromAffirmation (max_values: None, max_size: Some(33), added: 2508, mode: MaxEncodedLen) + // Storage: Asset PreApprovedAsset (r:1 w:0) + // Proof: Asset PreApprovedAsset (max_values: None, max_size: Some(65), added: 2540, mode: MaxEncodedLen) + // Storage: Portfolio PreApprovedPortfolios (r:1 w:0) + // Proof: Portfolio PreApprovedPortfolios (max_values: None, max_size: Some(82), added: 2557, mode: MaxEncodedLen) + // Storage: Asset MandatoryMediators (r:1 w:0) + // Proof: Asset MandatoryMediators (max_values: None, max_size: Some(161), added: 2636, mode: MaxEncodedLen) + // Storage: Settlement InstructionCounter (r:1 w:1) + // Proof: Settlement InstructionCounter (max_values: Some(1), max_size: Some(8), added: 503, mode: MaxEncodedLen) + // Storage: Settlement InstructionLegs (r:2 w:1) + // Proof Skipped: Settlement InstructionLegs (max_values: None, max_size: None, mode: Measured) + // Storage: Portfolio PortfolioAssetBalances (r:2 w:2) + // Proof: Portfolio PortfolioAssetBalances (max_values: None, max_size: Some(97), added: 2572, mode: MaxEncodedLen) + // Storage: Settlement InstructionMediatorsAffirmations (r:1 w:0) + // Proof: Settlement InstructionMediatorsAffirmations (max_values: None, max_size: Some(58), added: 2533, mode: MaxEncodedLen) + // Storage: Settlement InstructionMemos (r:1 w:0) + // Proof: Settlement InstructionMemos (max_values: None, max_size: Some(48), added: 2523, mode: MaxEncodedLen) + // Storage: Asset BalanceOf (r:2 w:2) + // Proof: Asset BalanceOf (max_values: None, max_size: Some(80), added: 2555, mode: MaxEncodedLen) + // Storage: Asset Frozen (r:1 w:0) + // Proof: Asset Frozen (max_values: None, max_size: Some(33), added: 2508, mode: MaxEncodedLen) + // Storage: CddServiceProviders ActiveMembers (r:1 w:0) + // Proof Skipped: CddServiceProviders ActiveMembers (max_values: Some(1), max_size: None, mode: Measured) + // Storage: Identity Claims (r:25 w:0) + // Proof Skipped: Identity Claims (max_values: None, max_size: None, mode: Measured) + // Storage: Statistics AssetTransferCompliances (r:1 w:0) + // Proof: Statistics AssetTransferCompliances (max_values: None, max_size: Some(246), added: 2721, mode: MaxEncodedLen) + // Storage: Statistics AssetStats (r:14 w:10) + // Proof: Statistics AssetStats (max_values: None, max_size: Some(107), added: 2582, mode: MaxEncodedLen) + // Storage: ComplianceManager AssetCompliances (r:1 w:0) + // Proof Skipped: ComplianceManager AssetCompliances (max_values: None, max_size: None, mode: Measured) + // Storage: Checkpoint CachedNextCheckpoints (r:1 w:0) + // Proof Skipped: Checkpoint CachedNextCheckpoints (max_values: None, max_size: None, mode: Measured) + // Storage: Checkpoint CheckpointIdSequence (r:1 w:0) + // Proof: Checkpoint CheckpointIdSequence (max_values: None, max_size: Some(40), added: 2515, mode: MaxEncodedLen) + // Storage: Portfolio PortfolioAssetCount (r:1 w:1) + // Proof: Portfolio PortfolioAssetCount (max_values: None, max_size: Some(57), added: 2532, mode: MaxEncodedLen) + // Storage: Statistics ActiveAssetStats (r:1 w:0) + // Proof: Statistics ActiveAssetStats (max_values: None, max_size: Some(423), added: 2898, mode: MaxEncodedLen) + // Storage: Settlement UserAffirmations (r:0 w:2) + // Proof: Settlement UserAffirmations (max_values: None, max_size: Some(66), added: 2541, mode: MaxEncodedLen) + // Storage: Settlement InstructionAffirmsPending (r:0 w:1) + // Proof: Settlement InstructionAffirmsPending (max_values: None, max_size: Some(24), added: 2499, mode: MaxEncodedLen) + // Storage: Settlement InstructionStatuses (r:0 w:1) + // Proof: Settlement InstructionStatuses (max_values: None, max_size: Some(21), added: 2496, mode: MaxEncodedLen) + // Storage: Settlement InstructionDetails (r:0 w:1) + // Proof: Settlement InstructionDetails (max_values: None, max_size: Some(65), added: 2540, mode: MaxEncodedLen) + // Storage: Settlement VenueInstructions (r:0 w:1) + // Proof: Settlement VenueInstructions (max_values: None, max_size: Some(32), added: 2507, mode: MaxEncodedLen) + // Storage: Settlement AffirmsReceived (r:0 w:2) + // Proof: Settlement AffirmsReceived (max_values: None, max_size: Some(66), added: 2541, mode: MaxEncodedLen) + // Storage: Settlement InstructionLegStatus (r:0 w:1) + // Proof: Settlement InstructionLegStatus (max_values: None, max_size: Some(73), added: 2548, mode: MaxEncodedLen) + fn invest_offchain() -> Weight { + // Minimum execution time: 222_178 nanoseconds. + Weight::from_ref_time(230_675_000) + .saturating_add(DbWeight::get().reads(76)) + .saturating_add(DbWeight::get().writes(29)) + } + // Storage: Identity KeyRecords (r:1 w:0) // Proof Skipped: Identity KeyRecords (max_values: None, max_size: None, mode: Measured) // Storage: ExternalAgents GroupOfAgent (r:1 w:0) // Proof Skipped: ExternalAgents GroupOfAgent (max_values: None, max_size: None, mode: Measured) @@ -232,4 +318,16 @@ impl pallet_sto::WeightInfo for SubstrateWeight { .saturating_add(DbWeight::get().reads(4)) .saturating_add(DbWeight::get().writes(2)) } + // Storage: Identity KeyRecords (r:1 w:0) + // Proof: Identity KeyRecords (max_values: None, max_size: Some(73), added: 2548, mode: MaxEncodedLen) + // Storage: Sto Fundraisers (r:1 w:0) + // Proof Skipped: Sto Fundraisers (max_values: None, max_size: None, mode: Measured) + // Storage: Sto FundraiserOffchainAsset (r:0 w:1) + // Proof: Sto FundraiserOffchainAsset (max_values: None, max_size: Some(60), added: 2535, mode: MaxEncodedLen) + fn enable_offchain_funding() -> Weight { + // Minimum execution time: 9_769 nanoseconds. + Weight::from_ref_time(10_771_000) + .saturating_add(DbWeight::get().reads(2)) + .saturating_add(DbWeight::get().writes(1)) + } } diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index c538124401..7be2931e2e 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -301,6 +301,9 @@ parameter_types! { /// Settlement type definitions. pub mod settlement; +/// STO type definitions. +pub mod sto; + /// Constants definitions. pub mod constants; pub use constants::{SystematicIssuers, GC_DID, SYSTEMATIC_ISSUERS, TECHNICAL_DID, UPGRADE_DID}; diff --git a/primitives/src/sto.rs b/primitives/src/sto.rs new file mode 100644 index 0000000000..bc2a6c9ee1 --- /dev/null +++ b/primitives/src/sto.rs @@ -0,0 +1,115 @@ +// This file is part of the Polymesh distribution (https://github.com/PolymeshAssociation/Polymesh). +// Copyright (c) 2024 Polymesh Association + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. + +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; + +use crate::settlement::ReceiptMetadata; +use crate::{impl_checked_inc, IdentityId, Ticker}; + +/// The per-AssetId ID of a fundraiser. +#[derive(Encode, Decode, TypeInfo, MaxEncodedLen)] +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)] +pub struct FundraiserId(pub u64); +impl_checked_inc!(FundraiserId); + +/// An offchain fundraiser receipt. +#[derive( + Encode, + Decode, + MaxEncodedLen, + Clone, + PartialEq, + Eq, + Debug, + PartialOrd, + Ord +)] +pub struct FundraiserReceipt { + /// Unique receipt number set by the signer for their receipts. + uid: u64, + /// The [`FundraiserId`] of the STO fundraiser for which the receipt is for. + fundraiser_id: FundraiserId, + /// The [`IdentityId`] of the sender. + sender_identity: IdentityId, + /// The [`IdentityId`] of the receiver. + receiver_identity: IdentityId, + /// [`Ticker`] of the asset being transferred. + ticker: Ticker, + /// The amount transferred. + amount: Balance, +} + +impl FundraiserReceipt { + /// Creates a new [`FundraiserReceipt`]. + pub fn new( + uid: u64, + fundraiser_id: FundraiserId, + sender_identity: IdentityId, + receiver_identity: IdentityId, + ticker: Ticker, + amount: Balance, + ) -> Self { + Self { + uid, + fundraiser_id, + sender_identity, + receiver_identity, + ticker, + amount, + } + } +} + +/// Details about an offchain transaction receipt. +#[derive( + Encode, + Decode, + MaxEncodedLen, + TypeInfo, + Clone, + PartialEq, + Eq, + Debug, + PartialOrd, + Ord +)] +pub struct FundraiserReceiptDetails { + /// Unique receipt number set by the signer for their receipts + pub uid: u64, + /// The [`AccountId`] of the Signer for this receipt. + pub signer: AccountId, + /// Signature confirming the receipt details. + pub signature: OffChainSignature, + /// The [`ReceiptMetadata`] that can be used to attach messages to receipts. + pub metadata: Option, +} + +impl FundraiserReceiptDetails { + /// Creates a new [`FundraiserReceiptDetails`]. + pub fn new( + uid: u64, + signer: AccountId, + signature: OffChainSignature, + metadata: Option, + ) -> Self { + Self { + uid, + signer, + signature, + metadata, + } + } +}