Skip to content
Merged
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
26 changes: 20 additions & 6 deletions backend/src/schema/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ pub struct Protocol {
name: String,
scope: String,
repository_url: Option<String>,
/// Project homepage, read from the `org.opencontainers.image.url`
/// annotation of the published OCI manifest. Populated on the detail
/// query (which pulls the manifest); `None` on list queries.
homepage_url: Option<String>,
published_date: i64,
version: String,
readme: Option<String>,
Expand Down Expand Up @@ -131,21 +135,31 @@ pub struct Tx {

#[ComplexObject]
impl Protocol {
/// Transactions in a stable order (sorted by name). The TII stores them in
/// a map, whose iteration order must never leak into the API: it would
/// reshuffle the UI on every load.
async fn transactions(&self) -> Vec<Tx> {
if let Some(tii) = &self.tii {
return self.transactions_from_tii(tii);
}
let mut txs = if let Some(tii) = &self.tii {
self.transactions_from_tii(tii)
} else {
self.transactions_from_source()
};

self.transactions_from_source()
txs.sort_by(|a, b| a.name.cmp(&b.name));
txs
}

/// Parties in a stable order (sorted by name); see [`Self::transactions`].
async fn parties(&self) -> Vec<Party> {
let Some(tii) = &self.tii else { return vec![] };

tii.parties.iter().map(|(name, party)| Party {
let mut parties: Vec<Party> = tii.parties.iter().map(|(name, party)| Party {
name: name.clone(),
description: party.description.clone(),
}).collect()
}).collect();

parties.sort_by(|a, b| a.name.cmp(&b.name));
parties
}

async fn profiles(&self) -> Vec<Profile> {
Expand Down
14 changes: 14 additions & 0 deletions backend/src/schema/protocol/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ pub async fn build_protocol(resolved: ResolvedProtocol) -> Result<(Protocol, Ima
let tii = oci::get_tii(&oci_image)
.and_then(|json| serde_json::from_str::<TiiFile>(&json).ok());

// The project homepage travels as the standard OCI `url` annotation on the
// published manifest (`trix publish` maps `[protocol].homepage` to it).
// The zot search summary does not surface it, but the full pull does.
let homepage_url = oci_image
.manifest
.as_ref()
.and_then(|m| m.annotations.as_ref())
.and_then(|a| a.get("org.opencontainers.image.url"))
.cloned();

let published_date = if let Some(published_date) = image.last_updated {
chrono::DateTime::parse_from_rfc3339(&published_date)
.unwrap()
Expand All @@ -97,6 +107,7 @@ pub async fn build_protocol(resolved: ResolvedProtocol) -> Result<(Protocol, Ima
name: image.title.unwrap_or_default(),
scope: image.vendor.unwrap_or_default(),
repository_url: image.source,
homepage_url,
description: image.description,
published_date,
source,
Expand Down Expand Up @@ -196,6 +207,9 @@ impl ProtocolQuery {
scope: image.vendor.clone().unwrap_or_default(),
version: image.tag.clone().unwrap_or_default(),
repository_url: image.source.clone(),
// Not in the zot search summary; only the detail
// query (full manifest pull) can populate it.
homepage_url: None,
description: image.description.clone(),
published_date,
source,
Expand Down
12 changes: 12 additions & 0 deletions frontend/@types/graphql.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,15 +95,27 @@ interface ProfileParty {
interface Protocol {
description: Maybe<Scalars['String']['output']>;
environment: Array<EnvironmentParam>;
/**
* Project homepage, read from the `org.opencontainers.image.url`
* annotation of the published OCI manifest. Populated on the detail
* query (which pulls the manifest); `None` on list queries.
*/
homepageUrl: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
name: Scalars['String']['output'];
/** Parties in a stable order (sorted by name); see [`Self::transactions`]. */
parties: Array<Party>;
profiles: Array<Profile>;
publishedDate: Scalars['Int']['output'];
readme: Maybe<Scalars['String']['output']>;
repositoryUrl: Maybe<Scalars['String']['output']>;
scope: Scalars['String']['output'];
source: Maybe<Scalars['String']['output']>;
/**
* Transactions in a stable order (sorted by name). The TII stores them in
* a map, whose iteration order must never leak into the API: it would
* reshuffle the UI on every load.
*/
transactions: Array<Tx>;
version: Scalars['String']['output'];
}
Expand Down
23 changes: 23 additions & 0 deletions frontend/app/components/icons/world.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { SVGProps } from 'react';

// Tabler Icons world
export function WorldIcon({ strokeWidth = 1.5, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
{...props}
>
<path stroke="none" d="M0 0h24v24H0z" />
<path d="M3 12a9 9 0 1 0 18 0 9 9 0 1 0-18 0" />
<path d="M3.6 9h16.8M3.6 15h16.8M11.5 3a17 17 0 0 0 0 18M12.5 3a17 17 0 0 1 0 18" />
</svg>
);
}
1 change: 1 addition & 0 deletions frontend/app/gql/protocols.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const DETAIL_QUERY = gql`
version
publishedDate
repositoryUrl
homepageUrl
readme
description
source
Expand Down
41 changes: 40 additions & 1 deletion frontend/app/lib/tracker/lifted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,21 @@ export interface LiftedParty {
readonly role: string;
}

export interface LiftedReference {
readonly name: string;
/** UTxO reference as `txhash#index`, lowercase hex. */
readonly ref: string;
}

export interface Lifted {
readonly txName: string;
readonly parties: Record<string, LiftedParty>;
/**
* Reference inputs of the matched transaction. This is how callers discover
* the concrete UTxO refs a protocol expects as parameters (e.g. bodega's
* `project_info_ref`): the values real on-chain transactions used.
*/
readonly references: LiftedReference[];
readonly raw: string;
}

Expand Down Expand Up @@ -46,9 +58,15 @@ interface RawLiftedParty {
role?: unknown;
}

interface RawLiftedReference {
tir_input_name?: unknown;
utxo_ref?: unknown;
}

interface RawLifted {
tx_name?: unknown;
parties?: Record<string, unknown>;
references?: unknown;
}

/**
Expand All @@ -73,5 +91,26 @@ export function parseLifted(json: string): Lifted {
}
}

return { txName, parties, raw: json };
// The tracker writes a reference input as
// `{ tir_input_name, utxo_ref: [[...tx hash bytes], index], ... }`.
const references: LiftedReference[] = [];
if (Array.isArray(data.references)) {
for (const value of data.references) {
if (!value || typeof value !== 'object') continue;
const entry = value as RawLiftedReference;
if (!Array.isArray(entry.utxo_ref) || entry.utxo_ref.length !== 2) continue;
const [hash, index] = entry.utxo_ref as [unknown, unknown];
if (!Array.isArray(hash) || typeof index !== 'number') continue;
try {
references.push({
name: typeof entry.tir_input_name === 'string' ? entry.tir_input_name : '',
ref: `${bytesToHex(hash as number[])}#${index}`,
});
} catch {
// malformed byte array — skip this entry, keep the rest
}
}
}

return { txName, parties, references, raw: json };
}
11 changes: 11 additions & 0 deletions frontend/app/pages/protocol/details/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import clsx from 'clsx';
import dayjs from 'dayjs';

import { GitIcon } from '~/components/icons/git';
import { WorldIcon } from '~/components/icons/world';

interface Props {
className?: string;
Expand All @@ -27,6 +28,16 @@ export function Info({ protocol, className }: Props) {
<p className="mt-2 text-lg text-primary-600">@{protocol.scope}</p>
</div>

{protocol.homepageUrl && (
<div>
<p className="text-zinc-500">Homepage</p>
<a href={protocol.homepageUrl} className="w-fit mt-2 text-zinc-100 flex items-center gap-2" target="_blank" rel="noreferrer">
<WorldIcon width="20" height="20" />
<span className="underline">{protocol.homepageUrl.replace(/http(s)?:\/\//i, '').replace(/\/$/, '')}</span>
</a>
</div>
)}

{protocol.repositoryUrl && (
<div>
<p className="text-zinc-500">Repository</p>
Expand Down
31 changes: 29 additions & 2 deletions frontend/app/pages/protocol/details/tab/activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Link, useFetcher, useSearchParams } from 'react-router';
import { type darkStyles, JsonView } from 'react-json-view-lite';

import { parseLifted, truncateHex } from '~/lib/tracker/lifted';
import { type LiftedReference, parseLifted, truncateHex } from '~/lib/tracker/lifted';
import { useFetcherPolling } from '~/hooks/useFetcherPolling';
import { PartyChip } from './activity/PartyChip';
import { TxNamePill } from './activity/TxNamePill';
Expand Down Expand Up @@ -326,14 +326,15 @@ function DetailView({ match, hash, loading }: { match: Match | null; hash: strin
);
}

const { parties } = parseLifted(match.lifted);
const { parties, references } = parseLifted(match.lifted);

return (
<div className="space-y-6">
{backLink}
<article className="space-y-8">
<DetailHeader match={match} />
<PartiesSection parties={parties} />
<ReferencesSection references={references} />
<RawLiftedDetails rawLifted={match.lifted} />
</article>
</div>
Expand Down Expand Up @@ -389,6 +390,32 @@ function PartiesSection({ parties }: { parties: Record<string, { address: string
);
}

// Reference inputs of the matched transaction, as `txhash#index`. This is the
// discovery surface for ref-UTxO parameters (e.g. bodega's `project_info_ref`):
// callers can read the concrete values real on-chain transactions used.
function ReferencesSection({ references }: { references: LiftedReference[]; }) {
if (references.length === 0) return null;

return (
<section className="space-y-3">
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider">
Reference inputs ({references.length})
</h3>
<div className="rounded-md border border-zinc-800 bg-zinc-950 overflow-hidden">
{references.map((reference, i) => (
<div
key={`${reference.name}-${i}`}
className="px-4 py-2.5 border-b last:border-b-0 border-zinc-800/50 flex flex-col sm:flex-row sm:items-baseline gap-1 sm:gap-0"
>
<span className="sm:w-40 text-zinc-400 font-mono text-sm break-all">{reference.name || '—'}</span>
<span className="flex-1 font-mono text-sm text-zinc-50 break-all select-all">{reference.ref}</span>
</div>
))}
</div>
</section>
);
}

function RawLiftedDetails({ rawLifted }: { rawLifted: string; }) {
let parsed: unknown = null;
let parseFailed = false;
Expand Down
12 changes: 7 additions & 5 deletions frontend/app/pages/protocol/details/tab/sdks/quick-start/go.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type SdkRenderer,
toPascalCase,
type TrpConfig,
unboundParties,
unboundPartyBindings,
userProvidedParams,
} from './shared';

Expand Down Expand Up @@ -33,14 +33,14 @@ function clientOptionsBlock(trp: TrpConfig): string[] {
function quickStart(protocol: Protocol, profile: Profile | null, trp: TrpConfig): string {
const hasProfiles = (protocol.profiles ?? []).length > 0;
const supplied = profileSuppliedNames(profile);
const unbound = unboundParties(protocol, supplied);
const unbound = unboundPartyBindings(protocol, profile, supplied);
const profileArg = hasProfiles && profile
? `, protocol.Profile${toPascalCase(profile.name)}`
: '';

const partyLines = unbound.map((p, i) => {
const partyLines = unbound.map(p => {
const setter = `With${toPascalCase(p.name)}`;
if (i === 0) {
if (p.kind === 'signer') {
return ` .${setter}(facade.SignerParty(signer))`;
}
return ` .${setter}(facade.AddressParty(${JSON.stringify(p.address)}))`;
Expand All @@ -54,7 +54,9 @@ function quickStart(protocol: Protocol, profile: Profile | null, trp: TrpConfig)
' "github.com/tx3-lang/go-sdk/sdk/facade"',
' "github.com/tx3-lang/go-sdk/sdk/signer"',
' "github.com/tx3-lang/go-sdk/sdk/trp"',
' "./gen/go/protocol"',
'',
' // The generated module; its package name is `protocol`.',
` ${JSON.stringify(`yourapp/${protocol.name}`)}`,
')',
'',
'ctx := context.Background()',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ const RENDERERS: Record<SDKKey, SdkRenderer> = {
go: goRenderer,
};

function profileHasData(profile: Profile): boolean {
// A profile "exists" only when the published `.tii` gives it actual content —
// party addresses or environment values. Publishers emit empty stubs for every
// known channel (`profiles.{channel}: { environment: {}, parties: {} }`), so
// key presence alone would list channels the protocol does not really support.
export function profileHasData(profile: Profile): boolean {
if (profile.parties.length > 0) return true;
if (!profile.environment) return false;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import {
toPascalCase,
toSnakeCase,
type TrpConfig,
unboundParties,
unboundPartyBindings,
userProvidedParams,
} from './shared';

// The generated package is the protocol's own folder (snake_cased so the
// module name stays a valid Python identifier); there is no `gen.` prefix.
function pythonModule(protocol: Protocol): string {
return `gen.python.${toSnakeCase(protocol.name)}`;
return toSnakeCase(protocol.name);
}

function clientOptionsLiteral(trp: TrpConfig): string {
Expand All @@ -27,14 +29,14 @@ function quickStart(protocol: Protocol, profile: Profile | null, trp: TrpConfig)
const module = pythonModule(protocol);
const hasProfiles = (protocol.profiles ?? []).length > 0;
const supplied = profileSuppliedNames(profile);
const unbound = unboundParties(protocol, supplied);
const unbound = unboundPartyBindings(protocol, profile, supplied);
const profileArg = hasProfiles && profile
? `, Profile.${toSnakeCase(profile.name).toUpperCase()}`
: '';

const partyLines = unbound.map((p, i) => {
const partyLines = unbound.map(p => {
const setter = `with_${toSnakeCase(p.name)}`;
if (i === 0) {
if (p.kind === 'signer') {
return `client = client.${setter}(Party.signer(signer))`;
}
return `client = client.${setter}(Party.address(${JSON.stringify(p.address)}))`;
Expand Down
Loading