From 919c3d902e55f426874ad6bafc40e70efc7469ca Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 23 Apr 2026 14:19:21 -0600 Subject: [PATCH 1/3] feat(tangle-cloud): finish hosted blueprint tier 2 contract rollout --- .claude/review-lessons.md | 27 ++ .../blueprintApps/BlueprintHostCard.tsx | 149 ++++++++ .../[id]/LegacyBlueprintDetailsPage.tsx | 126 +++++++ .../[id]/deploy/DeploySteps/Deployment.tsx | 44 ++- .../DeploySteps/OperatorSelectionStep.tsx | 26 +- .../[id]/deploy/DeploySteps/PaymentStep.tsx | 3 + .../src/pages/blueprints/[id]/deploy/page.tsx | 84 ++++- .../src/pages/blueprints/[id]/page.tsx | 3 +- .../src/pages/blueprints/create/page.tsx | 335 ++++++++++++++++- .../src/pages/services/[id]/page.tsx | 8 + apps/tangle-cloud/tailwind.config.ts | 4 +- apps/tangle-cloud/vite.config.ts | 120 ++++++ .../src/blueprintApps/authoring.ts | 344 ++++++++++++++++++ .../src/blueprintApps/types.ts | 73 ++++ .../components/blueprints/BlueprintHeader.tsx | 8 +- .../src/data/graphql/useBlueprints.ts | 19 +- libs/tangle-shared-ui/src/types/blueprint.ts | 4 + .../agent-browser/run-wallet-flow-suite.mjs | 25 +- .../blueprint-metadata/blueprint.json | 31 ++ scripts/local-env/start-local-env.sh | 77 +++- 20 files changed, 1463 insertions(+), 47 deletions(-) create mode 100644 .claude/review-lessons.md create mode 100644 apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx create mode 100644 apps/tangle-cloud/src/pages/blueprints/[id]/LegacyBlueprintDetailsPage.tsx create mode 100644 libs/tangle-shared-ui/src/blueprintApps/authoring.ts create mode 100644 libs/tangle-shared-ui/src/blueprintApps/types.ts create mode 100644 scripts/local-env/blueprint-metadata/blueprint.json diff --git a/.claude/review-lessons.md b/.claude/review-lessons.md new file mode 100644 index 0000000000..50f02d170a --- /dev/null +++ b/.claude/review-lessons.md @@ -0,0 +1,27 @@ +# Review Lessons for tangle-network/dapp + +Auto-generated from PR review history. Each rule addresses a pattern +found in 2+ separate PRs — these are recurring mistakes, not one-offs. + +**6 recurring patterns** across 6 unique findings. + +## Security + +- **Shielded note private keys stored unencrypted in IndexedDB** (3x) + NoteData contains privateKey and blinding fields (shielded.ts:10-11). + +## Correctness + +- **Contract addresses default to empty string when env vars missing** (3x) + SHIELDED_GATEWAY_ADDRESS, SHIELDED_CREDITS_ADDRESS, and WRAPPED_TOKEN_ADDRESS all fall back to '' when their VITE_ env vars are unset. +- **Payment contract addresses not resolved per-network** (3x) + Core staking contracts use getContractsByChainId(chainId) for per-network resolution. +- **Read-then-delete uses two separate IndexedDB transactions** (3x) + deleteCreditKeys performs an ownership check in a readonly transaction, then deletes in a separate readwrite transaction. + +## Operational + +- **Payment env vars missing from .env.example** (3x) + The three VITE_SHIELDED_* and VITE_WRAPPED_TOKEN_ADDRESS env vars required by the payments feature are not documented in .env.example. +- **Decryption errors silently swallowed in credit key loading** (3x) + loadCreditKeysForAddress catches all decryption errors and marks the key as isLocked=true without distinguishing between wrong-password, corrupted data, and system errors. diff --git a/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx b/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx new file mode 100644 index 0000000000..4b0f2a1a9d --- /dev/null +++ b/apps/tangle-cloud/src/components/blueprintApps/BlueprintHostCard.tsx @@ -0,0 +1,149 @@ +import type { Blueprint } from '@tangle-network/tangle-shared-ui/types/blueprint'; +import { Card, CardVariant, Typography, Button } from '@tangle-network/ui-components'; +import { ExternalLinkLine } from '@tangle-network/icons'; + +const SURFACE_LABELS: Record< + NonNullable['surfaces'][number], + string +> = { + 'generic-overview': 'Overview', + 'service-explorer': 'Service explorer', + 'service-console': 'Service console', + 'actions-panel': 'Actions', + resources: 'Resources', + chat: 'Chat', + vaults: 'Vaults', + metrics: 'Metrics', + permissions: 'Permissions', +}; + +type Props = { + blueprint: Blueprint; + serviceId?: bigint; +}; + +const BlueprintHostCard = ({ blueprint, serviceId }: Props) => { + if (!blueprint.blueprintUi) { + return null; + } + + const { blueprintUi } = blueprint; + const publisherLabel = blueprintUi.publisher.namespace + ? `@${blueprintUi.publisher.namespace}` + : blueprintUi.publisher.name; + + return ( + +
+
+ + Hosted Blueprint Contract + + + {blueprintUi.displayName} + + + {blueprintUi.description} + +
+ + {blueprintUi.externalApp && ( + + )} +
+ +
+ + + + +
+ + {(blueprintUi.requestedSlug || blueprintUi.canonicalSlug) && ( +
+ + Requested host slug + + + {blueprintUi.canonicalSlug ?? blueprintUi.requestedSlug} + +
+ )} + + {blueprintUi.surfaces.length > 0 && ( +
+ + Shared host surfaces + +
+ {blueprintUi.surfaces.map((surface) => ( + + {SURFACE_LABELS[surface]} + + ))} +
+
+ )} + + {blueprintUi.externalApp && ( +
+ + Third-party app execution stays outside the shared cloud runtime + + + Tier 3 BYOdApp embedding is not enabled here. Tangle Cloud keeps the + shared host pages on-site and only hands off to publisher apps via a + new tab. + {serviceId !== undefined + ? ` Service #${serviceId.toString()} remains manageable from the hosted protocol surfaces.` + : ''} + +
+ )} +
+ ); +}; + +const InfoBlock = ({ label, value }: { label: string; value: string }) => ( +
+ + {label} + + + {value} + +
+); + +export default BlueprintHostCard; diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/LegacyBlueprintDetailsPage.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/LegacyBlueprintDetailsPage.tsx new file mode 100644 index 0000000000..1ed11edc99 --- /dev/null +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/LegacyBlueprintDetailsPage.tsx @@ -0,0 +1,126 @@ +import BlueprintHeader from '@tangle-network/tangle-shared-ui/components/blueprints/BlueprintHeader'; +import OperatorsTable from '@tangle-network/tangle-shared-ui/components/tables/Operators'; +import { useBlueprintDetails } from '@tangle-network/tangle-shared-ui/data/graphql'; +import { ErrorFallback } from '@tangle-network/ui-components/components/ErrorFallback'; +import SkeletonLoader from '@tangle-network/ui-components/components/SkeletonLoader'; +import { Typography } from '@tangle-network/ui-components/typography/Typography'; +import { + type FC, + type PropsWithChildren, + useCallback, + useMemo, + useState, +} from 'react'; +import { Link, useNavigate, Navigate } from 'react-router'; +import { PagePath, TangleDAppPagePath } from '../../../types'; +import pollWithBackoff from '../../../utils/pollWithBackoff'; +import RegistrationDrawer from '../RegistrationDrawer'; +import useOperatorInfo from '@tangle-network/tangle-shared-ui/hooks/useOperatorInfo'; +import useParamWithSchema from '@tangle-network/tangle-shared-ui/hooks/useParamWithSchema'; +import { z } from 'zod'; +import { useAccount } from 'wagmi'; + +const StakingOperatorAction: FC> = ({ + children, +}) => { + return ( + + {children} + + ); +}; + +const LegacyBlueprintDetailsPage: FC = () => { + const navigate = useNavigate(); + const id = useParamWithSchema('id', z.coerce.bigint()); + const { result, isLoading, error, refetch } = useBlueprintDetails(id); + const { isOperator } = useOperatorInfo(); + const { address: userAddress } = useAccount(); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const isRegistered = useMemo(() => { + if (!userAddress || result?.operators === undefined) { + return false; + } + + return result.operators.some((operator) => { + return operator.address.toLowerCase() === userAddress.toLowerCase(); + }); + }, [userAddress, result?.operators]); + + const handleRegistrationComplete = useCallback(async () => { + setIsDrawerOpen(false); + + await pollWithBackoff(async () => { + const refetchResult = (await refetch()) as { + data?: typeof result; + }; + const latestResult = refetchResult.data; + + if (!latestResult || !userAddress) { + return false; + } + + return latestResult.operators.some( + (operator) => + operator.address.toLowerCase() === userAddress.toLowerCase(), + ); + }); + }, [refetch, userAddress]); + + if (isLoading) { + return ( +
+ + +
+ ); + } else if (id === undefined || result === null) { + return ; + } else if (error) { + return ; + } + + return ( +
+ { + e.preventDefault(); + navigate(PagePath.BLUEPRINTS_DEPLOY.replace(':id', `${id ?? ''}`)); + }, + }} + registerBtnProps={{ + onClick: () => setIsDrawerOpen(true), + }} + isRegistered={isRegistered ?? false} + /> + +
+ {!isLoading && ( + + Registered Operators + + )} + + +
+ + +
+ ); +}; + +export default LegacyBlueprintDetailsPage; diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/Deployment.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/Deployment.tsx index 33ebc7644a..ff74c6f62e 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/Deployment.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/Deployment.tsx @@ -5,9 +5,20 @@ import { SelectOperatorsStep } from './OperatorSelectionStep'; import { RequestArgsConfigurationStep } from './RequestArgsConfigurationStep'; import { PaymentStep } from './PaymentStep'; import { RequestModeStep } from './RequestModeStep'; +import { + Accordion, + AccordionButton, + AccordionContent, + AccordionItem, + Typography, +} from '@tangle-network/ui-components'; export const Deployment: FC = (props) => { const minimumNativeSecurityRequirement = 0; + const expectedRequestArgsCount = + props.requestSchemaFieldCount ?? props.blueprint?.requestParams?.length ?? 0; + const shouldShowAdvancedSummary = + expectedRequestArgsCount > 0 || props.hasRequestSchema === false; return ( <> @@ -16,9 +27,36 @@ export const Deployment: FC = (props) => { {...props} minimumNativeSecurityRequirement={minimumNativeSecurityRequirement} /> - - - + +
+ + + + Advanced configuration + + +
+ + Most services only need a name, operators, and a caller. Open + this section when you need custom request args, security + commitments, or a non-default payment setup. + + {shouldShowAdvancedSummary && ( + + {expectedRequestArgsCount > 0 + ? `This blueprint expects ${expectedRequestArgsCount} request argument${expectedRequestArgsCount === 1 ? '' : 's'}.` + : 'This blueprint has no request arguments, but its request schema could not be resolved.'} + + )} +
+ + + + +
+
+
+
); }; diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx index 0d15276905..3cf8662af1 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/OperatorSelectionStep.tsx @@ -39,11 +39,12 @@ export const SelectOperatorsStep: FC = ({ blueprint, blueprintOperators, }) => { + const selectedOperators = watch('operators') ?? []; const [rowSelection, setRowSelection] = useState( - watch(`operators`)?.reduce((acc, operator) => { + selectedOperators.reduce((acc, operator) => { acc[operator] = true; return acc; - }, {} as RowSelectionState) || {}, + }, {} as RowSelectionState), ); const [searchQuery, setSearchQuery] = useState(''); @@ -105,6 +106,27 @@ export const SelectOperatorsStep: FC = ({ setValue(`operators`, Object.keys(rowSelection) as Address[]); }, [rowSelection, setValue]); + useEffect(() => { + if (selectedOperators.length === 0) { + return; + } + + const nextSelection = selectedOperators.reduce((acc, operator) => { + acc[operator] = true; + return acc; + }, {} as RowSelectionState); + + const currentKeys = Object.keys(rowSelection).filter((key) => rowSelection[key]); + const nextKeys = Object.keys(nextSelection); + const isSame = + currentKeys.length === nextKeys.length && + nextKeys.every((key) => rowSelection[key]); + + if (!isSame) { + setRowSelection(nextSelection); + } + }, [rowSelection, selectedOperators]); + const onSelectAsset = (asset: StakingAsset, isChecked: boolean) => { const selectedAssets_ = Array.from(selectedAssets ?? []); const newSelectedAssets = isChecked diff --git a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/PaymentStep.tsx b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/PaymentStep.tsx index cc24e91f88..9ca5db65c7 100644 --- a/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/PaymentStep.tsx +++ b/apps/tangle-cloud/src/pages/blueprints/[id]/deploy/DeploySteps/PaymentStep.tsx @@ -41,6 +41,8 @@ export const PaymentStep: FC = ({ setValue('paymentAmount', nextValue); }; + const selectedPaymentAssetId = watch('paymentAsset')?.id; + return ( @@ -56,6 +58,7 @@ export const PaymentStep: FC = ({ Select Payment Asset + updateForm('uiDraft', { ...form.uiDraft, requestedSlug: v }) + } + placeholder="trading" + isControlled + /> + +
+ + Publisher namespace + + + updateForm('uiDraft', { + ...form.uiDraft, + publisherNamespace: v, + }) + } + placeholder="tangle or your project" + isControlled + /> +
+ + +
+
+ + Service label + + + updateForm('uiDraft', { ...form.uiDraft, serviceNoun: v }) + } + placeholder="service" + isControlled + /> +
+
+ + Resource label + + + updateForm('uiDraft', { ...form.uiDraft, resourceNoun: v }) + } + placeholder="bot" + isControlled + /> +
+
+ + Resource route + + +
+
+ +
+ + Host surfaces + +
+ {BLUEPRINT_UI_SURFACE_OPTIONS.map((option) => { + const isSelected = form.uiDraft.surfaces.includes(option.value); + + return ( + + ); + })} +
+
+ +
+
+ + External app URL + + + updateForm('uiDraft', { ...form.uiDraft, externalAppUrl: v }) + } + placeholder="https://app.example.com" + isControlled + /> +
+
+ + External app mode + + +
+
+ +
+ + Metadata JSON preview + +