From 4af35ccb78b7370c4b016f40dd9b5c25b3d328e8 Mon Sep 17 00:00:00 2001 From: MikeDiam Date: Fri, 29 May 2026 12:54:13 +0300 Subject: [PATCH 1/2] feat(arbitrum-adapter): scaffold @txkit/arbitrum-adapter package Skeleton package for the Arbitrum Open House London Buildathon Week 1 deliverable. Bridges Arbitrum specifics and the ERC-8265 PreparedTransaction Envelope via four additive `meta.arbitrum.*` sub-keys plus an Arbitrum-aware calldata decoder seed. Surface: - attach/extract/isBridgeIntent (canonical / Hop / Across / Stargate + open string) - attach/extract/isRetryableHints (l2Gas, l2GasPriceBid, maxSubmissionCost, refunds) - attach/extract/isSequencerFeePreview (L1 calldata + L2 compute split, Nova compression flag) - previewSequencerFee (stub; viem-driven precompile reads land in alpha.1) - decodeArbitrumCall + KNOWN_ARBITRUM_ADDRESSES (precompiles, Delayed Inbox, Hop / Across L1 entrypoints) 18 vitest tests pass. tsc --noEmit clean. tsup build: 4.86 KB ESM / 5.26 KB CJS / 9.98 KB DTS. Apache-2.0. Zero React or wagmi deps. Mirrors @txkit/x402-adapter and @txkit/ows-adapter structure. --- packages/arbitrum-adapter/CHANGELOG.md | 16 ++ packages/arbitrum-adapter/LICENSE | 202 ++++++++++++++++++ packages/arbitrum-adapter/README.md | 131 ++++++++++++ packages/arbitrum-adapter/package.json | 77 +++++++ .../src/__tests__/arbitrum.spec.ts | 170 +++++++++++++++ packages/arbitrum-adapter/src/bridge.ts | 49 +++++ packages/arbitrum-adapter/src/decoder.ts | 95 ++++++++ packages/arbitrum-adapter/src/index.ts | 20 ++ packages/arbitrum-adapter/src/retryable.ts | 43 ++++ packages/arbitrum-adapter/src/sequencer.ts | 78 +++++++ packages/arbitrum-adapter/src/types.ts | 135 ++++++++++++ packages/arbitrum-adapter/tsconfig.json | 9 + packages/arbitrum-adapter/tsup.config.ts | 11 + packages/arbitrum-adapter/vitest.config.ts | 8 + pnpm-lock.yaml | 68 +++++- 15 files changed, 1111 insertions(+), 1 deletion(-) create mode 100644 packages/arbitrum-adapter/CHANGELOG.md create mode 100644 packages/arbitrum-adapter/LICENSE create mode 100644 packages/arbitrum-adapter/README.md create mode 100644 packages/arbitrum-adapter/package.json create mode 100644 packages/arbitrum-adapter/src/__tests__/arbitrum.spec.ts create mode 100644 packages/arbitrum-adapter/src/bridge.ts create mode 100644 packages/arbitrum-adapter/src/decoder.ts create mode 100644 packages/arbitrum-adapter/src/index.ts create mode 100644 packages/arbitrum-adapter/src/retryable.ts create mode 100644 packages/arbitrum-adapter/src/sequencer.ts create mode 100644 packages/arbitrum-adapter/src/types.ts create mode 100644 packages/arbitrum-adapter/tsconfig.json create mode 100644 packages/arbitrum-adapter/tsup.config.ts create mode 100644 packages/arbitrum-adapter/vitest.config.ts diff --git a/packages/arbitrum-adapter/CHANGELOG.md b/packages/arbitrum-adapter/CHANGELOG.md new file mode 100644 index 0000000..d3fccd9 --- /dev/null +++ b/packages/arbitrum-adapter/CHANGELOG.md @@ -0,0 +1,16 @@ +# @txkit/arbitrum-adapter + +## [0.1.0-alpha.0] - 2026-05-29 + +Initial skeleton release for the Arbitrum Open House London Buildathon Week 1 deliverable. + +- `attachBridgeIntent` / `extractBridgeIntent` / `isBridgeIntent` - attach an L1->L2 bridge intent (canonical / Hop / Across / Stargate, open string for future providers) to `PreparedEnvelope.meta.arbitrum.bridge` +- `attachRetryableHints` / `extractRetryableHints` / `isRetryableHints` - attach retryable-ticket UX hints (l2Gas, l2GasPriceBid, maxSubmissionCost, refund addresses) to `PreparedEnvelope.meta.arbitrum.retryable` +- `attachSequencerFeePreview` / `extractSequencerFeePreview` / `previewSequencerFee` - attach a sequencer-fee breakdown (L1 calldata cost + L2 compute) to `PreparedEnvelope.meta.arbitrum.sequencerFee`. `previewSequencerFee` is a stub; will land in alpha.1 with viem-driven estimation. +- `decodeArbitrumCall` - calldata decoder seed for Arbitrum-specific contracts (ArbSys, Delayed Inbox, Hop Bridge entrypoints). Returns `null` for unknown selectors; alpha.1 will expand the registry. +- TypeScript types: `ArbitrumChainId`, `L1ToL2BridgeIntent`, `L1ToL2BridgeProvider`, `RetryableTicketHints`, `SequencerFeePreview`, `ArbitrumMeta`, `EnvelopeWithArbitrum` +- `KNOWN_ARBITRUM_ADDRESSES` registry constant (address -> label) covering core Arbitrum precompiles + canonical bridge inbox addresses +- Vitest stubs covering attach / extract round-trips, type narrowing, meta-preservation, and decoder fall-through +- Zero React or wagmi dependencies. Depends only on `@txkit/tx-protocol` for envelope types. + +Surface stable, helper bodies are skeleton-grade and will be hardened with on-chain data + viem integration in alpha.1. diff --git a/packages/arbitrum-adapter/LICENSE b/packages/arbitrum-adapter/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/arbitrum-adapter/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/arbitrum-adapter/README.md b/packages/arbitrum-adapter/README.md new file mode 100644 index 0000000..9880ea0 --- /dev/null +++ b/packages/arbitrum-adapter/README.md @@ -0,0 +1,131 @@ +# @txkit/arbitrum-adapter + +[![npm](https://img.shields.io/npm/v/@txkit/arbitrum-adapter/alpha.svg)](https://www.npmjs.com/package/@txkit/arbitrum-adapter) +[![license](https://img.shields.io/npm/l/@txkit/arbitrum-adapter.svg)](https://github.com/txkit/mono/blob/main/LICENSE) + +Bridge Arbitrum specifics and `@txkit/tx-protocol` `PreparedEnvelope`. Attach L1->L2 bridge intents, retryable-ticket UX hints, and sequencer-fee previews to an envelope; decode Arbitrum-flavoured calldata. + +> **v0.1.0-alpha** - skeleton scaffolded for the Arbitrum Open House London Buildathon (June 14 deadline). Surface stable; helper bodies will harden in alpha.1 with viem integration. + +## Why this exists + +The PreparedTransaction Envelope (ERC-8265, [PR #1753](https://github.com/ethereum/ERCs/pull/1753)) gives producers a chain-neutral way to describe a transaction before signing. Arbitrum needs a few additions on top of that base shape so wallet previews can show what is actually happening: + +- L1->L2 bridge deposits are two-leg flows. The envelope is the L1 transaction; the wallet should show the expected L2 outcome ("you will receive X on Arbitrum One via Across"). +- Retryable tickets carry their own gas budget plus refund-address semantics that have no analogue on a vanilla L1 transaction. +- Arbitrum's fee model splits each transaction into L2 compute + L1 calldata cost, with Nova compressing calldata via Brotli on the AnyTrust DAC. A flat `gasPrice * gasLimit` preview hides the L1 component entirely. +- Arbitrum-specific contracts (precompiles, the Delayed Inbox, canonical gateway routers, Hop / Across L1 entrypoints) carry intents that a generic ERC-20 decoder will not surface. + +`@txkit/tx-protocol` reserves a `meta` slot on every envelope. This package puts a typed shape on `meta.arbitrum`, with `bridge`, `retryable`, and `sequencerFee` sub-keys, plus an Arbitrum-aware decoder seed. + +## Install + +```bash +npm install @txkit/arbitrum-adapter@alpha @txkit/tx-protocol@alpha +``` + +## Usage + +### Attach an L1->L2 bridge intent + +```ts +import { createEvmTx } from '@txkit/tx-protocol' +import { attachBridgeIntent, extractBridgeIntent } from '@txkit/arbitrum-adapter' + +const envelope = createEvmTx({ + chain: 'eip155:1', // L1 deposit transaction + calls: [ { to: '0x5C7BCd6E7De5423a257D81B442095A1a6ced35C5', data: '0x...' } ], + validity: { notAfter: Math.floor(Date.now() / 1000) + 3600 }, + description: { short: 'Bridge 0.1 ETH to Arbitrum One via Across', action: 'bridge' }, +}) + +const withBridge = attachBridgeIntent(envelope, { + provider: 'across', + l1ChainId: 'eip155:1', + l2ChainId: 'eip155:42161', + tokenIn: 'native', + amount: '0x16345785d8a0000', + recipient: '0xRecipientOnL2', +}) + +const intent = extractBridgeIntent(withBridge) +// -> { provider: 'across', l1ChainId: 'eip155:1', l2ChainId: 'eip155:42161', ... } +``` + +### Surface a retryable-ticket budget + +```ts +import { attachRetryableHints } from '@txkit/arbitrum-adapter' + +const withRetryable = attachRetryableHints(envelope, { + l2Gas: '0x186a0', + l2GasPriceBid: '0x5f5e100', + maxSubmissionCost: '0x38d7ea4c68000', + callValueRefundAddress: '0xRefundReceiver', +}) +``` + +### Preview the sequencer fee + +```ts +import { attachSequencerFeePreview } from '@txkit/arbitrum-adapter' + +const withFee = attachSequencerFeePreview(envelope, { + l2GasEstimate: '0x186a0', + l1CalldataBytes: 132, + l1BaseFeeWei: '0x3b9aca00', + l1FeeWei: '0x16345785d8a0000', + l2FeeWei: '0x71afd498d0000', + totalFeeWei: '0x1d4ab7f7c2a0000', + isCompressed: false, // true for Arbitrum Nova +}) +``` + +### Decode Arbitrum-flavoured calldata + +```ts +import { decodeArbitrumCall } from '@txkit/arbitrum-adapter' + +const decoded = decodeArbitrumCall({ + to: '0x4dBd4fC535Ac27206064B6804B5d6C7Acb7C1aBc', + calldata: '0x679b6ded...', +}) +// -> { kind: 'retryable-create', contractLabel: 'Arbitrum Inbox' } +``` + +## API + +### Bridge intents + +- `attachBridgeIntent(envelope, intent)` - attach `meta.arbitrum.bridge` +- `extractBridgeIntent(envelope)` - read `meta.arbitrum.bridge` +- `isBridgeIntent(value)` - type guard + +### Retryable tickets + +- `attachRetryableHints(envelope, hints)` - attach `meta.arbitrum.retryable` +- `extractRetryableHints(envelope)` - read `meta.arbitrum.retryable` +- `isRetryableHints(value)` - type guard + +### Sequencer-fee preview + +- `attachSequencerFeePreview(envelope, preview)` - attach `meta.arbitrum.sequencerFee` +- `extractSequencerFeePreview(envelope)` - read `meta.arbitrum.sequencerFee` +- `isSequencerFeePreview(value)` - type guard +- `previewSequencerFee({ chain, calldata, l1BaseFeeWei? })` - skeleton stub, returns `null` until alpha.1 +- `NOVA_USES_COMPRESSED_CALLDATA` - constant flag (`true`) + +### Decoder + +- `decodeArbitrumCall({ to, calldata })` - returns `ArbitrumDecodedCall | null` +- `KNOWN_ARBITRUM_ADDRESSES` - address -> label registry (precompiles, Delayed Inbox, canonical gateway, Hop / Across L1 entrypoints) + +## Status + +Skeleton. Helper bodies for `previewSequencerFee` and the decoder registry will harden in alpha.1 with viem-driven precompile reads (`ArbGasInfo`, `NodeInterface`) plus expanded coverage of Hop bonders per token, Across SpokePool per chain, Stargate routers, Camelot, GMX, Pendle, and Aave V3 on Arbitrum One. + +Tracking issue: open one in [github.com/txkit/mono/issues](https://github.com/txkit/mono/issues) if you have a buildathon use case that needs prioritisation. + +## License + +[Apache-2.0](./LICENSE) diff --git a/packages/arbitrum-adapter/package.json b/packages/arbitrum-adapter/package.json new file mode 100644 index 0000000..03e0c27 --- /dev/null +++ b/packages/arbitrum-adapter/package.json @@ -0,0 +1,77 @@ +{ + "name": "@txkit/arbitrum-adapter", + "version": "0.1.0-alpha.0", + "description": "Bridge Arbitrum specifics and PreparedTransaction. L1->L2 bridge intents (canonical / Hop / Across / Stargate), retryable-ticket UX hints, sequencer-fee preview, and an Arbitrum-aware calldata decoder seed.", + "license": "Apache-2.0", + "author": "Michael Diamond", + "homepage": "https://docs.txkit.dev/protocol/prepared-tx", + "repository": { + "type": "git", + "url": "git+https://github.com/txkit/mono.git", + "directory": "packages/arbitrum-adapter" + }, + "bugs": { + "url": "https://github.com/txkit/mono/issues" + }, + "keywords": [ + "txkit", + "web3", + "ethereum", + "arbitrum", + "arbitrum-one", + "arbitrum-nova", + "arbitrum-sepolia", + "l1-to-l2-bridge", + "retryable-ticket", + "sequencer-fee", + "ai-agents", + "prepared-transaction", + "erc-8265" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=20.19" + }, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "LICENSE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "prepublishOnly": "pnpm run build", + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist", + "lint": "tsc --noEmit", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@txkit/tx-protocol": "workspace:^" + }, + "devDependencies": { + "tsup": "^8.4.0", + "typescript": "^5.7.3", + "vitest": "^4.1.1" + }, + "sideEffects": false +} diff --git a/packages/arbitrum-adapter/src/__tests__/arbitrum.spec.ts b/packages/arbitrum-adapter/src/__tests__/arbitrum.spec.ts new file mode 100644 index 0000000..1cfaad4 --- /dev/null +++ b/packages/arbitrum-adapter/src/__tests__/arbitrum.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' + +import { attachBridgeIntent, extractBridgeIntent, isBridgeIntent } from '../bridge' +import { KNOWN_ARBITRUM_ADDRESSES, decodeArbitrumCall } from '../decoder' +import { attachRetryableHints, extractRetryableHints, isRetryableHints } from '../retryable' +import { + NOVA_USES_COMPRESSED_CALLDATA, + attachSequencerFeePreview, + extractSequencerFeePreview, + isSequencerFeePreview, + previewSequencerFee, +} from '../sequencer' +import type { L1ToL2BridgeIntent, RetryableTicketHints, SequencerFeePreview } from '../types' + + +const MINIMAL_ENVELOPE = { + version: '0.1' as const, + kind: 'evm-tx' as const, + content: { + chain: 'eip155:1' as const, + calls: [ { to: '0x4dbd4fc535ac27206064b6804b5d6c7acb7c1abc' as `0x${string}`, data: '0x' as `0x${string}` } ], + validity: { notAfter: 9999999999 }, + }, +} + +const SAMPLE_BRIDGE: L1ToL2BridgeIntent = { + provider: 'canonical', + l1ChainId: 'eip155:1', + l2ChainId: 'eip155:42161', + tokenIn: 'native', + amount: '0xde0b6b3a7640000', + recipient: '0x0000000000000000000000000000000000000099', + expiresAt: 9999999999, +} + +const SAMPLE_RETRYABLE: RetryableTicketHints = { + l2Gas: '0x186a0', + l2GasPriceBid: '0x5f5e100', + maxSubmissionCost: '0x38d7ea4c68000', + callValueRefundAddress: '0x0000000000000000000000000000000000000099', +} + +const SAMPLE_PREVIEW: SequencerFeePreview = { + l2GasEstimate: '0x186a0', + l1CalldataBytes: 132, + l1BaseFeeWei: '0x3b9aca00', + l1FeeWei: '0x16345785d8a0000', + l2FeeWei: '0x71afd498d0000', + totalFeeWei: '0x1d4ab7f7c2a0000', + isCompressed: false, +} + +describe('arbitrum-adapter / bridge', () => { + it('attachBridgeIntent populates meta.arbitrum.bridge', () => { + const envelope = attachBridgeIntent(MINIMAL_ENVELOPE as unknown as Parameters[0], SAMPLE_BRIDGE) + expect(envelope.meta?.arbitrum?.bridge).toEqual(SAMPLE_BRIDGE) + }) + + it('extractBridgeIntent reads what attach wrote', () => { + const envelope = attachBridgeIntent(MINIMAL_ENVELOPE as unknown as Parameters[0], SAMPLE_BRIDGE) + expect(extractBridgeIntent(envelope)).toEqual(SAMPLE_BRIDGE) + }) + + it('extractBridgeIntent returns null when meta missing or malformed', () => { + expect(extractBridgeIntent({})).toBeNull() + expect(extractBridgeIntent({ meta: {} })).toBeNull() + expect(extractBridgeIntent({ meta: { arbitrum: { bridge: { provider: 'x' } as never } } })).toBeNull() + }) + + it('isBridgeIntent narrows correctly', () => { + expect(isBridgeIntent(SAMPLE_BRIDGE)).toBe(true) + expect(isBridgeIntent(null)).toBe(false) + expect(isBridgeIntent({ provider: 'canonical' })).toBe(false) + }) + + it('attach preserves existing meta keys and other arbitrum sub-keys', () => { + const seeded = { + ...MINIMAL_ENVELOPE, + meta: { foo: 'bar', arbitrum: { retryable: SAMPLE_RETRYABLE } }, + } + const envelope = attachBridgeIntent(seeded as unknown as Parameters[0], SAMPLE_BRIDGE) + expect(envelope.meta?.foo).toBe('bar') + expect(envelope.meta?.arbitrum?.bridge).toEqual(SAMPLE_BRIDGE) + expect(envelope.meta?.arbitrum?.retryable).toEqual(SAMPLE_RETRYABLE) + }) +}) + +describe('arbitrum-adapter / retryable', () => { + it('attachRetryableHints populates meta.arbitrum.retryable', () => { + const envelope = attachRetryableHints(MINIMAL_ENVELOPE as unknown as Parameters[0], SAMPLE_RETRYABLE) + expect(envelope.meta?.arbitrum?.retryable).toEqual(SAMPLE_RETRYABLE) + }) + + it('extractRetryableHints returns null for malformed payloads', () => { + expect(extractRetryableHints({})).toBeNull() + expect(extractRetryableHints({ meta: { arbitrum: { retryable: { l2Gas: 1 } as never } } })).toBeNull() + }) + + it('isRetryableHints requires all three required hex fields', () => { + expect(isRetryableHints(SAMPLE_RETRYABLE)).toBe(true) + expect(isRetryableHints({ l2Gas: '0x1', l2GasPriceBid: '0x1' })).toBe(false) + expect(isRetryableHints(undefined)).toBe(false) + }) +}) + +describe('arbitrum-adapter / sequencer', () => { + it('attachSequencerFeePreview populates meta.arbitrum.sequencerFee', () => { + const envelope = attachSequencerFeePreview(MINIMAL_ENVELOPE as unknown as Parameters[0], SAMPLE_PREVIEW) + expect(envelope.meta?.arbitrum?.sequencerFee).toEqual(SAMPLE_PREVIEW) + }) + + it('extractSequencerFeePreview reads what attach wrote', () => { + const envelope = attachSequencerFeePreview(MINIMAL_ENVELOPE as unknown as Parameters[0], SAMPLE_PREVIEW) + expect(extractSequencerFeePreview(envelope)).toEqual(SAMPLE_PREVIEW) + }) + + it('isSequencerFeePreview rejects partial shapes', () => { + expect(isSequencerFeePreview(SAMPLE_PREVIEW)).toBe(true) + expect(isSequencerFeePreview({ l2GasEstimate: '0x1' })).toBe(false) + }) + + it('previewSequencerFee is a skeleton stub - returns null', () => { + const preview = previewSequencerFee({ chain: 'eip155:42161', calldata: '0x' }) + expect(preview).toBeNull() + }) + + it('exposes the Nova compression flag', () => { + expect(NOVA_USES_COMPRESSED_CALLDATA).toBe(true) + }) +}) + +describe('arbitrum-adapter / decoder', () => { + it('decodeArbitrumCall labels the Arbitrum One Delayed Inbox', () => { + const decoded = decodeArbitrumCall({ + to: '0x4dbd4fc535ac27206064b6804b5d6c7acb7c1abc', + calldata: '0x679b6ded000000000000000000000000', + }) + expect(decoded).toEqual({ kind: 'retryable-create', contractLabel: 'Arbitrum Inbox' }) + }) + + it('decodeArbitrumCall flags known bridge providers when the selector is unknown', () => { + const decoded = decodeArbitrumCall({ + to: '0xb8901acb165ed027e32754e0ffe830802919727f', + calldata: '0xdeadbeef', + }) + expect(decoded?.kind).toBe('bridge-deposit') + expect(decoded).toMatchObject({ provider: 'hop' }) + }) + + it('decodeArbitrumCall returns null for addresses outside the registry', () => { + const decoded = decodeArbitrumCall({ + to: '0x1111111111111111111111111111111111111111', + calldata: '0xdeadbeef', + }) + expect(decoded).toBeNull() + }) + + it('decoder address registry is case-insensitive (lowercased lookup)', () => { + const decoded = decodeArbitrumCall({ + to: '0x4DBD4FC535AC27206064B6804B5D6C7ACB7C1ABC', + calldata: '0xdeadbeef', + }) + expect(decoded).toMatchObject({ kind: 'bridge-deposit', provider: 'canonical' }) + }) + + it('KNOWN_ARBITRUM_ADDRESSES exposes the core precompile labels', () => { + expect(KNOWN_ARBITRUM_ADDRESSES['0x0000000000000000000000000000000000000064']).toContain('ArbSys') + expect(KNOWN_ARBITRUM_ADDRESSES['0x000000000000000000000000000000000000006c']).toContain('ArbGasInfo') + }) +}) diff --git a/packages/arbitrum-adapter/src/bridge.ts b/packages/arbitrum-adapter/src/bridge.ts new file mode 100644 index 0000000..a409daa --- /dev/null +++ b/packages/arbitrum-adapter/src/bridge.ts @@ -0,0 +1,49 @@ +import type { PreparedEnvelope } from '@txkit/tx-protocol' + +import type { EnvelopeWithArbitrum, L1ToL2BridgeIntent } from './types' + + +/** + * Attach an L1->L2 bridge intent to a PreparedEnvelope's + * `meta.arbitrum.bridge` slot. Pure - returns a new envelope, does not + * mutate. Existing meta and other arbitrum sub-keys are preserved. + * + * Use when the envelope is the L1 deposit / lock transaction and the + * producer wants the wallet preview to show the L2-side outcome + * ("you will receive X on Arbitrum One via Y"). + */ +export const attachBridgeIntent = (envelope: E, intent: L1ToL2BridgeIntent): E & EnvelopeWithArbitrum => { + const meta = ((envelope as unknown as EnvelopeWithArbitrum).meta ?? {}) + + return { + ...envelope, + meta: { + ...meta, + arbitrum: { + ...(meta.arbitrum ?? {}), + bridge: intent, + }, + }, + } as E & EnvelopeWithArbitrum +} + +export const isBridgeIntent = (value: unknown): value is L1ToL2BridgeIntent => { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Record + return typeof candidate.provider === 'string' + && typeof candidate.l1ChainId === 'string' + && typeof candidate.l2ChainId === 'string' + && typeof candidate.tokenIn === 'string' + && typeof candidate.amount === 'string' +} + +/** + * Read the L1->L2 bridge intent from an envelope, if present. Returns + * null when `meta.arbitrum.bridge` is missing or malformed. + */ +export const extractBridgeIntent = (envelope: EnvelopeWithArbitrum): L1ToL2BridgeIntent | null => { + const intent = envelope.meta?.arbitrum?.bridge + return isBridgeIntent(intent) ? intent : null +} diff --git a/packages/arbitrum-adapter/src/decoder.ts b/packages/arbitrum-adapter/src/decoder.ts new file mode 100644 index 0000000..4259bc4 --- /dev/null +++ b/packages/arbitrum-adapter/src/decoder.ts @@ -0,0 +1,95 @@ +import type { ArbitrumDecodedCall, L1ToL2BridgeProvider } from './types' + + +/** + * Address -> label registry for Arbitrum-specific contracts. Seeded with + * the core Arbitrum precompiles + the canonical Delayed Inbox on + * Ethereum mainnet + one Hop / Across L1 entrypoint each. Alpha.1 will + * expand coverage (Sepolia inbox, Hop bonders per token, Across SpokePool + * per chain, Stargate routers, Camelot / GMX / Pendle / Aave V3 on + * Arbitrum One). + * + * Lowercase keys for case-insensitive lookup. + */ +export const KNOWN_ARBITRUM_ADDRESSES: Readonly> = { + // Arbitrum precompiles (identical across One / Sepolia / Nova). + '0x0000000000000000000000000000000000000064': 'ArbSys (precompile)', + '0x000000000000000000000000000000000000006c': 'ArbGasInfo (precompile)', + '0x00000000000000000000000000000000000000c8': 'NodeInterface (precompile)', + + // L1 entrypoints for Arbitrum One (Ethereum mainnet). + '0x4dbd4fc535ac27206064b6804b5d6c7acb7c1abc': 'Arbitrum One Delayed Inbox (L1)', + '0x72ce9c846789fdb6fc1f34ac4ad25dd9ef7031ef': 'Arbitrum One L1 Gateway Router', + + // L1 entrypoints for Arbitrum Nova. + '0xc4448b71118c9071bcb9734a0eac55d18a153949': 'Arbitrum Nova Delayed Inbox (L1)', + + // Third-party bridge entrypoints on L1. + '0xb8901acb165ed027e32754e0ffe830802919727f': 'Hop Protocol L1 Bridge (ETH)', + '0x5c7bcd6e7de5423a257d81b442095a1a6ced35c5': 'Across SpokePool (L1 Ethereum)', +} + +/** + * Selector -> intent label registry. Empty in the skeleton; expansion to + * follow as the decoder hardens. Only function-selector lookups go here; + * full ABI decoding is the responsibility of `@txkit/tx-decoder`. + */ +const KNOWN_SELECTORS: Readonly> = { + // Inbox.createRetryableTicket(address,uint256,uint256,address,address,uint256,uint256,bytes) + '0x679b6ded': { kind: 'retryable-create', contractLabel: 'Arbitrum Inbox' }, + // ArbSys.sendTxToL1(address,bytes) + '0x928c169a': { kind: 'arbsys', method: 'sendTxToL1' }, + // ArbSys.withdrawEth(address) + '0x25e16063': { kind: 'arbsys', method: 'withdrawEth' }, +} + +const PROVIDER_BY_ADDRESS: Readonly> = { + '0x4dbd4fc535ac27206064b6804b5d6c7acb7c1abc': 'canonical', + '0xc4448b71118c9071bcb9734a0eac55d18a153949': 'canonical', + '0xb8901acb165ed027e32754e0ffe830802919727f': 'hop', + '0x5c7bcd6e7de5423a257d81b442095a1a6ced35c5': 'across', +} + +const normalize = (address: string): string => address.toLowerCase() + +const selectorOf = (calldata: `0x${string}`): string | null => { + if (calldata.length < 10) { + return null + } + return calldata.slice(0, 10).toLowerCase() +} + +/** + * Decode an Arbitrum-flavoured call into a coarse intent label. Skeleton + * registry; returns `kind: 'unknown'` (with the address label when known) + * for selectors we have not catalogued yet, and `null` for inputs whose + * `to` is not in the Arbitrum registry at all. + * + * This is not a replacement for `@txkit/tx-decoder` - it surfaces + * Arbitrum-specific intents that a generic decoder would not flag, so the + * wallet preview can label a transaction as "L1->L2 bridge via Hop" or + * "Arbitrum retryable ticket creation". + */ +export const decodeArbitrumCall = (args: { + to: `0x${string}`, + calldata: `0x${string}`, +}): ArbitrumDecodedCall | null => { + const to = normalize(args.to) + const contractLabel = KNOWN_ARBITRUM_ADDRESSES[to] + if (!contractLabel) { + return null + } + + const selector = selectorOf(args.calldata) + const matchedSelector = selector ? KNOWN_SELECTORS[selector] : undefined + if (matchedSelector) { + return matchedSelector + } + + const provider = PROVIDER_BY_ADDRESS[to] + if (provider) { + return { kind: 'bridge-deposit', provider, contractLabel } + } + + return { kind: 'unknown', contractLabel } +} diff --git a/packages/arbitrum-adapter/src/index.ts b/packages/arbitrum-adapter/src/index.ts new file mode 100644 index 0000000..d02711c --- /dev/null +++ b/packages/arbitrum-adapter/src/index.ts @@ -0,0 +1,20 @@ +export { attachBridgeIntent, extractBridgeIntent, isBridgeIntent } from './bridge' +export { attachRetryableHints, extractRetryableHints, isRetryableHints } from './retryable' +export { + NOVA_USES_COMPRESSED_CALLDATA, + attachSequencerFeePreview, + extractSequencerFeePreview, + isSequencerFeePreview, + previewSequencerFee, +} from './sequencer' +export { KNOWN_ARBITRUM_ADDRESSES, decodeArbitrumCall } from './decoder' +export type { + ArbitrumChainId, + ArbitrumDecodedCall, + ArbitrumMeta, + EnvelopeWithArbitrum, + L1ToL2BridgeIntent, + L1ToL2BridgeProvider, + RetryableTicketHints, + SequencerFeePreview, +} from './types' diff --git a/packages/arbitrum-adapter/src/retryable.ts b/packages/arbitrum-adapter/src/retryable.ts new file mode 100644 index 0000000..45e2eb2 --- /dev/null +++ b/packages/arbitrum-adapter/src/retryable.ts @@ -0,0 +1,43 @@ +import type { PreparedEnvelope } from '@txkit/tx-protocol' + +import type { EnvelopeWithArbitrum, RetryableTicketHints } from './types' + + +/** + * Attach retryable-ticket UX hints to a PreparedEnvelope's + * `meta.arbitrum.retryable` slot. Pure - returns a new envelope, does + * not mutate. Existing meta and other arbitrum sub-keys are preserved. + * + * Used when the underlying L1 transaction calls + * Inbox.createRetryableTicket and the wallet should surface the L2 + * gas budget plus refund destinations. + */ +export const attachRetryableHints = (envelope: E, hints: RetryableTicketHints): E & EnvelopeWithArbitrum => { + const meta = ((envelope as unknown as EnvelopeWithArbitrum).meta ?? {}) + + return { + ...envelope, + meta: { + ...meta, + arbitrum: { + ...(meta.arbitrum ?? {}), + retryable: hints, + }, + }, + } as E & EnvelopeWithArbitrum +} + +export const isRetryableHints = (value: unknown): value is RetryableTicketHints => { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Record + return typeof candidate.l2Gas === 'string' + && typeof candidate.l2GasPriceBid === 'string' + && typeof candidate.maxSubmissionCost === 'string' +} + +export const extractRetryableHints = (envelope: EnvelopeWithArbitrum): RetryableTicketHints | null => { + const hints = envelope.meta?.arbitrum?.retryable + return isRetryableHints(hints) ? hints : null +} diff --git a/packages/arbitrum-adapter/src/sequencer.ts b/packages/arbitrum-adapter/src/sequencer.ts new file mode 100644 index 0000000..66793c0 --- /dev/null +++ b/packages/arbitrum-adapter/src/sequencer.ts @@ -0,0 +1,78 @@ +import type { PreparedEnvelope } from '@txkit/tx-protocol' + +import type { ArbitrumChainId, EnvelopeWithArbitrum, SequencerFeePreview } from './types' + + +/** + * AnyTrust data-availability committee on Arbitrum Nova compresses + * posted calldata with Brotli before publishing the digest to L1, which + * is why Nova's L1 calldata cost component is typically a small fraction + * of Arbitrum One's. This constant is informational and surfaced by + * `previewSequencerFee` via the `isCompressed` flag. + */ +export const NOVA_USES_COMPRESSED_CALLDATA = true + +const isNova = (chainId: ArbitrumChainId): boolean => chainId === 'eip155:42170' + +/** + * Attach a sequencer-fee preview to a PreparedEnvelope's + * `meta.arbitrum.sequencerFee` slot. Pure - returns a new envelope. + * Existing meta and other arbitrum sub-keys are preserved. + */ +export const attachSequencerFeePreview = (envelope: E, preview: SequencerFeePreview): E & EnvelopeWithArbitrum => { + const meta = ((envelope as unknown as EnvelopeWithArbitrum).meta ?? {}) + + return { + ...envelope, + meta: { + ...meta, + arbitrum: { + ...(meta.arbitrum ?? {}), + sequencerFee: preview, + }, + }, + } as E & EnvelopeWithArbitrum +} + +export const isSequencerFeePreview = (value: unknown): value is SequencerFeePreview => { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Record + return typeof candidate.l2GasEstimate === 'string' + && typeof candidate.l1FeeWei === 'string' + && typeof candidate.l2FeeWei === 'string' + && typeof candidate.totalFeeWei === 'string' + && typeof candidate.l1CalldataBytes === 'number' + && typeof candidate.isCompressed === 'boolean' +} + +export const extractSequencerFeePreview = (envelope: EnvelopeWithArbitrum): SequencerFeePreview | null => { + const preview = envelope.meta?.arbitrum?.sequencerFee + return isSequencerFeePreview(preview) ? preview : null +} + +/** + * Compute a sequencer-fee preview for a calldata payload on the given + * Arbitrum chain. Skeleton stub - returns `null` until alpha.1, when the + * estimation will use viem's `arbGasInfo.getPricesInWei` precompile read + * plus `NodeInterface.gasEstimateL1Component`. + * + * Reference contract addresses (precompiles, identical across Arbitrum + * One / Sepolia / Nova): + * - ArbSys 0x0000000000000000000000000000000000000064 + * - ArbGasInfo 0x000000000000000000000000000000000000006C + * - NodeInterface 0x00000000000000000000000000000000000000C8 + */ +export const previewSequencerFee = (_args: { + chain: ArbitrumChainId, + calldata: `0x${string}`, + l1BaseFeeWei?: `0x${string}`, +}): SequencerFeePreview | null => { + // Skeleton: surface the chain compression flag so callers can still + // render the Nova vs One distinction even before live estimation lands. + const { chain } = _args + void chain + void isNova + return null +} diff --git a/packages/arbitrum-adapter/src/types.ts b/packages/arbitrum-adapter/src/types.ts new file mode 100644 index 0000000..4a95eac --- /dev/null +++ b/packages/arbitrum-adapter/src/types.ts @@ -0,0 +1,135 @@ +/** + * Arbitrum-specific extension shapes for PreparedTransaction Envelope. + * + * The envelope reserves a `meta` slot; this package puts a typed shape on + * `meta.arbitrum`, with `bridge`, `retryable`, and `sequencerFee` sub-keys. + * Consumers should not assume any envelope carries these fields - they are + * additive metadata, never authoritative on their own. + * + * References: + * - Arbitrum chain IDs: docs.arbitrum.io/build-decentralized-apps/reference/chain-params + * - Retryable tickets: docs.arbitrum.io/arbos/l1-to-l2-messaging + * - Sequencer fee model: docs.arbitrum.io/build-decentralized-apps/how-to-estimate-gas + */ +export type ArbitrumChainId = + | 'eip155:42161' // Arbitrum One + | 'eip155:421614' // Arbitrum Sepolia + | 'eip155:42170' // Arbitrum Nova + +/** + * Bridge provider for L1->L2 transfers. Closed enum for v0.1 covers the + * production-traffic majority; the trailing `string` keeps the door open + * for vendor-specific bridges (e.g. an exchange's own Arbitrum gateway) + * without forcing a spec bump. + */ +export type L1ToL2BridgeProvider = + | 'canonical' // Arbitrum native Delayed Inbox / Outbox + | 'hop' // Hop Protocol + | 'across' // Across Protocol + | 'stargate' // LayerZero Stargate + | (string & {}) + +/** + * Producer-attested intent for an L1->L2 bridge. The envelope is the L1 + * deposit / lock transaction; this intent describes the L2-side outcome + * the wallet preview should surface ("you will receive 100 USDC on + * Arbitrum One via Across"). + */ +export type L1ToL2BridgeIntent = { + provider: L1ToL2BridgeProvider + /** Source chain (almost always Ethereum mainnet / Sepolia). CAIP-2. */ + l1ChainId: `eip155:${number}` + /** Destination Arbitrum chain (One / Sepolia / Nova). CAIP-2. */ + l2ChainId: ArbitrumChainId + /** Asset deposited on L1 (ERC-20 contract or 'native' for ETH). */ + tokenIn: `0x${string}` | 'native' + /** Asset expected on L2. Omit when same as tokenIn (canonical 1:1 wrap). */ + tokenOut?: `0x${string}` | 'native' + /** Amount in smallest L1 units (hex quantity). */ + amount: `0x${string}` + /** Optional override L2 recipient. Defaults to msg.sender on L1. */ + recipient?: `0x${string}` + /** Optional producer estimate of total L1+L2 fee cost (informational). */ + estimatedFeeWei?: `0x${string}` + /** Optional time bound after which the intent is stale (Unix seconds). */ + expiresAt?: number +} + +/** + * UX hints for an Arbitrum retryable ticket (the L1->L2 message-passing + * primitive). Wallet previews can surface these so the user understands + * the gas budget reserved for the L2 leg and where any refund will land. + * + * Underlying contract: Arbitrum Inbox.createRetryableTicket / + * unsafeCreateRetryableTicket on L1. + */ +export type RetryableTicketHints = { + /** Gas limit reserved for the L2 leg of the retryable. */ + l2Gas: `0x${string}` + /** Max L2 gas price the producer is willing to pay. */ + l2GasPriceBid: `0x${string}` + /** Max submission cost (covers the retryable-ticket creation overhead). */ + maxSubmissionCost: `0x${string}` + /** Address that receives the callvalue if the retryable is canceled or fails. */ + callValueRefundAddress?: `0x${string}` + /** Address that receives excess L1 fees after submission. */ + excessFeeRefundAddress?: `0x${string}` +} + +/** + * Sequencer-fee breakdown shown to the user before signing. Arbitrum + * charges L2 transactions for both L2 compute (gasUsed * l2BaseFee) and + * the L1 calldata cost (encoded calldata bytes posted to Ethereum, or + * compressed via Brotli on Arbitrum Nova's data-availability committee). + * + * For Nova specifically, `isCompressed: true` reflects the AnyTrust DAC + * compression saving - the on-chain L1 calldata cost is typically <5% + * of the equivalent Arbitrum One number. + */ +export type SequencerFeePreview = { + /** L2 gas estimate (compute portion). */ + l2GasEstimate: `0x${string}` + /** Bytes of L1 calldata the sequencer will post (post-compression on Nova). */ + l1CalldataBytes: number + /** L1 base fee at preview time. */ + l1BaseFeeWei: `0x${string}` + /** Estimated L1 calldata cost in wei. */ + l1FeeWei: `0x${string}` + /** Estimated L2 compute cost in wei. */ + l2FeeWei: `0x${string}` + /** Total estimated fee in wei (l1FeeWei + l2FeeWei). */ + totalFeeWei: `0x${string}` + /** True when the chain compresses calldata before posting (Nova / AnyTrust). */ + isCompressed: boolean + /** Block at which the preview was computed. */ + previewBlock?: number +} + +/** + * Arbitrum-specific calldata decode result. Returned by `decodeArbitrumCall` + * for known selectors; `null` for everything else. Skeleton; alpha.1 will + * expand the registry and add per-protocol structured fields. + */ +export type ArbitrumDecodedCall = + | { kind: 'bridge-deposit'; provider: L1ToL2BridgeProvider; contractLabel: string } + | { kind: 'retryable-create'; contractLabel: string } + | { kind: 'arbsys'; method: string } + | { kind: 'unknown'; contractLabel?: string } + +/** + * Aggregate shape of the `meta.arbitrum` slot. All three sub-keys are + * independently optional; producers attach what they have. + */ +export type ArbitrumMeta = { + bridge?: L1ToL2BridgeIntent + retryable?: RetryableTicketHints + sequencerFee?: SequencerFeePreview +} + +export type EnvelopeWithArbitrum = { + meta?: { + arbitrum?: ArbitrumMeta + [key: string]: unknown + } + [key: string]: unknown +} diff --git a/packages/arbitrum-adapter/tsconfig.json b/packages/arbitrum-adapter/tsconfig.json new file mode 100644 index 0000000..952e88f --- /dev/null +++ b/packages/arbitrum-adapter/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "incremental": false + }, + "include": ["src"] +} diff --git a/packages/arbitrum-adapter/tsup.config.ts b/packages/arbitrum-adapter/tsup.config.ts new file mode 100644 index 0000000..f69c36a --- /dev/null +++ b/packages/arbitrum-adapter/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: [ 'src/index.ts' ], + format: [ 'esm', 'cjs' ], + dts: true, + splitting: false, + sourcemap: false, + clean: true, + treeshake: true, +}) diff --git a/packages/arbitrum-adapter/vitest.config.ts b/packages/arbitrum-adapter/vitest.config.ts new file mode 100644 index 0000000..bc612c2 --- /dev/null +++ b/packages/arbitrum-adapter/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: [ 'src/**/*.spec.{ts,tsx}' ], + environment: 'node', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6d7249..8242923 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,6 +261,22 @@ importers: specifier: ^5 version: 5.9.3 + packages/arbitrum-adapter: + dependencies: + '@txkit/tx-protocol': + specifier: workspace:^ + version: link:../tx-protocol + devDependencies: + tsup: + specifier: ^8.4.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^4.1.1 + version: 4.1.1(@types/node@25.5.0)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/core: devDependencies: tsup: @@ -377,7 +393,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.1 - version: 4.1.1(@types/node@25.5.0)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.1(@types/node@25.5.0)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) zod-to-json-schema: specifier: ^3.25.2 version: 3.25.2(zod@3.25.76) @@ -13313,6 +13329,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.1(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.1 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + '@vitest/mocker@4.1.1(vite@8.0.8(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.1 @@ -20320,6 +20344,21 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.8 + rolldown: 1.0.0-rc.15 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + esbuild: 0.27.4 + fsevents: 2.3.3 + jiti: 2.6.1 + tsx: 4.21.0 + yaml: 2.8.3 + vite@8.0.8(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 @@ -20339,6 +20378,33 @@ snapshots: optionalDependencies: vite: 7.3.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vitest@4.1.1(@types/node@25.5.0)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.8(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 + transitivePeerDependencies: + - msw + vitest@4.1.1(@types/node@25.5.0)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.1 From 9183a22247364932c1f0424264c0a442d762e12c Mon Sep 17 00:00:00 2001 From: MikeDiam Date: Fri, 29 May 2026 13:16:53 +0300 Subject: [PATCH 2/2] ci(release): add @txkit/arbitrum-adapter publish step + idempotent guards - New `Publish @txkit/arbitrum-adapter` step before the @txkit/react step (the only consumer of the cohort with internal deps, kept last). - Each publish step now wraps `pnpm publish` in a `npm view ... >/dev/null 2>&1` check so cohort releases ship only the packages whose package.json version is not yet on the registry. A v* tag can add a new package without forcing a synchronized cohort bump for the seven existing ones (alpha.3 will stay alpha.3, arbitrum-adapter alpha.0 publishes cleanly). - Behaviour for the documented synchronized-cohort path is unchanged: bump everyone's version, tag v0.1.0-alpha.N, all eight publish. --- .github/workflows/release.yml | 72 +++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed18947..e8ef289 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,38 +38,96 @@ jobs: # Publish order respects dependency graph: core has no internal # deps, themes is css-only, tx-protocol has no @txkit deps, react # depends on @txkit/core. + # + # Each step is idempotent (publish-if-new): a cohort release only + # ships packages whose package.json version is not yet on the npm + # registry. Lets a tag like v0.1.0-alpha.N add a single new package + # without forcing a synchronized cohort bump for the others. - name: Publish @txkit/core - run: pnpm --filter @txkit/core publish --no-git-checks --access public --tag alpha + run: | + VERSION=$(node -p "require('./packages/core/package.json').version") + if npm view "@txkit/core@$VERSION" version >/dev/null 2>&1; then + echo "@txkit/core@$VERSION already on npm, skipping" + else + pnpm --filter @txkit/core publish --no-git-checks --access public --tag alpha + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish @txkit/themes - run: pnpm --filter @txkit/themes publish --no-git-checks --access public --tag alpha + run: | + VERSION=$(node -p "require('./packages/themes/package.json').version") + if npm view "@txkit/themes@$VERSION" version >/dev/null 2>&1; then + echo "@txkit/themes@$VERSION already on npm, skipping" + else + pnpm --filter @txkit/themes publish --no-git-checks --access public --tag alpha + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish @txkit/tx-protocol - run: pnpm --filter @txkit/tx-protocol publish --no-git-checks --access public --tag alpha + run: | + VERSION=$(node -p "require('./packages/tx-protocol/package.json').version") + if npm view "@txkit/tx-protocol@$VERSION" version >/dev/null 2>&1; then + echo "@txkit/tx-protocol@$VERSION already on npm, skipping" + else + pnpm --filter @txkit/tx-protocol publish --no-git-checks --access public --tag alpha + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish @txkit/tx-decoder - run: pnpm --filter @txkit/tx-decoder publish --no-git-checks --access public --tag alpha + run: | + VERSION=$(node -p "require('./packages/tx-decoder/package.json').version") + if npm view "@txkit/tx-decoder@$VERSION" version >/dev/null 2>&1; then + echo "@txkit/tx-decoder@$VERSION already on npm, skipping" + else + pnpm --filter @txkit/tx-decoder publish --no-git-checks --access public --tag alpha + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish @txkit/ows-adapter - run: pnpm --filter @txkit/ows-adapter publish --no-git-checks --access public --tag alpha + run: | + VERSION=$(node -p "require('./packages/ows-adapter/package.json').version") + if npm view "@txkit/ows-adapter@$VERSION" version >/dev/null 2>&1; then + echo "@txkit/ows-adapter@$VERSION already on npm, skipping" + else + pnpm --filter @txkit/ows-adapter publish --no-git-checks --access public --tag alpha + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish @txkit/x402-adapter - run: pnpm --filter @txkit/x402-adapter publish --no-git-checks --access public --tag alpha + run: | + VERSION=$(node -p "require('./packages/x402-adapter/package.json').version") + if npm view "@txkit/x402-adapter@$VERSION" version >/dev/null 2>&1; then + echo "@txkit/x402-adapter@$VERSION already on npm, skipping" + else + pnpm --filter @txkit/x402-adapter publish --no-git-checks --access public --tag alpha + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish @txkit/arbitrum-adapter + run: | + VERSION=$(node -p "require('./packages/arbitrum-adapter/package.json').version") + if npm view "@txkit/arbitrum-adapter@$VERSION" version >/dev/null 2>&1; then + echo "@txkit/arbitrum-adapter@$VERSION already on npm, skipping" + else + pnpm --filter @txkit/arbitrum-adapter publish --no-git-checks --access public --tag alpha + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish @txkit/react - run: pnpm --filter @txkit/react publish --no-git-checks --access public --tag alpha + run: | + VERSION=$(node -p "require('./packages/react/package.json').version") + if npm view "@txkit/react@$VERSION" version >/dev/null 2>&1; then + echo "@txkit/react@$VERSION already on npm, skipping" + else + pnpm --filter @txkit/react publish --no-git-checks --access public --tag alpha + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}