Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funding-browse-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": minor
---

Products published to browse whose Worker manifest serves Funding join `funding_candidates` after the host's own providers. The core reads the browse Publisher list, compiled in per network, through the same day-long dotNS cache as manifests.
5 changes: 5 additions & 0 deletions .changeset/funding-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": minor
---

The core reads Worker manifests v1 and v2 from dotNS, with the `includes.funding` configuration, cached for a day. Hosts list their funding providers with `set_funding_providers`, each with a Worker manifest snapshot, and read `funding_candidates(intent)`; `select_funding_provider` accepts only a candidate serving the session's direction.
5 changes: 5 additions & 0 deletions .changeset/worker-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@parity/truapi": minor
---

The core reads a product's Worker manifest from dotNS and caches it for a day like the root manifest. `worker_manifest(product_id)` on the pairing and signing host runtimes, and natively, answers its entrypoint, its Pocket, Chat and Input flags, and its `includes.funding` configuration with unrecognised values left out.
16 changes: 15 additions & 1 deletion rust/crates/truapi-codegen/tests/golden/host-callbacks.ts

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

3 changes: 2 additions & 1 deletion rust/crates/truapi-host-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,8 @@ Scripts under `js/scripts/` include:
(`deliver:900,release:500,fail,dismiss,provide,provide,provide-cancel`). A
`deliver`, `release` or `fail` session is settled through the core's test
hook with no chain behind it. A `provide` session is handed to the product
that asked, which runs as a Worker and serves it as the provider: it reports
that asked, which `TRUAPI_FUNDING_PROVIDERS` offers as a provider with a
scripted Worker manifest seeded into the core's cache, which runs as a Worker and serves it as the provider: it reports
progress, starts top-ups and payment requests, which scripted engines
complete in full and record in `TRUAPI_FUNDING_LEDGER`, and the core settles
the session from them. `provide-cancel` also cancels it. The phase runs the
Expand Down
1 change: 1 addition & 0 deletions rust/crates/truapi-host-cli/js/funding-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface TranscriptLine {
outcome?: string;
tag?: string;
route?: string;
providers?: string[];
}

/** What the host recorded. */
Expand Down
10 changes: 9 additions & 1 deletion rust/crates/truapi-host-cli/js/funding-provider-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,15 @@ export async function runProviderStart(
) {
throw new Error(`unexpected assignment ${stringify(item)}`);
}
return "the provider's worker was handed the session it was chosen for";
await waitForTranscript(
fundingLogPath,
(line) =>
line.kind === "candidates" &&
line.intent === inbound &&
(line.providers ?? []).length > 0,
`the provider list for ${inbound}`,
);
return "the host listed the provider and handed it the session it was chosen for";
});

