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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useServiceApproveTx } from './useServiceApproveTx';
import { TeeBackend, useServiceApproveTx } from './useServiceApproveTx';

const { mockUseChainId, mockGetContractsByChainId, mockUseContractWrite } =
vi.hoisted(() => ({
Expand Down Expand Up @@ -120,7 +120,7 @@ describe('useServiceApproveTx', () => {
const blsPubkey = [1n, 2n, 3n, 4n] as const;
const blsPopSignature = [5n, 6n] as const;
const tee = {
backend: 2,
backend: TeeBackend.AwsNitro,
expectedMeasurement:
'0x1111111111111111111111111111111111111111111111111111111111111111' as const,
nonceBinding:
Expand Down
18 changes: 16 additions & 2 deletions apps/tangle-cloud/src/data/services/useServiceApproveTx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,24 @@ import type { ContractSecurityCommitment } from '../../types';

export { TxStatus };

/**
* Vendor-mediated TEE backends accepted by `approveService`. The on-chain enum
* also defines `Unset = 0` (rejected as a misconfiguration sentinel) and
* `DirectTdx = 5` (rejected — vendor-mediated backends only). We omit both
* here so the type system makes invalid backends unrepresentable.
*/
export const TeeBackend = {
Phala: 1,
AwsNitro: 2,
GcpConfidential: 3,
AzureSkr: 4,
} as const;

export type TeeBackendValue = (typeof TeeBackend)[keyof typeof TeeBackend];

/** A TEE attestation commitment matching `Types.TeeAttestationCommitment`. */
export interface TeeAttestationCommitment {
/** Backend enum index — Unset=0, Phala=1, AwsNitro=2, GcpConfidential=3, AzureSkr=4, DirectTdx=5 (rejected on-chain). */
backend: number;
backend: TeeBackendValue;
expectedMeasurement: `0x${string}`;
/** Must equal `teeNonceFor(requestId)` exposed by the Tangle contract. */
nonceBinding: `0x${string}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,19 +397,9 @@ export const PendingInstanceTable: FC<PendingInstanceTableProps> = ({
const onConfirmApprove = useCallback(
async (data: ApprovalConfirmationFormFields) => {
if (!selectedRequest || !approveServiceRequest) return;

// The unified `approveService` entrypoint derives the staking percent
// on-chain from `securityCommitments[0].exposureBps` (or defaults to 100%
// when no commitments are supplied). The form's `stakingPercent` /
// `tntExposureBps` inputs are no longer wired to a separate calldata
// field — operators that want to pin a non-default exposure must do so
// through `securityCommitments`.
await approveServiceRequest({
requestId: selectedRequest.requestId,
securityCommitments:
data.securityCommitments && data.securityCommitments.length > 0
? data.securityCommitments
: undefined,
securityCommitments: data.securityCommitments,
});
},
[selectedRequest, approveServiceRequest],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ type Props = {
type FormValues = {
requestId: bigint;
commitments: Record<string, number>;
tntExposureBps: number;
};

const addCommasToNumber = (value: number) => value.toLocaleString();
Expand Down Expand Up @@ -112,18 +111,24 @@ const ServiceRequestDetailModal: FC<Props> = ({
const { data: stakeByAsset, isLoading: isLoadingStake } =
useOperatorStakeByAsset(operatorAddress, assetsToQuery);

// Seed the commitment record from every requirement on the request — including
// the default-TNT one when present. The contract's unified `approveService`
// entrypoint accepts an explicit per-asset commitment array; sending the
// operator's actual slider value (rather than dropping it and relying on
// on-chain auto-fill at the minimum) is what lets operators commit above
// `minExposureBps` from this UI.
const initialCommitments = useMemo(() => {
if (!hasCustomRequirements || !requirements || requirements.length === 0) {
return {};
}

const commitments: Record<string, number> = {};
for (const req of requirements) {
const key = toAssetMapKey(req.asset.token);
commitments[key] = req.minExposureBps;
if (requirements && requirements.length > 0) {
for (const req of requirements) {
commitments[toAssetMapKey(req.asset.token)] = req.minExposureBps;
}
} else if (defaultTntRequirement) {
commitments[toAssetMapKey(defaultTntRequirement.asset.token)] =
defaultTntRequirement.minExposureBps;
}
return commitments;
}, [hasCustomRequirements, requirements]);
}, [requirements, defaultTntRequirement]);

const derivedSimpleStakingPercent = useMemo(() => {
if (!contractDetails || !operatorAddress) {
Expand Down Expand Up @@ -177,7 +182,6 @@ const ServiceRequestDetailModal: FC<Props> = ({
defaultValues: {
requestId: selectedRequest?.requestId ?? BigInt(0),
commitments: {},
tntExposureBps: 0,
},
});

Expand All @@ -187,15 +191,6 @@ const ServiceRequestDetailModal: FC<Props> = ({
}
}, [initialCommitments, setValue]);

// Initialize tntExposureBps from default TNT requirement
useEffect(() => {
if (defaultTntRequirement && !hasCustomRequirements) {
setValue('tntExposureBps', defaultTntRequirement.minExposureBps, {
shouldValidate: true,
});
}
}, [defaultTntRequirement, hasCustomRequirements, setValue]);

// Close modal on successful transaction
useEffect(() => {
if (
Expand All @@ -216,52 +211,35 @@ const ServiceRequestDetailModal: FC<Props> = ({

const buildSecurityCommitments = useCallback(
(commitments: Record<string, number>): ContractSecurityCommitment[] => {
if (!requirements || requirements.length === 0) {
return [];
}

return requirements.map((req) => {
const key = toAssetMapKey(req.asset.token);
const exposureBps = commitments[key] ?? req.minExposureBps;

return {
asset: {
kind: req.asset.kind,
token: req.asset.token,
},
exposureBps,
};
});
// Map every active requirement (custom set OR the default-TNT) into a
// per-asset commitment using the operator's slider value, falling back
// to the requirement's `minExposureBps` only if the slider is unset.
const sourceRequirements =
requirements && requirements.length > 0
? requirements
: defaultTntRequirement
? [defaultTntRequirement]
: [];

return sourceRequirements.map((req) => ({
asset: { kind: req.asset.kind, token: req.asset.token },
exposureBps:
commitments[toAssetMapKey(req.asset.token)] ?? req.minExposureBps,
}));
},
[requirements],
[requirements, defaultTntRequirement],
);

const handleFormSubmit = useCallback(
(data: FormValues) => {
const formattedData: ApprovalConfirmationFormFields = {
const securityCommitments = buildSecurityCommitments(data.commitments);
return onApprove({
requestId: Number(data.requestId),
};

if (hasCustomRequirements) {
formattedData.securityCommitments = buildSecurityCommitments(
data.commitments,
);
} else {
formattedData.stakingPercent = derivedSimpleStakingPercent;
if (defaultTntRequirement && data.tntExposureBps > 0) {
formattedData.tntExposureBps = data.tntExposureBps;
}
}

return onApprove(formattedData);
securityCommitments:
securityCommitments.length > 0 ? securityCommitments : undefined,
});
},
[
buildSecurityCommitments,
defaultTntRequirement,
derivedSimpleStakingPercent,
hasCustomRequirements,
onApprove,
],
[buildSecurityCommitments, onApprove],
);

const handleApproveClick = useCallback(() => {
Expand Down Expand Up @@ -338,114 +316,88 @@ const ServiceRequestDetailModal: FC<Props> = ({

{!isLoadingRequirements &&
!isLoadingStake &&
hasCustomRequirements &&
requirements !== undefined && (
<div className="space-y-4">
<Text
variant="body2"
className="text-muted-foreground text-center"
>
Set your exposure percentage within the allowed bounds for
each asset.
</Text>

{requirements.map((req) => {
const key = toAssetMapKey(req.asset.token);

return (
<Controller
key={key}
name={`commitments.${key}`}
control={control}
defaultValue={req.minExposureBps}
rules={{
min: {
value: req.minExposureBps,
message: `Must be at least ${req.minExposureBps / 100}%`,
},
max: {
value: req.maxExposureBps,
message: `Cannot exceed ${req.maxExposureBps / 100}%`,
},
}}
render={({ field, fieldState }) => (
<ExposureCommitmentInput
tokenAddress={req.asset.token}
assetKind={req.asset.kind}
metadata={req.metadata}
minExposureBps={req.minExposureBps}
maxExposureBps={req.maxExposureBps}
value={field.value ?? req.minExposureBps}
onChange={field.onChange}
errorMessage={fieldState.error?.message}
delegatedAmount={getStakeForAsset(req.asset.token)}
/>
)}
/>
);
})}
</div>
)}

{!isLoadingRequirements &&
!isLoadingStake &&
!hasCustomRequirements && (
<div className="space-y-4">
{defaultTntRequirement ? (
<>
<Text
variant="body2"
className="text-muted-foreground text-center"
>
Standard approval — set your TNT security commitment.
</Text>

<Controller
name="tntExposureBps"
control={control}
defaultValue={defaultTntRequirement.minExposureBps}
rules={{
min: {
value: defaultTntRequirement.minExposureBps,
message: `Must be at least ${defaultTntRequirement.minExposureBps / 100}%`,
},
max: {
value: defaultTntRequirement.maxExposureBps,
message: `Cannot exceed ${defaultTntRequirement.maxExposureBps / 100}%`,
},
}}
render={({ field, fieldState }) => (
<ExposureCommitmentInput
tokenAddress={defaultTntRequirement.asset.token}
assetKind={defaultTntRequirement.asset.kind}
metadata={defaultTntRequirement.metadata}
minExposureBps={defaultTntRequirement.minExposureBps}
maxExposureBps={defaultTntRequirement.maxExposureBps}
value={
field.value ?? defaultTntRequirement.minExposureBps
}
onChange={field.onChange}
errorMessage={fieldState.error?.message}
delegatedAmount={getStakeForAsset(
defaultTntRequirement.asset.token,
)}
operatorExposureBps={
derivedSimpleStakingPercent * 100
}
/>
)}
/>
</>
) : (
(() => {
// Unified rendering: every requirement on the request — custom or
// the default-TNT one — becomes a single per-asset slider bound to
// `commitments.${tokenKey}`. The contract's unified
// `approveService(ApprovalParams)` accepts the resulting array
// directly, so the operator's slider value is no longer dropped
// for the default-TNT case.
const renderable =
requirements && requirements.length > 0
? requirements
: defaultTntRequirement
? [defaultTntRequirement]
: [];

if (renderable.length === 0) {
return (
<Text
variant="body2"
className="text-center text-muted-foreground"
>
No custom commitments required.
No security commitments required for this request.
</Text>
)}
</div>
)}
);
}

return (
<div className="space-y-4">
<Text
variant="body2"
className="text-muted-foreground text-center"
>
Set your exposure percentage within the allowed bounds for
each asset.
</Text>

{renderable.map((req) => {
const key = toAssetMapKey(req.asset.token);
const isDefaultTnt =
!hasCustomRequirements &&
defaultTntRequirement !== null &&
toAssetMapKey(defaultTntRequirement.asset.token) === key;

return (
<Controller
key={key}
name={`commitments.${key}`}
control={control}
defaultValue={req.minExposureBps}
rules={{
min: {
value: req.minExposureBps,
message: `Must be at least ${req.minExposureBps / 100}%`,
},
max: {
value: req.maxExposureBps,
message: `Cannot exceed ${req.maxExposureBps / 100}%`,
},
}}
render={({ field, fieldState }) => (
<ExposureCommitmentInput
tokenAddress={req.asset.token}
assetKind={req.asset.kind}
metadata={req.metadata}
minExposureBps={req.minExposureBps}
maxExposureBps={req.maxExposureBps}
value={field.value ?? req.minExposureBps}
onChange={field.onChange}
errorMessage={fieldState.error?.message}
delegatedAmount={getStakeForAsset(req.asset.token)}
operatorExposureBps={
isDefaultTnt
? derivedSimpleStakingPercent * 100
: undefined
}
/>
)}
/>
);
})}
</div>
);
})()}
</form>
</ModalBody>

Expand Down
Loading
Loading