@gibs/quotes 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -93,7 +93,13 @@ const resolveHopOutcome = (options) => {
93
93
  return {
94
94
  kind: 'delivered',
95
95
  deliveredAmount: exactResult.deliveredAmount,
96
- outcome: { kind: 'quote', deliveredAmount: exactResult.deliveredAmount },
96
+ outcome: exactResult.priceImpactPercent === undefined
97
+ ? { kind: 'quote', deliveredAmount: exactResult.deliveredAmount }
98
+ : {
99
+ kind: 'quote',
100
+ deliveredAmount: exactResult.deliveredAmount,
101
+ priceImpactPercent: exactResult.priceImpactPercent,
102
+ },
97
103
  };
98
104
  }
99
105
  if (carrier?.estimate !== undefined) {
@@ -1,4 +1,4 @@
1
- import { type ComposedRouteVerdict, type PlannedHop, type RoutePlan } from '@gibs/bridge-sdk/routing';
1
+ import { type ComposedRouteVerdict, type GasCostFor, type HopCarrier, type PlannedHop, type RoutePlan } from '@gibs/bridge-sdk/routing';
2
2
  /**
3
3
  * Decides whether a priced {@link RoutePlan} may be served to a reader as a
4
4
  * route they can sign — the boundary between the route-search engine
@@ -79,15 +79,6 @@ export type ServableWithheldReason = {
79
79
  readonly kind: 'too-many-signatures';
80
80
  readonly signatureCount: number;
81
81
  readonly maxSignatures: number;
82
- } | {
83
- /**
84
- * A section other than the first starts on a chain the reader holds no
85
- * native gas on, and the hop landing there does not deliver native
86
- * coin either — so the reader would have nothing to pay that section's
87
- * own signature with.
88
- */
89
- readonly kind: 'gasless-middle-chain';
90
- readonly chainId: number;
91
82
  } | {
92
83
  /**
93
84
  * The reader named a recipient other than their own wallet for a
@@ -103,6 +94,42 @@ export type ServableWithheldReason = {
103
94
  readonly kind: 'quality';
104
95
  readonly verdict: ComposedRouteVerdict | null;
105
96
  };
97
+ /**
98
+ * One gas top-up a served plan needs: a section other than the first starts
99
+ * on a chain where the reader holds no native gas, and the previous section
100
+ * does not land native coin there. The reader must get gas on that chain
101
+ * before they can sign the section.
102
+ *
103
+ * A PART OF THE ROUTE, NEVER A REASON TO WITHHOLD IT. The owner: "if the user
104
+ * needs gas, then they should get it" — a gas top-up is a scored part of the
105
+ * route. The ranking already charges {@link estimatedCostAmount} (as the
106
+ * `refuelAmount` of the same `GasCostFor`); this record is what a host shows
107
+ * the reader so the charge is not invisible.
108
+ *
109
+ * NO PROVIDER CARRIES THE TOP-UP TODAY. No descriptor in `@gibs/bridge-sdk`'s
110
+ * provider registry declares a refuel or native-drop capability, so the top-up
111
+ * is a requirement the host presents, not a hop inside the plan.
112
+ */
113
+ export type GasTopUp = {
114
+ /** 0-based index into `plan.signatures` of the section that needs the gas. */
115
+ readonly sectionIndex: number;
116
+ /** The chain the reader needs native gas on. */
117
+ readonly chainId: number;
118
+ /** The carrier the reader signs with on that chain — the section's first hop. */
119
+ readonly carrier: HopCarrier;
120
+ /**
121
+ * The native coin the section's signature burns, in that chain's native
122
+ * base units — the least the top-up must land. Null when the host passed no
123
+ * {@link ServableContext.gasCostFor}, or its quote carries no native figure.
124
+ */
125
+ readonly nativeAmountNeeded: bigint | null;
126
+ /**
127
+ * What getting that gas costs the reader, in the plan's destination token's
128
+ * base units — the same `refuelAmount` the ranking subtracts. Null when the
129
+ * host passed no {@link ServableContext.gasCostFor}.
130
+ */
131
+ readonly estimatedCostAmount: bigint | null;
132
+ };
106
133
  /** The verdict {@link servableVerdict} returns. */
107
134
  export type ServableVerdict = {
108
135
  readonly kind: 'servable';
@@ -110,6 +137,12 @@ export type ServableVerdict = {
110
137
  readonly firstSectionRequest: FirstSectionRequest;
111
138
  /** Who signs the first section. */
112
139
  readonly leg: PlanFirstLegKind;
140
+ /**
141
+ * Every gas top-up the plan needs, in section order — empty when the
142
+ * reader can pay gas for every section. A host must show each one
143
+ * with the route; see {@link GasTopUp}.
144
+ */
145
+ readonly gasTopUps: readonly GasTopUp[];
113
146
  } | {
114
147
  readonly kind: 'withheld';
115
148
  readonly reason: ServableWithheldReason;
@@ -133,19 +166,38 @@ export type ServableContext = {
133
166
  readonly customRecipientSectionIndexes: ReadonlySet<number>;
134
167
  /**
135
168
  * The composed-route quality verdict for this plan (`assessComposedRoute`,
136
- * `@gibs/bridge-sdk/routing`). Read only when the plan has more than one
137
- * signature — a single-signature plan strands the reader nowhere between
138
- * signatures, so the gate this verdict feeds does not apply to it. `null`
139
- * or omitted means "not priced yet," which withholds a multi-signature
140
- * plan exactly as a `'rejected'` verdict would.
169
+ * `@gibs/bridge-sdk/routing`). A plan with more than one signature is
170
+ * always gated: `null` or omitted means "not priced yet," which withholds
171
+ * it exactly as a `'rejected'` verdict would. A single-signature plan
172
+ * strands the reader nowhere between signatures, so it is gated only when
173
+ * the host passes a verdict (a swap that loses real value is still a bad
174
+ * offer); `null` or omitted, the gate does not apply to it.
141
175
  */
142
176
  readonly qualityVerdict?: ComposedRouteVerdict | null;
143
177
  /** Overrides the signature-count ceiling. Defaults to {@link MAX_SERVABLE_SIGNATURES}. */
144
178
  readonly maxSignatures?: number;
179
+ /**
180
+ * The SAME gas model the search ranked with (`@gibs/bridge-sdk/routing`'s
181
+ * `GasCostFor`). It prices each {@link GasTopUp}. Omitted, a top-up is
182
+ * still reported, with its amounts null.
183
+ */
184
+ readonly gasCostFor?: GasCostFor;
145
185
  };
186
+ /**
187
+ * Lists the gas top-ups `plan` needs — see {@link GasTopUp}. A section needs
188
+ * one when it is not the first, its chain is in `gaslessChainIds`, and the
189
+ * previous section does not land native coin there
190
+ * (`sectionIsFundedByLanding`).
191
+ *
192
+ * @param plan - the plan to inspect
193
+ * @param context - the reader's gasless chains, and optionally the gas model
194
+ * that prices each top-up
195
+ * @returns every top-up, in section order; empty when none is needed
196
+ */
197
+ export declare const planGasTopUps: (plan: RoutePlan, context: Pick<ServableContext, "gaslessChainIds" | "gasCostFor">) => readonly GasTopUp[];
146
198
  /**
147
199
  * Decides whether `plan` may be served to a reader as a route they can sign
148
- * right now, against the five reasons a route can be held back:
200
+ * right now, against the four reasons a route can be held back:
149
201
  *
150
202
  * 1. **`unconfirmed`** — some hop still rests on an estimate, not a live
151
203
  * quote (`planIsConfirmed`).
@@ -153,16 +205,18 @@ export type ServableContext = {
153
205
  * {@link ServableContext.maxSignatures} allows.
154
206
  * 3. **`middle-recipient-not-reader`** — a non-last section would land
155
207
  * somewhere other than the reader's own wallet.
156
- * 4. **`gasless-middle-chain`** — a non-first section starts on a chain the
157
- * reader cannot pay gas on, and nothing lands native coin there to cover
158
- * it.
159
- * 5. **`quality`** — a composed, multi-signature plan fails the value-loss
208
+ * 4. **`quality`** — a composed, multi-signature plan fails the value-loss
160
209
  * gate (`assessComposedRoute`).
161
210
  *
162
211
  * Checked in that order; the first failing check is the reason returned. A
163
- * plan that clears all five is `'servable'`, carrying the first section's
164
- * hops and the first-hop signer kind ({@link planFirstLegKind}) so a host can
165
- * go build the real signing request.
212
+ * plan that clears all four is `'servable'`, carrying the first section's
213
+ * hops, the first-hop signer kind ({@link planFirstLegKind}), and every gas
214
+ * top-up the reader needs ({@link planGasTopUps}) so a host can go build the
215
+ * real signing request and show what the route asks of the reader.
216
+ *
217
+ * A GASLESS MIDDLE CHAIN NEVER WITHHOLDS A PLAN. It used to; the owner ruled
218
+ * that "a gas top-up is a scored part of the route, never a reason to
219
+ * withhold." The plan is served with a {@link GasTopUp} instead.
166
220
  *
167
221
  * THROWS, RATHER THAN RETURNING A VERDICT, FOR A MALFORMED PLAN — an empty
168
222
  * plan, or one that fails {@link sectionHandoverAgrees}. Both name a defect
@@ -1,5 +1,4 @@
1
- import { isNativeAsset } from '@gibs/bridge-sdk/ecosystems';
2
- import { assetNodeKey, composedRouteMayBeOffered, isRateLimitedCarrier, planIsConfirmed, } from '@gibs/bridge-sdk/routing';
1
+ import { assetNodeKey, composedRouteMayBeOffered, isRateLimitedCarrier, planIsConfirmed, sectionIsFundedByLanding, } from '@gibs/bridge-sdk/routing';
3
2
  /**
4
3
  * Reads {@link PlanFirstLegKind} off a plan's FIRST hop only.
5
4
  *
@@ -61,9 +60,40 @@ export const sectionHandoverAgrees = (plan) => {
61
60
  };
62
61
  /** Owner decision, 2026-09-23: "minimize the steps, but if you have to go through 3 ... that is what you have to do." */
63
62
  export const MAX_SERVABLE_SIGNATURES = 3;
63
+ /**
64
+ * Lists the gas top-ups `plan` needs — see {@link GasTopUp}. A section needs
65
+ * one when it is not the first, its chain is in `gaslessChainIds`, and the
66
+ * previous section does not land native coin there
67
+ * (`sectionIsFundedByLanding`).
68
+ *
69
+ * @param plan - the plan to inspect
70
+ * @param context - the reader's gasless chains, and optionally the gas model
71
+ * that prices each top-up
72
+ * @returns every top-up, in section order; empty when none is needed
73
+ */
74
+ export const planGasTopUps = (plan, context) => plan.signatures.flatMap((group, sectionIndex) => {
75
+ const firstHop = plan.hops[group[0] ?? -1];
76
+ if (sectionIndex === 0 || firstHop === undefined)
77
+ return [];
78
+ const { chainId } = firstHop.edge.from;
79
+ if (!context.gaslessChainIds.has(chainId))
80
+ return [];
81
+ if (sectionIsFundedByLanding(plan, sectionIndex))
82
+ return [];
83
+ const quote = context.gasCostFor?.(chainId, firstHop.edge.carrier);
84
+ return [
85
+ {
86
+ sectionIndex,
87
+ chainId,
88
+ carrier: firstHop.edge.carrier,
89
+ nativeAmountNeeded: quote?.nativeGasAmount ?? null,
90
+ estimatedCostAmount: quote?.refuelAmount ?? null,
91
+ },
92
+ ];
93
+ });
64
94
  /**
65
95
  * Decides whether `plan` may be served to a reader as a route they can sign
66
- * right now, against the five reasons a route can be held back:
96
+ * right now, against the four reasons a route can be held back:
67
97
  *
68
98
  * 1. **`unconfirmed`** — some hop still rests on an estimate, not a live
69
99
  * quote (`planIsConfirmed`).
@@ -71,16 +101,18 @@ export const MAX_SERVABLE_SIGNATURES = 3;
71
101
  * {@link ServableContext.maxSignatures} allows.
72
102
  * 3. **`middle-recipient-not-reader`** — a non-last section would land
73
103
  * somewhere other than the reader's own wallet.
74
- * 4. **`gasless-middle-chain`** — a non-first section starts on a chain the
75
- * reader cannot pay gas on, and nothing lands native coin there to cover
76
- * it.
77
- * 5. **`quality`** — a composed, multi-signature plan fails the value-loss
104
+ * 4. **`quality`** — a composed, multi-signature plan fails the value-loss
78
105
  * gate (`assessComposedRoute`).
79
106
  *
80
107
  * Checked in that order; the first failing check is the reason returned. A
81
- * plan that clears all five is `'servable'`, carrying the first section's
82
- * hops and the first-hop signer kind ({@link planFirstLegKind}) so a host can
83
- * go build the real signing request.
108
+ * plan that clears all four is `'servable'`, carrying the first section's
109
+ * hops, the first-hop signer kind ({@link planFirstLegKind}), and every gas
110
+ * top-up the reader needs ({@link planGasTopUps}) so a host can go build the
111
+ * real signing request and show what the route asks of the reader.
112
+ *
113
+ * A GASLESS MIDDLE CHAIN NEVER WITHHOLDS A PLAN. It used to; the owner ruled
114
+ * that "a gas top-up is a scored part of the route, never a reason to
115
+ * withhold." The plan is served with a {@link GasTopUp} instead.
84
116
  *
85
117
  * THROWS, RATHER THAN RETURNING A VERDICT, FOR A MALFORMED PLAN — an empty
86
118
  * plan, or one that fails {@link sectionHandoverAgrees}. Both name a defect
@@ -116,16 +148,8 @@ export const servableVerdict = (plan, context) => {
116
148
  return { kind: 'withheld', reason: { kind: 'middle-recipient-not-reader', sectionIndex } };
117
149
  }
118
150
  }
119
- for (let sectionIndex = 1; sectionIndex < plan.signatures.length; sectionIndex += 1) {
120
- const previousGroup = plan.signatures[sectionIndex - 1];
121
- const landingHop = plan.hops[previousGroup[previousGroup.length - 1]];
122
- const landingNode = landingHop.edge.to;
123
- const landsNativeCoin = isNativeAsset(landingNode.address, landingNode.ecosystem);
124
- if (!landsNativeCoin && context.gaslessChainIds.has(landingNode.chainId)) {
125
- return { kind: 'withheld', reason: { kind: 'gasless-middle-chain', chainId: landingNode.chainId } };
126
- }
127
- }
128
- if (plan.signatures.length > 1) {
151
+ const gateApplies = plan.signatures.length > 1 || (context.qualityVerdict ?? null) !== null;
152
+ if (gateApplies) {
129
153
  const verdict = context.qualityVerdict ?? null;
130
154
  const passesQualityGate = verdict !== null && composedRouteMayBeOffered(verdict);
131
155
  if (!passesQualityGate) {
@@ -138,5 +162,6 @@ export const servableVerdict = (plan, context) => {
138
162
  kind: 'servable',
139
163
  firstSectionRequest: { hops: firstSectionHops, inputAmount: context.amount },
140
164
  leg: planFirstLegKind(plan),
165
+ gasTopUps: planGasTopUps(plan, context),
141
166
  };
142
167
  };
@@ -330,13 +330,15 @@ const recomposePath = (path, startAmount, askedAmountForHop, cache) => {
330
330
  // ever deliver. The difference stays in the reader's wallet unspent.
331
331
  runningAmount = hop.edge.fusesNext && minimumDeliveredAmount !== null ? minimumDeliveredAmount : deliveredAmount;
332
332
  const refusalReason = refusalReasonByHopIndex[index] ?? null;
333
+ const measuredImpact = hop.outcome.priceImpactPercent;
334
+ const impactField = measuredImpact === undefined ? {} : { priceImpactPercent: measuredImpact };
333
335
  const outcome = figureHop.figure.kind === 'quote'
334
336
  ? minimumDeliveredAmount === null
335
- ? { kind: 'quote', deliveredAmount }
336
- : { kind: 'quote', deliveredAmount, minimumDeliveredAmount }
337
+ ? { kind: 'quote', deliveredAmount, ...impactField }
338
+ : { kind: 'quote', deliveredAmount, minimumDeliveredAmount, ...impactField }
337
339
  : refusalReason === null
338
- ? { kind: 'estimate', deliveredAmount, basis: figureHop.figure.basis }
339
- : { kind: 'estimate', deliveredAmount, basis: figureHop.figure.basis, refusalReason };
340
+ ? { kind: 'estimate', deliveredAmount, basis: figureHop.figure.basis, ...impactField }
341
+ : { kind: 'estimate', deliveredAmount, basis: figureHop.figure.basis, refusalReason, ...impactField };
340
342
  updatedHops.push({ ...hop, outcome });
341
343
  }
342
344
  return { ...path, hops: updatedHops };
package/dist/types.d.ts CHANGED
@@ -129,6 +129,8 @@ export type ExactHopResult = {
129
129
  readonly outcome: 'delivered';
130
130
  /** What this edge delivers for exactly the requested input amount, in `to`'s base units. */
131
131
  readonly deliveredAmount: bigint;
132
+ /** How far this edge's executed rate falls below its marginal rate, in percent; absent when the carrier cannot measure it. */
133
+ readonly priceImpactPercent?: number;
132
134
  } | {
133
135
  readonly outcome: 'refused';
134
136
  /** Why this carrier's own data says the amount cannot cross. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gibs/quotes",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
4
4
  "description": "Isomorphic core for asking cross-chain bridge aggregators (LI.FI, Relay, NEAR Intents) for display and execution quotes — the wire contract, refusal reading, and the shared guards a host wraps every upstream call in. Runs unchanged in a browser or in Node.",
5
5
  "keywords": [
6
6
  "ethereum",
@@ -122,7 +122,7 @@
122
122
  "typecheck": "npx tsc -p tsconfig.test.json"
123
123
  },
124
124
  "dependencies": {
125
- "@gibs/bridge-sdk": "^1.13.0",
125
+ "@gibs/bridge-sdk": "^1.13.1",
126
126
  "zod": "^3.25.76"
127
127
  },
128
128
  "peerDependencies": {