await check("provider_present_frame", async () => {
Expand Down
57 changes: 54 additions & 3 deletions rust/crates/truapi-host-cli/src/funding_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
//! product's side of funding headlessly.
//!
//! `provide` and `provide-cancel` instead hand the session to the product that
//! asked, so a provider worker can serve its own request; `provide-cancel`
//! asked, which `TRUAPI_FUNDING_PROVIDERS` must name as a provider, so a
//! provider worker can serve its own request; `provide-cancel`
//! then cancels it, as a user would. Top-ups and payment requests that worker
//! starts are accepted and complete in full, recorded in the ledger named by
//! `TRUAPI_FUNDING_LEDGER` so their statuses outlive a restart.
Expand All @@ -29,6 +30,7 @@ use std::time::Duration;
use futures::stream::{self, BoxStream, StreamExt};
use truapi::SigningHostRuntime;
use truapi::host_logic::funding::FundingStage;
use truapi::host_logic::funding_providers::FundingProviderEntry;
use truapi::latest::{
FundingDirection, FundingFailure, FundingFrameOutcome, GenericError,
HostFundingStatusSubscribeItem, HostPaymentError, HostPaymentRequest,
Expand All @@ -37,10 +39,49 @@ use truapi::latest::{
HostPaymentTopUpStatusSubscribeItem,
};
use truapi::platform::{
FundingPlatform, FundingPresentOutcome, FundingPresentation, PaymentPlatform, ProductContext,
TopUpPlatform, async_trait,
CoreStorage, FundingPlatform, FundingPresentOutcome, FundingPresentation, PaymentPlatform,
ProductContext, TopUpPlatform, async_trait,
};

/// Worker manifest every scripted provider publishes: card in, and crypto in
/// and out, quoted by its worker.
const SCRIPTED_PROVIDER_MANIFEST: &str = r#"{"$v":1,"appVersion":[1,0,0],"kind":"worker","entrypoint":"index.js","includes":{"funding":{"routes":[{"mode":"CARD","directions":["In"],"assets":["EUR"]},{"mode":"CRYPTO","directions":["In","Out"],"assets":["USDT"]}]}}}"#;

/// Offer each product named in `TRUAPI_FUNDING_PROVIDERS` (comma-separated)
/// as a funding provider publishing [`SCRIPTED_PROVIDER_MANIFEST`], seeded
/// into the core's manifest cache as though read from dotNS, so the core
/// neither reads the chain for it nor drops it for publishing nothing there.
pub async fn offer_scripted_providers(
runtime: &SigningHostRuntime,
platform: &dyn CoreStorage,
) -> anyhow::Result<()> {
let Ok(spec) = std::env::var("TRUAPI_FUNDING_PROVIDERS") else {
return Ok(());
};
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
let mut providers = Vec::new();
for product_id in spec.split(',').map(str::trim).filter(|id| !id.is_empty()) {
platform
.write_core_storage(
truapi::worker_manifest_cache_key(product_id),
truapi::encode_cached_root_manifest(Some(SCRIPTED_PROVIDER_MANIFEST), now_secs),
)
.await
.map_err(|error| {
anyhow::anyhow!("seeding the funding provider {product_id}: {error:?}")
})?;
providers.push(FundingProviderEntry {
product_id: product_id.to_string(),
worker_manifest: Some(SCRIPTED_PROVIDER_MANIFEST.to_string()),
});
}
runtime
.set_funding_providers(providers)
.map_err(|error| anyhow::anyhow!("offering funding providers: {}", error.reason))
}

/// How long a started session stays in flight before it is settled, so a
/// product sees its first status before the terminal one.
const SETTLE_AFTER: Duration = Duration::from_millis(500);
Expand Down Expand Up @@ -143,6 +184,16 @@ impl CliFundingHost {
else {
return Ok(FundingPresentOutcome::Dismissed);
};
let candidates: Vec<String> = runtime
.funding_candidates(&intent)
.into_iter()
.map(|candidate| candidate.provider_id)
.collect();
self.record(serde_json::json!({
"kind": "candidates",
"intent": intent,
"providers": candidates,
}));
runtime
.select_funding_provider(&intent, &product.product_id)
.await?;
Expand Down
2 changes: 2 additions & 0 deletions rust/crates/truapi-host-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1710,6 +1710,8 @@ async fn start_signing_host(
pocket.clone(),
)?;
apply_local_product_grants(platform.as_ref(), &args.product_config).await?;
#[cfg(feature = "test-host")]
funding_host::offer_scripted_providers(&runtime, platform.as_ref()).await?;
let runtime_factory = frame_server::SwitchableSigningRuntime::new(runtime.clone());
let last_script = profile
.as_ref()
Expand Down
14 changes: 12 additions & 2 deletions rust/crates/truapi/RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,15 +406,25 @@ AutoSigning without approval. Legacy-account signing still asks the user.
Native hosts reach this through `NativeTrUApiHostRuntime`:
`set_funding_callbacks` (the overlay), `set_top_up_callbacks` with
`notify_top_up_status` (the top-up engine), `open_funding`,
`funding_session`, `funding_sessions`, `select_funding_provider`,
`funding_session`, `funding_sessions`, `set_funding_providers`,
`funding_candidates`, `select_funding_provider`,
`cancel_funding` and `acknowledge_funding_session`. Amounts cross the FFI as decimal strings.
`funding_sessions` lists sessions in flight first, then ended ones, each
newest first. An ended session is handed to the host through
`funding_session_changed` each time funding resumes until the host calls
`acknowledge_funding_session`, so its history writes every outcome once;
the core keeps the 50 newest recorded sessions and every unrecorded one
within the 200 newest ended.
`select_funding_provider` hands a session to the provider the user chose.
The host lists its providers with `set_funding_providers`, each with the
Worker manifest it ships for it. `funding_candidates(intent)` answers the
providers serving that session's direction, with their routes, from what the
`includes.funding` configuration of each provider's Worker manifest declares:
dotNS's answer once the core has read it (cached for a day, re-checked in the
background on every query), the shipped snapshot until then, so the list
renders with no chain read. A provider whose manifest no longer serves
Funding drops out.
`select_funding_provider` hands a session to the provider the user chose,
which must be one of those candidates.
That provider's worker runs it through the `FundingProvider` trait:
`serveSubscribe` replays its sessions in flight and then streams new ones
and cancel requests, `report` stores each update on the session (only from
Expand Down
63 changes: 63 additions & 0 deletions rust/crates/truapi/src/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,23 @@ pub struct PairingHostRuntime {
}

impl PairingHostRuntime {
/// `product_id`'s Worker manifest from dotNS, cached for a day like the
/// root manifest. `Ok(None)` when the product publishes no Worker, or one
/// this core cannot use; `Err` when dotNS could not be read.
#[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.worker_manifest"))]
pub async fn worker_manifest(
&self,
product_id: &str,
) -> Result<Option<crate::host_logic::worker_manifest::WorkerManifest>, v01::GenericError> {
crate::runtime::product_manifest::worker_manifest(
&self.services,
self.services.platform.as_ref(),
product_id,
)
.await
.map_err(|reason| v01::GenericError { reason })
}

/// Build a long-lived pairing-host runtime around a platform implementation.
#[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.new"))]
pub fn new<P>(platform: Arc<P>, config: PairingHostConfig, spawner: Spawner) -> Self
Expand Down Expand Up @@ -613,6 +630,23 @@ pub struct SigningHostRuntime {
}

impl SigningHostRuntime {
/// `product_id`'s Worker manifest from dotNS, cached for a day like the
/// root manifest. `Ok(None)` when the product publishes no Worker, or one
/// this core cannot use; `Err` when dotNS could not be read.
#[instrument(skip_all, fields(runtime.method = "signing_host_runtime.worker_manifest"))]
pub async fn worker_manifest(
&self,
product_id: &str,
) -> Result<Option<crate::host_logic::worker_manifest::WorkerManifest>, v01::GenericError> {
crate::runtime::product_manifest::worker_manifest(
&self.services,
self.services.platform.as_ref(),
product_id,
)
.await
.map_err(|reason| v01::GenericError { reason })
}

/// Answer resource allocation as granted without performing it.
///
/// For test hosts only, with the `test-host` feature enabled.
Expand Down Expand Up @@ -817,6 +851,35 @@ impl SigningHostRuntime {
})
}

/// Replace the funding providers this host offers, each with the Worker
/// manifest it ships for it. The core re-reads each one from dotNS in the
/// background, and that answer wins once it has one.
#[instrument(skip_all, fields(runtime.method = "signing_host_runtime.set_funding_providers"))]
pub fn set_funding_providers(
&self,
providers: Vec<crate::host_logic::funding_providers::FundingProviderEntry>,
) -> Result<(), v01::GenericError> {
self.services
.set_funding_providers(providers)
.map_err(|err| v01::GenericError {
reason: err.to_string(),
})
}

/// The providers funding session `intent` can be handed to, with the
/// routes that serve its direction, in the host's order. Empty for a
/// session the core does not know.
pub fn funding_candidates(
&self,
intent: &str,
) -> Vec<crate::host_logic::funding_providers::FundingCandidate> {
self.services
.funding()
.get(intent)
.map(|session| self.services.funding_candidates(session.direction))
.unwrap_or_default()
}

/// Hand open funding session `intent` to the provider the user chose,
/// by product id. Returns whether it was open and not yet assigned.
#[instrument(skip_all, fields(runtime.method = "signing_host_runtime.select_funding_provider"))]
Expand Down
2 changes: 2 additions & 0 deletions rust/crates/truapi/src/host_logic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ pub mod dotns_gateway;
pub mod entropy;
pub mod features;
pub mod funding;
pub mod funding_providers;
pub mod product_account;
pub mod raw_signing;
pub mod session;
pub mod session_store;
pub mod sso;
pub mod statement_store;
pub mod worker;
pub mod worker_manifest;
23 changes: 23 additions & 0 deletions rust/crates/truapi/src/host_logic/dotns_gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,24 @@ pub fn decode_string_array(data: &[u8]) -> Result<Vec<String>, DotnsContractErro
.collect()
}

/// Decodes an ABI `bytes32[]`, such as a page of `Publisher.getPublished`.
pub fn decode_bytes32_array(data: &[u8]) -> Result<Vec<[u8; 32]>, DotnsContractError> {
let array_at = word_usize(data, 0)?;
let array = data.get(array_at..).ok_or(DotnsContractError::Abi {
context: "array offset",
})?;
let len = word_usize(array, 0)?;
(1..=len)
.map(|index| {
word(array, index).map(|bytes| {
let mut value = [0u8; 32];
value.copy_from_slice(bytes);
value
})
})
.collect()
}

/// Decodes one `DotnsPopController.pendingClaims(address,uint256,uint256)`
/// page, an ABI `(string label, uint64 mintedAt)[]`, as `(label, minted_at)`
/// pairs.
Expand Down Expand Up @@ -1242,6 +1260,11 @@ mod tests {
Vec::<String>::new()
);

// bytes32[] = [0x11.., 0x22..]; a truncated one is refused.
let hashes = [abi_word(0x20).to_vec(), abi_word(2).to_vec(), vec![0x11; 32], vec![0x22; 32]].concat();
assert_eq!(decode_bytes32_array(&hashes).unwrap(), vec![[0x11; 32], [0x22; 32]]);
assert!(decode_bytes32_array(&hashes[..hashes.len() - 1]).is_err());

// pendingClaims = [("alice01", 42), ("bob", 7)].
let struct_a = [
abi_word(0x40).to_vec(),
Expand Down
Loading
Loading