-
Notifications
You must be signed in to change notification settings - Fork 3
Add off-escrow proportional-fee job type #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
DROOdotFOO
wants to merge
5
commits into
Virtual-Protocol:main
Choose a base branch
from
DROOdotFOO:feat/off-escrow-proportional-fee
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
731487b
Add off-escrow proportional-fee job type
DROOdotFOO b5a6300
Fix settlement deliverable destination chain
DROOdotFOO f8f7ba0
Harden off-escrow example; drop caps/meta comments
DROOdotFOO a73dc37
Fix chain-id source and funded-stall in example
DROOdotFOO cc70c37
Fix unsafe notional coercion and per-job fee
DROOdotFOO File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import type { AcpAgentOffering } from "../events/types.js"; | ||
|
|
||
| /** | ||
| * Unit of an offering's percentage `priceValue`: basis points or percent. | ||
| * | ||
| * Which one a `percentage` offering uses is a registry convention and must be | ||
| * confirmed before relying on it. {@link computePercentageFee} takes the unit | ||
| * explicitly rather than assuming one. | ||
| */ | ||
| export type FeeUnit = "bps" | "percent"; | ||
|
|
||
| // `priceValue` is a JS number and may be fractional (e.g. 8.5 bps). We scale it | ||
| // to an integer at this fixed precision so the fee math stays in bigint for the | ||
| // (large) notional. Six decimals of rate precision is ample for bps / percent. | ||
| const RATE_PRECISION = 1_000_000n; | ||
|
|
||
| /** | ||
| * Compute a proportional fee from a notional amount. | ||
| * | ||
| * The fee is returned in the same atomic units / token as `notionalAtomic`. | ||
| * When the notional token differs from the fee (budget) token, the caller must | ||
| * USD-normalize first; the SDK does not know cross-token rates. | ||
| * | ||
| * Rounds DOWN (integer division). `priceValue` may be fractional. | ||
| * | ||
| * @param notionalAtomic Fee notional in atomic units (e.g. 1000 USDC -> | ||
| * 1_000_000_000n at 6 decimals). | ||
| * @param priceValue The offering's `priceValue` (the rate). | ||
| * @param unit Whether `priceValue` is basis points or percent. See {@link FeeUnit}. | ||
| */ | ||
| export function computePercentageFee( | ||
| notionalAtomic: bigint, | ||
| priceValue: number, | ||
| unit: FeeUnit | ||
| ): bigint { | ||
| if (notionalAtomic < 0n) { | ||
| throw new Error( | ||
| `notionalAtomic must be non-negative, got ${notionalAtomic}` | ||
| ); | ||
| } | ||
| if (!Number.isFinite(priceValue) || priceValue < 0) { | ||
| throw new Error( | ||
| `priceValue must be a non-negative finite number, got ${priceValue}` | ||
| ); | ||
| } | ||
| const scaledRate = BigInt(Math.round(priceValue * Number(RATE_PRECISION))); | ||
| const denom = (unit === "bps" ? 10_000n : 100n) * RATE_PRECISION; | ||
| return (notionalAtomic * scaledRate) / denom; | ||
| } | ||
|
|
||
| /** | ||
| * Read the fee notional (atomic, as bigint) from a requirement payload, using | ||
| * the field named by `offering.feeBasisField`. Throws if the offering declares | ||
| * no fee-basis field, or the requirement value is missing / not an integer | ||
| * atomic amount. | ||
| */ | ||
| export function readFeeBasis( | ||
| offering: AcpAgentOffering, | ||
| requirementData: Record<string, unknown> | ||
| ): bigint { | ||
| const field = offering.feeBasisField; | ||
| if (!field) { | ||
| throw new Error( | ||
| `Offering "${offering.name}" does not declare feeBasisField; cannot derive a proportional fee` | ||
| ); | ||
| } | ||
| const raw = requirementData[field]; | ||
| if (raw === undefined || raw === null) { | ||
| throw new Error(`Requirement is missing fee-basis field "${field}"`); | ||
| } | ||
| return toBigIntAtomic(raw, field); | ||
| } | ||
|
|
||
| function toBigIntAtomic(value: unknown, field: string): bigint { | ||
| if (typeof value === "bigint") return value; | ||
| if (typeof value === "number") { | ||
| // A JS number cannot represent an integer above 2^53 exactly, so a large | ||
| // atomic amount is silently rounded before it ever reaches BigInt (and | ||
| // Number.isInteger still returns true for the rounded double). Reject | ||
| // unsafe values and require large notionals as a decimal string, which the | ||
| // branch below converts losslessly. | ||
| if (!Number.isSafeInteger(value)) { | ||
| throw new Error( | ||
| `Fee-basis field "${field}" must be a safe-integer atomic amount ` + | ||
| `(<= ${Number.MAX_SAFE_INTEGER}); pass larger amounts as a decimal ` + | ||
| `string, got ${value}` | ||
| ); | ||
| } | ||
| return BigInt(value); | ||
| } | ||
| if (typeof value === "string" && /^\d+$/.test(value)) { | ||
| return BigInt(value); | ||
| } | ||
| throw new Error( | ||
| `Fee-basis field "${field}" is not a valid atomic amount: ${String(value)}` | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Assert that the notional the buyer DECLARED in the requirement matches the | ||
| * notional BOUND in their signed intent (Permit2 / ERC-3009). A provider should | ||
| * call this before `setBudget` so an under- or over-declared notional is | ||
| * rejected before any payment. | ||
| * | ||
| * Extracting `bound` from the signed intent is intent-standard-specific | ||
| * (Permit2 / ERC-3009) and lives outside this SDK; this helper only compares. | ||
| * It is only as trustworthy as `bound`: pass a value recovered from the | ||
| * verified signature, not one the buyer supplied in the clear. Both amounts | ||
| * must use the same atomic scale (same token and decimals). The comparison is | ||
| * exact. | ||
| */ | ||
| export function assertNotionalMatches(declared: bigint, bound: bigint): void { | ||
| if (declared !== bound) { | ||
| throw new Error( | ||
| `Declared notional ${declared} does not match the notional bound in the signed intent ${bound}` | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| /** | ||
| * Settlement-proof deliverable convention for off-escrow facilitator jobs. | ||
| * | ||
| * For jobs where the transferred value moves outside ACP escrow (the provider | ||
| * only relays the buyer's signed intent), the deliverable is a proof that the | ||
| * facilitated settlement happened on-chain: a settlement tx hash on the | ||
| * destination chain, so an evaluator / reputation layer can verify it | ||
| * independently even though no ACP escrow moved the principal. | ||
| */ | ||
| export interface SettlementDeliverable { | ||
| kind: "settlement"; | ||
| /** Settlement transaction hash on the destination chain. */ | ||
| settlementTxHash: string; | ||
| /** Destination chain id the settlement tx was mined on. */ | ||
| chainId: number; | ||
| /** Optional: notional settled, in atomic units (as a decimal string). */ | ||
| notional?: string; | ||
| /** Optional: token address the notional settled in. */ | ||
| token?: string; | ||
| } | ||
|
|
||
| /** Serialize a settlement proof for `session.submit(...)`. */ | ||
| export function buildSettlementDeliverable( | ||
| proof: Omit<SettlementDeliverable, "kind"> | ||
| ): string { | ||
| const payload: SettlementDeliverable = { kind: "settlement", ...proof }; | ||
| return JSON.stringify(payload); | ||
| } | ||
|
|
||
| /** | ||
| * Parse and shape-validate a settlement deliverable. Returns `null` when the | ||
| * string is not a well-formed settlement proof (mirrors the `parse*` helpers in | ||
| * the fund-transfer example). Use this in an evaluator before releasing the fee. | ||
| */ | ||
| export function parseSettlementDeliverable( | ||
| deliverable: string | ||
| ): SettlementDeliverable | null { | ||
| let data: unknown; | ||
| try { | ||
| data = JSON.parse(deliverable); | ||
| } catch { | ||
| return null; | ||
| } | ||
| if (!data || typeof data !== "object") return null; | ||
| const o = data as Record<string, unknown>; | ||
| if (o.kind !== "settlement") return null; | ||
| if ( | ||
| typeof o.settlementTxHash !== "string" || | ||
| o.settlementTxHash.length === 0 | ||
| ) { | ||
| return null; | ||
| } | ||
| if (typeof o.chainId !== "number" || !Number.isInteger(o.chainId) || o.chainId <= 0) { | ||
| return null; | ||
| } | ||
|
|
||
| const out: SettlementDeliverable = { | ||
| kind: "settlement", | ||
| settlementTxHash: o.settlementTxHash, | ||
| chainId: o.chainId, | ||
| }; | ||
| if (typeof o.notional === "string") out.notional = o.notional; | ||
| if (typeof o.token === "string") out.token = o.token; | ||
| return out; | ||
| } | ||
|
|
||
| /** | ||
| * JSON schema usable as `AcpAgentOffering.deliverable` so an off-escrow | ||
| * offering documents the settlement-proof shape it returns. | ||
| */ | ||
| export const SETTLEMENT_DELIVERABLE_SCHEMA = { | ||
| type: "object", | ||
| required: ["kind", "settlementTxHash", "chainId"], | ||
| properties: { | ||
| kind: { const: "settlement" }, | ||
| settlementTxHash: { type: "string" }, | ||
| // chain ids are positive integers (incl. the synthetic Solana ids 500/501) | ||
| chainId: { type: "integer", minimum: 1 }, | ||
| notional: { type: "string" }, | ||
| token: { type: "string" }, | ||
| }, | ||
| } as const; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.