diff --git a/.changeset/config.json b/.changeset/config.json index 3fabfd33..9bcd3a7f 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -10,6 +10,7 @@ "@omni-bridge/solana", "@omni-bridge/btc", "@omni-bridge/starknet", + "@omni-bridge/aptos", "@omni-bridge/sdk" ] ], diff --git a/.changeset/curly-eyes-search.md b/.changeset/curly-eyes-search.md new file mode 100644 index 00000000..b358beb0 --- /dev/null +++ b/.changeset/curly-eyes-search.md @@ -0,0 +1,8 @@ +--- +"@omni-bridge/core": minor +"@omni-bridge/near": minor +"@omni-bridge/aptos": minor +"@omni-bridge/sdk": minor +--- + +Add Aptos chain support: new `@omni-bridge/aptos` package with `createAptosBuilder` (init_transfer, log_metadata, deploy_token, fin_transfer entry-function payloads), event helpers for MPC proof construction, `aptos:` OmniAddress prefix, `ChainKind.Aptos`, and NEAR storage borsh schema support diff --git a/.github/prompts/pr-review.prompt.md b/.github/prompts/pr-review.prompt.md new file mode 100644 index 00000000..2036f23e --- /dev/null +++ b/.github/prompts/pr-review.prompt.md @@ -0,0 +1,98 @@ +You are reviewing a TypeScript pull request for **bridge-sdk-js** — the Omni Bridge SDK: a bun monorepo of `@omni-bridge/*` packages that validate cross-chain transfers and build **unsigned** transactions for the [Omni Bridge](https://github.com/Near-One/omni-bridge) protocol across NEAR, EVM chains (Ethereum, Arbitrum, Base, BNB, Polygon, Abstract), Solana, Fogo, Starknet, Aptos, Bitcoin, and Zcash. Consumers sign and broadcast what the SDK returns, so correctness here means: **every encoded byte matches the target chain's on-chain contract, amounts survive decimal normalization, and adding or changing a chain is wired through every layer so nothing is silently mis-routed.** + +**IMPORTANT - CONTEXT AWARENESS:** +- Review any existing PR comments and discussions provided alongside this prompt before giving feedback +- Do not duplicate points already raised in existing discussions +- If a resolved thread addressed an issue, do not re-raise it +- You have read access to the checked-out repository — use `Read`, `Grep`, and `Glob` to verify how changes interact with surrounding code, look up referenced types/functions/tests, and consult [CLAUDE.md] for project structure, key concepts (transaction builder pattern, factory per chain, OmniAddress system, decimal normalization), and conventions +- Use `gh pr diff` for the full diff and `gh pr view` for PR metadata + +PRIORITY CHECKS (report only if found): + +1. Wire-format fidelity (the cardinal sins of this codebase) + - Borsh discriminants are POSITIONAL: the `ChainKind` enum in `packages/core/src/types.ts` and the `OmniAddressSchema` `b.enum` in `packages/near/src/storage.ts` must match the declaration order of Rust `omni_types` exactly (`b.nativeEnum`/`b.enum` serialize the position, not the value). New variants are append-only; reordering or inserting silently corrupts every `fin_transfer`/`deploy_token`/`bind_token` payload. `packages/near/tests/chain-kind-schema.test.ts` must lock any new discriminant + - Per-chain encodings must match the on-chain contract and bridge-sdk-rs byte-for-byte: EVM calldata (viem ABI, native-token `value` semantics), Solana instruction data + PDA seeds (seeds come from the program IDL — never modified), Starknet calldata (Cairo `ByteArray`, u256 low/high word order, `Option` variant tags), Aptos entry-function args (canonical zero-padded 64-hex addresses, u64/u128 as decimal strings, `vector` as `number[]` — a hex STRING gets UTF-8-encoded by the ts-sdk, `Option` None as `null`), NEAR borsh args. Verify argument ORDER, integer widths, and nonce handling + - 65-byte MPC signatures split correctly (`r||s` + `v`); per-chain signature encodings (Starknet felts vs Aptos rs/v) not interchanged + - Decimal normalization: amounts that don't survive source→destination decimal conversion are silent fund loss — `validateTransferAmount()` must guard every new path + +2. Chain wiring exhaustiveness + - A new or changed chain must thread through ALL of: `packages/core/src/types.ts` (`ChainKind`, `OmniAddress` union, `ChainPrefix`), `utils/address.ts` (both prefix maps), `config.ts` (addresses; optional key + clear error if not yet deployed), `bridge.ts` (`chainKindToApiChain`, `getContractAddress`), `api.ts` (`ChainSchema` z.enum, `TransactionSchema` variant + its refine list), `utils/token.ts` (token prefix maps), `packages/near/src/storage.ts` (borsh schema arm + `parseOmniAddress` case), the sdk umbrella (`packages/sdk` index/package.json/tsconfig), root `tsconfig.json` references, `.changeset/config.json` fixed group, and the chain enumerations in README.md and `docs/` (incl. the type code blocks in `docs/reference/core.mdx`) + - The `Record` maps and `never`-default switches are compile-enforced — but the zod enums, borsh schema order, token maps, and docs are NOT; check those by hand + - Chain-classification gating (`isEvmChain`, UTXO-only paths, SVM-only paths) must match the chain's real nature; a chain folded into the wrong arm is a silent bug + +3. Backend API contract + - Zod schemas in `packages/core/src/api.ts` must match the bridge indexer endpoint's responses field-for-field (names, types, optionality, chain-name casing like `"HlEvm"`); a schema mismatch makes `BridgeAPI` throw on valid backend data + +4. Untrusted input robustness + - Event parsers and RPC-response handling consume attacker-influenced data (recipient strings, token metadata, on-chain event fields): validate before trusting — strict decimal-integer parsing (bare `BigInt()` accepts hex/signed/empty), shape-check hashes/addresses before interpolating into URLs, fail fast on missing fields instead of returning `undefined` + - bigint edge cases (max u64/u128, zero, truncation), short-form vs zero-padded address handling, lowercase normalization + +5. Security + - The SDK never holds private keys (transaction-builder pattern) — flag anything that accepts, logs, or embeds key material or secrets + - Hardcoded credentials, or RPC URLs with embedded tokens, in source or committed config + +6. Public API & release hygiene + - Unsigned-transaction types must stay structurally compatible with their stated consumer libraries (viem/ethers v6, @solana/web3.js, starknet.js, @aptos-labs/ts-sdk, near shims) — a field type change can break consumers without failing this repo's build + - Changes to public API need a hand-written changeset (`.changeset/*.md`) with the right bump level; new packages must join the changesets fixed group and the sdk umbrella re-exports (watch for `export *` name collisions across packages) + - Per [CLAUDE.md] Workflow Rules: reference docs, package READMEs, and guide snippets must stay in sync with exports — flag doc code blocks that no longer compile or show APIs that don't exist + +7. Logic & code quality + - Logic flaws, missing edge cases (empty inputs, `undefined` under `exactOptionalPropertyTypes`/`noUncheckedIndexedAccess`), unhandled promise rejections, missing error context on fetch failures + - CI gates on `bun run build`, `lint` (Biome), and `test` (Vitest) — flag code that would fail them: missing `.js` import extensions, Node.js APIs in `src/` (`Buffer`, `crypto`, `fs` — use `Uint8Array`/`TextEncoder`/`@noble/hashes`), formatting drift + - New chain packages should follow the established template (`packages/starknet`, `packages/aptos`): builder factory + encoding + events modules, exact-payload unit tests against Rust-SDK ground-truth vectors; flag gratuitous divergence + +REVIEW STYLE: +- List only issues that should block the merge +- Use bullet points, be direct and specific +- Provide code suggestions for fixes when helpful +- Do NOT comment on style, formatting, naming, or documentation unless it causes a bug +- Do NOT restate what the diff already shows +- If no critical issues found: approve with a one-line summary +- Sign off with: ✅ (approved) or ⚠️ (issues found) + +REQUIRED OUTPUT STRUCTURE: + +The review body must follow this layout: + +``` +## Pull request overview + +<2–4 sentence narrative summary of what this PR does and why.> + +**Changes:** +- + +### Reviewed changes + +
+Per-file summary + +| File | Description | +| ---- | ----------- | +| path/to/file.ts | What changed in this file | +| ... | ... | + +
+ +### Findings + +**Blocking** (must fix before merge): +- `path/to/file.ts:LINE` — + +**Non-blocking** (nits, follow-ups, suggestions): +- `path/to/file.ts:LINE` — + + + + +✅ Approved +⚠️ Issues found +``` + +Anchor every finding with a `file:line` reference so reviewers can jump to the location. + +Consult the repository's [CLAUDE.md] for project-specific conventions (AGENTS.md points to the same file). +Don't try to use `gh pr review` you don't have permissions for that and it will fail. +Please always use `gh pr comment` to post your review instead. + +[CLAUDE.md]: ../../CLAUDE.md diff --git a/.github/scripts/fetch-pr-comments.sh b/.github/scripts/fetch-pr-comments.sh new file mode 100755 index 00000000..99a27fed --- /dev/null +++ b/.github/scripts/fetch-pr-comments.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fetches PR comments via GraphQL and formats them for Claude review. +# +# Required env vars: GH_TOKEN, PR_NUMBER, REPO_OWNER, REPO_NAME +# Outputs: /tmp/pr_comments_context.txt + +QUERY='query($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + comments(first: 100) { + totalCount + nodes { + author { login } + body + createdAt + } + } + reviewThreads(first: 100) { + totalCount + nodes { + isResolved + isOutdated + path + line + comments(first: 50) { + nodes { + author { login } + body + createdAt + diffHunk + } + } + } + } + reviews(first: 50) { + totalCount + nodes { + author { login } + body + state + createdAt + } + } + } + } +}' + +# Execute GraphQL query and check for errors +if ! COMMENTS_JSON=$(gh api graphql \ + -f query="$QUERY" \ + -f owner="$REPO_OWNER" \ + -f repo="$REPO_NAME" \ + -F prNumber="$PR_NUMBER"); then + echo "Warning: Failed to fetch PR comments. Proceeding without comment context." + echo "⚠️ Unable to fetch existing comments due to API error." > /tmp/pr_comments_context.txt + exit 0 +fi + +# Project the relevant fields to JSON context for Claude, truncating long diff +# hunks to keep the token budget bounded. The LLM reads JSON directly, so no +# prose formatting is needed. +# Write to file instead of env var to avoid E2BIG on large PRs +if [ -n "$COMMENTS_JSON" ]; then + echo "$COMMENTS_JSON" > /tmp/pr_comments_json.txt + if ! jq ' + # Truncate a diff hunk to ~$max chars at line boundaries (never mid-line). + def truncate_hunk($max): + if (length <= $max) then . + else + (reduce (split("\n")[]) as $line ({acc: [], count: 0, done: false}; + if .done then . + elif ((.count + ($line | length) + 1) > $max and (.acc | length) > 0) + then .done = true + else {acc: (.acc + [$line]), count: (.count + ($line | length) + 1), done: false} + end) + | .acc | join("\n")) + "\n... (truncated)" + end; + (.data.repository.pullRequest // {}) | { + comments: [(.comments.nodes // [])[] | {author: .author.login, body, createdAt}], + reviews: [(.reviews.nodes // [])[] | select((.body // "") != "") | {author: .author.login, state, body, createdAt}], + threads: [(.reviewThreads.nodes // [])[] | {path, line, isResolved, isOutdated, + comments: [(.comments.nodes // [])[] | {author: .author.login, body, createdAt, diffHunk: ((.diffHunk // "") | truncate_hunk(500))}]}] + }' /tmp/pr_comments_json.txt > /tmp/pr_comments_context.txt; then + echo "⚠️ Unable to parse comment data." > /tmp/pr_comments_context.txt + fi +else + echo "⚠️ No comments data to process." > /tmp/pr_comments_context.txt + exit 0 +fi diff --git a/.github/workflows/claude-pr-review.yml b/.github/workflows/claude-pr-review.yml new file mode 100644 index 00000000..20a72fac --- /dev/null +++ b/.github/workflows/claude-pr-review.yml @@ -0,0 +1,73 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, ready_for_review] # When PR is ready for review (not draft) + issue_comment: + types: [created] # Listen for @claude mentions in PR comments + +jobs: + claude-review: + # Run if: (PR opened/ready AND not draft) OR @claude review in PR comment + if: | + (github.event_name == 'pull_request' && !github.event.pull_request.draft) || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request && + (contains(github.event.comment.body, '@claude review') || + contains(github.event.comment.body, '@claude code review'))) + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Fetch PR Comments Context + id: fetch-comments + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + run: .github/scripts/fetch-pr-comments.sh + + - name: Build review prompt + id: build-prompt + run: | + DELIM="PROMPT_EOF_$(uuidgen)" + { + echo "REVIEW_PROMPT<<$DELIM" + echo '' + echo "REPO: ${{ github.repository }}" + echo "PR NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}" + echo 'LANGUAGE: TypeScript' + echo '' + echo '' + echo '' + cat /tmp/pr_comments_context.txt + echo '' + echo '' + echo '' + cat .github/prompts/pr-review.prompt.md + echo '' + echo "$DELIM" + } >> "$GITHUB_ENV" + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@2cc1ac1331eac7a6a96d716dd204dd2888d0fcd2 # main @ Claude Code 2.1.128 / Agent SDK 0.2.128 (needed for --effort xhigh) + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API }} + prompt: ${{ env.REVIEW_PROMPT }} + claude_args: >- + --model claude-opus-4-8 + --effort xhigh + --allowed-tools "Read,Grep,Glob,Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)" diff --git a/CLAUDE.md b/CLAUDE.md index ddddfdf5..1cb5fe53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ packages/ ├── evm/ # @omni-bridge/evm - EVM transaction builder (viem-based) ├── near/ # @omni-bridge/near - NEAR transaction builder + shims ├── solana/ # @omni-bridge/solana - Solana instruction builder (Anchor-based) +├── starknet/ # @omni-bridge/starknet - Starknet transaction builder +├── aptos/ # @omni-bridge/aptos - Aptos entry-function payload builder ├── btc/ # @omni-bridge/btc - Bitcoin/Zcash UTXO operations └── sdk/ # @omni-bridge/sdk - Umbrella re-export of all packages ``` @@ -43,6 +45,8 @@ packages/ - `createEvmBuilder({ network, chain })` → EVM transaction building - `createNearBuilder({ network })` → NEAR transaction building - `createSolanaBuilder({ network, connection? })` → Solana instruction building +- `createStarknetBuilder({ network, bridgeAddress? })` → Starknet Call[] building +- `createAptosBuilder({ network, bridgeAddress? })` → Aptos entry-function payloads - `createBtcBuilder({ network, chain })` → Bitcoin/Zcash UTXO operations **Unsigned Transaction Types**: SDK returns library-agnostic plain objects: @@ -50,10 +54,12 @@ packages/ - `EvmUnsignedTransaction` → Compatible with viem and ethers v6 directly - `NearUnsignedTransaction` → Use shims: `toNearKitTransaction()` or `sendWithNearApiJs()` - `TransactionInstruction[]` → Native @solana/web3.js instructions +- `Call[]` → Native starknet.js calls +- `AptosFunctionPayload` → Compatible with @aptos-labs/ts-sdk `InputEntryFunctionData` - `BtcWithdrawalPlan` → UTXO inputs/outputs for signing **OmniAddress System**: Cross-chain addresses use chain prefixes: -`eth:0x...`, `near:account.near`, `sol:...`, `base:0x...`, `arb:0x...`, `btc:...`, `zcash:...` +`eth:0x...`, `near:account.near`, `sol:...`, `base:0x...`, `arb:0x...`, `strk:0x...`, `aptos:0x...`, `btc:...`, `zcash:...` ### Transfer Flow @@ -71,6 +77,8 @@ packages/ - `packages/near/src/builder.ts` - NEAR transaction builder - `packages/near/src/shims.ts` - near-kit and near-api-js conversion helpers - `packages/solana/src/builder.ts` - Solana instruction builder +- `packages/starknet/src/builder.ts` - Starknet transaction builder +- `packages/aptos/src/builder.ts` - Aptos payload builder - `packages/btc/src/builder.ts` - Bitcoin/Zcash UTXO builder ## Testing Patterns diff --git a/README.md b/README.md index 5ff2e8c3..36a8c72d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![Status](https://img.shields.io/badge/Status-Beta-blue) ![License](https://img.shields.io/badge/License-MIT-green) -TypeScript SDK for cross-chain token transfers via the [Omni Bridge](https://github.com/Near-one/omni-bridge) protocol. Transfer tokens between Ethereum, NEAR, Solana, Base, Arbitrum, Polygon, BNB Chain, Abstract, Starknet, Fogo, Bitcoin, and Zcash. +TypeScript SDK for cross-chain token transfers via the [Omni Bridge](https://github.com/Near-one/omni-bridge) protocol. Transfer tokens between Ethereum, NEAR, Solana, Base, Arbitrum, Polygon, BNB Chain, Abstract, Starknet, Aptos, Fogo, Bitcoin, and Zcash. ## Install @@ -52,25 +52,31 @@ The SDK uses **OmniAddress** format — a chain prefix followed by the native ad eth:0x1234... → Ethereum base:0x1234... → Base arb:0x1234... → Arbitrum +pol:0x1234... → Polygon near:alice.near → NEAR sol:ABC123... → Solana +fogo:ABC123... → Fogo abs:0x1234... → Abstract strk:0x1234... → Starknet +aptos:0x1234... → Aptos btc:bc1q... → Bitcoin +zcash:t1abc... → Zcash ``` This makes it unambiguous which chain an address belongs to, which is essential for cross-chain operations. ## Packages -| Package | Description | -| --------------------- | ---------------------------------------------- | -| `@omni-bridge/core` | Validation, types, configuration, API client | -| `@omni-bridge/evm` | Ethereum, Base, Arbitrum, Polygon, BNB Chain, Abstract | -| `@omni-bridge/near` | NEAR Protocol | -| `@omni-bridge/solana` | Solana | -| `@omni-bridge/btc` | Bitcoin, Zcash | -| `@omni-bridge/sdk` | Umbrella package (re-exports all of the above) | +| Package | Description | +| ----------------------- | ---------------------------------------------- | +| `@omni-bridge/core` | Validation, types, configuration, API client | +| `@omni-bridge/evm` | Ethereum, Base, Arbitrum, Polygon, BNB Chain, Abstract | +| `@omni-bridge/near` | NEAR Protocol | +| `@omni-bridge/solana` | Solana, Fogo | +| `@omni-bridge/starknet` | Starknet | +| `@omni-bridge/aptos` | Aptos | +| `@omni-bridge/btc` | Bitcoin, Zcash | +| `@omni-bridge/sdk` | Umbrella package (re-exports all of the above) | Install `@omni-bridge/sdk` for everything, or pick individual packages to minimize bundle size. diff --git a/bun.lock b/bun.lock index 3f2286e3..3a84a247 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,18 @@ "vitest": "^4.1.7", }, }, + "packages/aptos": { + "name": "@omni-bridge/aptos", + "version": "0.9.0", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@omni-bridge/core": "workspace:*", + "@scure/base": "^2.0.0", + }, + "devDependencies": { + "typescript": "^5.9.3", + }, + }, "packages/btc": { "name": "@omni-bridge/btc", "version": "0.9.0", @@ -78,6 +90,7 @@ "name": "@omni-bridge/sdk", "version": "0.9.0", "dependencies": { + "@omni-bridge/aptos": "workspace:*", "@omni-bridge/btc": "workspace:*", "@omni-bridge/core": "workspace:*", "@omni-bridge/evm": "workspace:*", @@ -424,6 +437,8 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@omni-bridge/aptos": ["@omni-bridge/aptos@workspace:packages/aptos"], + "@omni-bridge/btc": ["@omni-bridge/btc@workspace:packages/btc"], "@omni-bridge/core": ["@omni-bridge/core@workspace:packages/core"], diff --git a/docs/core-concepts/omni-addresses.mdx b/docs/core-concepts/omni-addresses.mdx index 8c1f4c63..7d42942d 100644 --- a/docs/core-concepts/omni-addresses.mdx +++ b/docs/core-concepts/omni-addresses.mdx @@ -25,6 +25,7 @@ The SDK uses a unified address format called OmniAddress. It's simple: a chain p | Abstract | `abs:0x1234567890123456789012345678901234567890` | | Starknet | `strk:0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7` | | Fogo | `fogo:dahPEoZGXfyV58JqqH85okdHmpN8U2q8owgPUXSCPxe` | +| Aptos | `aptos:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf` | ## Chain Prefixes @@ -42,6 +43,7 @@ The SDK uses a unified address format called OmniAddress. It's simple: a chain p | `abs` | Abstract | 2741 | | `strk` | Starknet | — | | `fogo` | Fogo (SVM-compatible) | — | +| `aptos` | Aptos | — | ## Tokens Use The Same Format @@ -66,6 +68,7 @@ For native tokens (ETH, SOL, etc.), use the zero address or default representati |-------|---------------------| | EVM chains | `eth:0x0000000000000000000000000000000000000000` | | Solana | `sol:11111111111111111111111111111111` | +| Aptos | `aptos:0x000000000000000000000000000000000000000000000000000000000000000a` (APT is the Fungible Asset at `0xa`) | ## Utility Functions @@ -109,6 +112,7 @@ enum ChainKind { Strk = 10, Abs = 11, Fogo = 12, + Aptos = 13, } ``` diff --git a/docs/docs.json b/docs/docs.json index b3202b81..4d4d70b0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -2,7 +2,7 @@ "$schema": "https://mintlify.com/docs.json", "theme": "mint", "name": "Omni Bridge SDK", - "description": "TypeScript SDK for cross-chain token transfers via the Omni Bridge protocol. Transfer tokens between Ethereum, NEAR, Solana, Base, Arbitrum, Polygon, BNB Chain, Abstract, Starknet, Fogo, Bitcoin, and Zcash.", + "description": "TypeScript SDK for cross-chain token transfers via the Omni Bridge protocol. Transfer tokens between Ethereum, NEAR, Solana, Base, Arbitrum, Polygon, BNB Chain, Abstract, Starknet, Aptos, Fogo, Bitcoin, and Zcash.", "colors": { "primary": "#6366F1", "light": "#818CF8", @@ -37,6 +37,7 @@ "guides/near", "guides/solana", "guides/bitcoin", + "guides/aptos", "guides/fees", "guides/tracking" ] @@ -64,7 +65,8 @@ "reference/evm", "reference/near", "reference/solana", - "reference/btc" + "reference/btc", + "reference/aptos" ] } ] @@ -90,7 +92,12 @@ } }, "contextual": { - "options": ["copy", "view", "chatgpt", "claude"] + "options": [ + "copy", + "view", + "chatgpt", + "claude" + ] }, "search": { "prompt": "Search documentation..." diff --git a/docs/getting-started.mdx b/docs/getting-started.mdx index a50f5ed8..ab978534 100644 --- a/docs/getting-started.mdx +++ b/docs/getting-started.mdx @@ -163,6 +163,9 @@ Now that you understand the pattern, pick the guide for your source chain: Bitcoin and Zcash + + Aptos + Or learn more about the core concepts: diff --git a/docs/guides/aptos.mdx b/docs/guides/aptos.mdx new file mode 100644 index 00000000..988fad13 --- /dev/null +++ b/docs/guides/aptos.mdx @@ -0,0 +1,187 @@ +--- +title: Aptos +description: Bridge tokens from Aptos +--- + +This guide covers bridging tokens from Aptos to other chains. The Aptos builder returns plain entry-function payloads compatible with `InputEntryFunctionData` from `@aptos-labs/ts-sdk` — pass them straight to the transaction builder or a wallet adapter. + + + The Aptos bridge contract is not yet deployed, so no bridge address ships in the SDK config. + Until then, `bridge.validateTransfer()` throws `UNSUPPORTED_CHAIN` for Aptos-source transfers, + and the full validate-then-build flow below only works once the address ships. Against your own + deployment you can already use the builder payload APIs (pass the module address via + `bridgeAddress`), the event helpers, and the address utilities. + + +## Setup + +```typescript +import { createBridge } from "@omni-bridge/core" +import { createAptosBuilder } from "@omni-bridge/aptos" + +const bridge = createBridge({ network: "mainnet" }) +const aptosBuilder = createAptosBuilder({ + network: "mainnet", + bridgeAddress: "0x...", // omni_bridge module address (required until deployment) +}) +``` + +## Complete Transfer + +Once the bridge address ships in the SDK config, the standard validate-then-build flow applies: + +```typescript +import { createBridge } from "@omni-bridge/core" +import { createAptosBuilder } from "@omni-bridge/aptos" +import { Account, Aptos, AptosConfig, Ed25519PrivateKey, Network } from "@aptos-labs/ts-sdk" + +const bridge = createBridge({ network: "mainnet" }) +const aptosBuilder = createAptosBuilder({ network: "mainnet", bridgeAddress: "0x..." }) + +const aptos = new Aptos(new AptosConfig({ network: Network.MAINNET })) +const account = Account.fromPrivateKey({ privateKey: new Ed25519PrivateKey("0x...") }) + +// 1. Validate +const validated = await bridge.validateTransfer({ + // APT is the Fungible Asset at 0xa; tokens use their FA metadata object address + token: "aptos:0x000000000000000000000000000000000000000000000000000000000000000a", + amount: 100_000_000n, // 1 APT (8 decimals) + sender: `aptos:${account.accountAddress.toString()}`, + recipient: "near:alice.near", + fee: 0n, + nativeFee: 0n, +}) + +// 2. Build the entry-function payload +const payload = aptosBuilder.buildTransfer({ + token: "0xa", + amount: validated.params.amount, + fee: validated.params.fee, + nativeFee: validated.params.nativeFee, + recipient: validated.params.recipient, +}) + +// 3. Build, sign, and submit with @aptos-labs/ts-sdk +const transaction = await aptos.transaction.build.simple({ + sender: account.accountAddress, + data: payload, +}) +const pending = await aptos.signAndSubmitTransaction({ signer: account, transaction }) +await aptos.waitForTransaction({ transactionHash: pending.hash }) +``` + +With a wallet adapter (AIP-62), pass the payload as the transaction data instead: + +```typescript +const { hash } = await signAndSubmitTransaction({ data: payload }) +``` + +## How Transfers Work on Aptos + +A few things differ from other chains: + +- **No approval step.** The Move contract pulls funds directly from the + transaction signer, so `buildTransfer` returns a single payload. +- **Tokens are Fungible Assets.** The `token` is the FA metadata object + address. Native APT is the canonical FA at `0xa` and goes through the same + flow — there is no separate native-token path. +- **`fee` is inclusive.** The token fee is part of `amount` (it must be + strictly less than `amount`). The `nativeFee` is charged separately in APT. +- **Amounts must fit in u64.** Aptos FA balances are `u64`; the contract + rejects larger amounts. +- **No storage deposits.** Primary fungible stores are auto-created on + deposit. + +## Token Metadata + +Before a token can be bridged for the first time, its metadata must be logged +on the source chain and the wrapped token deployed on the destination. + +```typescript +// Log an existing Fungible Asset's metadata (permissionless) +const payload = aptosBuilder.buildLogMetadata("0x...") // FA metadata object address +``` + +To deploy a NEAR-originated token on Aptos, use the MPC signature from the +NEAR `LogMetadataEvent`: + +```typescript +const payload = aptosBuilder.buildDeployToken(signature, { + token: "wrap.near", // NEAR token id signed in the payload + name: "Wrapped NEAR", + symbol: "wNEAR", + decimals: 24, +}) +``` + +The bridged token's FA address is deterministic and can be computed offline: + +```typescript +import { + deriveAptosBridgeObjectAddress, + deriveAptosBridgedTokenAddress, +} from "@omni-bridge/aptos" + +const bridgeObject = deriveAptosBridgeObjectAddress("0x...") // module address +const tokenAddress = deriveAptosBridgedTokenAddress(bridgeObject, "wrap.near") +``` + +## Manual Finalization + +To finalize a transfer **to** Aptos yourself (instead of paying a relayer), +fetch the signed transfer from NEAR and build a `fin_transfer` payload: + +```typescript +import { ChainKind } from "@omni-bridge/core" + +const payload = aptosBuilder.buildFinalization(signature, { + destinationNonce: 1n, + originChain: ChainKind.Near, + originNonce: 123n, + tokenAddress: "0x...", // FA metadata object address on Aptos + amount: 1_000_000n, + recipient: "0x...", // Aptos account address + feeRecipient: "relayer.near", +}) +``` + +Check whether a transfer has already been finalised: + +```typescript +import { isAptosTransferFinalised } from "@omni-bridge/aptos" + +const done = await isAptosTransferFinalised( + "https://fullnode.mainnet.aptoslabs.com/v1", + "0x...", // bridge module address + destinationNonce, +) +``` + +## Reading Events + +After initiating a transfer, you can extract the `InitTransfer` event (e.g. +for relayers building MPC proofs): + +```typescript +import { getAptosInitTransferEvent, getAptosInitTransferLog } from "@omni-bridge/aptos" + +const rpcUrl = "https://fullnode.mainnet.aptoslabs.com/v1" + +// Decoded event +const event = await getAptosInitTransferEvent(rpcUrl, bridgeAddress, txHash) +console.log(event.originNonce, event.amount, event.recipient) + +// Raw log with metadata for MPC proof construction (tx hash + event index) +const log = await getAptosInitTransferLog(rpcUrl, bridgeAddress, txHash) +``` + +## Next Steps + + + + Calculate relayer fees + + + Track transfer status + + diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 732e2336..8ce3f82a 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -34,6 +34,9 @@ The SDK is **library agnostic**. It returns plain transaction objects that work Starknet + + Aptos + ## Packages @@ -57,6 +60,8 @@ npm install @omni-bridge/core @omni-bridge/near | `@omni-bridge/evm` | Ethereum, Base, Arbitrum, Polygon, BNB, Abstract | | `@omni-bridge/near` | NEAR Protocol | | `@omni-bridge/solana` | Solana, Fogo | +| `@omni-bridge/starknet` | Starknet | +| `@omni-bridge/aptos` | Aptos | | `@omni-bridge/btc` | Bitcoin, Zcash | | `@omni-bridge/sdk` | Re-exports everything | diff --git a/docs/reference/aptos.mdx b/docs/reference/aptos.mdx new file mode 100644 index 00000000..ac7ecd0d --- /dev/null +++ b/docs/reference/aptos.mdx @@ -0,0 +1,407 @@ +--- +title: "@omni-bridge/aptos" +description: Entry-function payload builder for Aptos +--- + +## Import + +```typescript +import { + createAptosBuilder, + type AptosBuilder, + type AptosBuilderConfig, + type AptosFunctionPayload, + type AptosTokenMetadata, + type AptosTransferPayload, + type AptosEventLog, + type AptosInitTransferEvent, + normalizeAptosAddress, + aptosAddressToBytes, + deriveAptosBridgeObjectAddress, + deriveAptosBridgedTokenAddress, + getAptosEventLog, + getAptosInitTransferLog, + getAptosDeployTokenLog, + getAptosFinTransferLog, + getAptosInitTransferEvent, + parseAptosInitTransferEvent, + isAptosTransferFinalised, + normalizeAptosEventData, +} from "@omni-bridge/aptos" +``` + +This package builds entry-function payloads for the `omni_bridge` Move package. Payloads are plain objects structurally compatible with `InputEntryFunctionData` from `@aptos-labs/ts-sdk` — pass them to `aptos.transaction.build.simple({ sender, data })` or to a wallet adapter's `signAndSubmitTransaction({ data })`. + +--- + +## createAptosBuilder + +Factory function to create an Aptos payload builder. + +### Signature + +```typescript +function createAptosBuilder(config: AptosBuilderConfig): AptosBuilder +``` + +### Parameters + + + + + The network to use for config lookups. + + + + Address the `omni_bridge` Move package is published under. Required until the + bridge contract is deployed and its address ships in the SDK config — the factory + throws if neither the override nor a configured address is available. Short-form + addresses are normalized to the canonical zero-padded form. + + + + +### Returns + +`AptosBuilder` — payload builder instance with a readonly `bridgeAddress`. + +### Example + +```typescript +import { createAptosBuilder } from "@omni-bridge/aptos" + +const builder = createAptosBuilder({ + network: "testnet", + bridgeAddress: "0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf", +}) +``` + +--- + +## AptosBuilder Methods + +### buildTransfer + +Build an `init_transfer` payload. No approval step is needed — the Move contract pulls funds directly from the transaction signer. + +#### Signature + +```typescript +buildTransfer(params: { + token: string + amount: bigint + fee: bigint + nativeFee: bigint + recipient: string + message?: string +}): AptosFunctionPayload +``` + +#### Parameters + + + Fungible Asset metadata object address of the token. Native APT is the canonical FA at `0xa`. + + + + Amount in token base units. Must fit in u64 (Aptos FA amounts are u64) and includes the token fee. + + + + Token-denominated relayer fee. Must be strictly less than `amount`. + + + + Relayer fee in APT base units (octas), transferred separately from `amount`. + + + + Destination as an OmniAddress string, e.g. `near:alice.near` or `eth:0x...`. + + + + Optional UTF-8 message forwarded to the destination chain. + + +#### Returns + +`AptosFunctionPayload` — an `init_transfer` entry-function payload. + +#### Example + +```typescript +const payload = builder.buildTransfer({ + token: "0xa", + amount: 100_000_000n, // 1 APT + fee: 0n, + nativeFee: 0n, + recipient: "near:alice.near", +}) + +const transaction = await aptos.transaction.build.simple({ + sender: account.accountAddress, + data: payload, +}) +``` + +--- + +### buildLogMetadata + +Build a `log_metadata` payload for an existing Fungible Asset (permissionless). The NEAR side picks up the emitted event to allow deploying the wrapped token. + +#### Signature + +```typescript +buildLogMetadata(token: string): AptosFunctionPayload +``` + +#### Parameters + + + Fungible Asset metadata object address. + + +#### Returns + +`AptosFunctionPayload` — a `log_metadata` entry-function payload. + +--- + +### buildDeployToken + +Build a `deploy_token` payload from a NEAR `LogMetadataEvent` MPC signature. Permissionless — the signature is the authorization. + +#### Signature + +```typescript +buildDeployToken( + signature: Uint8Array, + metadata: AptosTokenMetadata +): AptosFunctionPayload +``` + +#### Parameters + + + 65-byte Ethereum-style MPC signature (`r || s || v`) over the metadata payload. + + + + + + Source-chain token id signed in the payload (e.g. the NEAR account id). + + + Token name. + + + Token symbol. + + + Origin decimals as signed in the payload. The contract clamps the deployed FA to at most 8 decimals. + + + + +#### Returns + +`AptosFunctionPayload` — a `deploy_token` entry-function payload. + +--- + +### buildFinalization + +Build a `fin_transfer` payload from a NEAR `SignTransferEvent`. Permissionless — the signature is the authorization. + +#### Signature + +```typescript +buildFinalization( + signature: Uint8Array, + payload: AptosTransferPayload +): AptosFunctionPayload +``` + +#### Parameters + + + 65-byte Ethereum-style MPC signature (`r || s || v`) over the transfer message payload. + + + + + + Destination nonce assigned by the NEAR bridge. + + + Origin `ChainKind` value (e.g. `ChainKind.Near` = 1). + + + Origin nonce of the transfer. + + + Fungible Asset metadata object address on Aptos. + + + Amount in token base units (must fit in u64). + + + Aptos account address of the recipient. + + + NEAR account id of the fee recipient (omit for none). + + + UTF-8 message (omit or empty for none). + + + + +#### Returns + +`AptosFunctionPayload` — a `fin_transfer` entry-function payload. + +--- + +## Event Helpers + +All event helpers take an Aptos fullnode REST endpoint (including the `/v1` segment), the bridge module address, and a transaction hash. + +### getAptosInitTransferEvent + +Fetch a committed transaction and decode its `InitTransfer` event. + +```typescript +function getAptosInitTransferEvent( + rpcUrl: string, + bridgeAddress: string, + txHash: string +): Promise +``` + +```typescript +interface AptosInitTransferEvent { + sender: string // canonical Aptos address + tokenAddress: string // canonical Aptos address + originNonce: bigint + amount: bigint + fee: bigint + nativeFee: bigint + recipient: string // OmniAddress string + message: string // UTF-8 decoded; raw 0x… hex kept if not valid UTF-8 +} +``` + +### getAptosInitTransferLog / getAptosDeployTokenLog / getAptosFinTransferLog + +Extract a raw bridge event log with the metadata needed for MPC proof construction (transaction hash + event index, with canonical sorted-key JSON data). + +```typescript +function getAptosInitTransferLog( + rpcUrl: string, + bridgeAddress: string, + txHash: string +): Promise +``` + +```typescript +interface AptosEventLog { + accountAddress: string // event GUID account address, zero-padded + sequenceNumber: bigint + typeTag: string // e.g. "0x…::omni_bridge::InitTransfer" (verbatim) + data: string // canonical JSON (recursively sorted keys) + eventIndex: number // index in the transaction's events array +} +``` + +### parseAptosInitTransferEvent + +Parse an `InitTransfer` event from its decoded `data` object (as returned by the fullnode REST API). + +```typescript +function parseAptosInitTransferEvent(data: unknown): AptosInitTransferEvent +``` + +### isAptosTransferFinalised + +Check whether a transfer with the given destination nonce has been finalised, via the `is_transfer_finalised` view function. + +```typescript +function isAptosTransferFinalised( + rpcUrl: string, + bridgeAddress: string, + nonce: bigint +): Promise +``` + +### normalizeAptosEventData + +Canonical string form of an event's `data`: JSON with recursively sorted object keys, so consumers hash identical bytes regardless of provider key ordering. This is the form carried in `AptosEventLog.data` for MPC proof construction. + +```typescript +function normalizeAptosEventData(value: unknown): string +``` + +--- + +## Address Utilities + +### normalizeAptosAddress + +Normalize an Aptos account address to its canonical form (`0x` + 64 lowercase hex chars). Accepts short-form addresses with or without the `0x` prefix. + +```typescript +normalizeAptosAddress("0xa") +// "0x000000000000000000000000000000000000000000000000000000000000000a" +``` + +### aptosAddressToBytes + +Decode an Aptos account address into its 32 raw bytes. + +```typescript +function aptosAddressToBytes(address: string): Uint8Array +``` + +### deriveAptosBridgeObjectAddress + +Compute the deterministic bridge state object address for an `omni_bridge` deployment (named object with seed `"omni_bridge::state"`). This object custodies locked tokens and is the address registered as the Aptos factory on the NEAR bridge. + +```typescript +function deriveAptosBridgeObjectAddress(moduleAddress: string): string +``` + +### deriveAptosBridgedTokenAddress + +Compute the deterministic Fungible Asset address of a bridge-deployed token (named object created by the bridge object with the NEAR token id as seed). + +```typescript +const bridgeObject = deriveAptosBridgeObjectAddress("0x...") +const fa = deriveAptosBridgedTokenAddress(bridgeObject, "wrap.near") +``` + +--- + +## Types + +### AptosFunctionPayload + +```typescript +interface AptosFunctionPayload { + function: `${string}::${string}::${string}` // "0x…::omni_bridge::init_transfer" + typeArguments: string[] + functionArguments: (string | number | number[] | null)[] +} +``` + +Argument encoding conventions (accepted by the ts-sdk argument converter): + +| Move type | Encoding | +|-----------|----------| +| `address` / `Object` | canonical `0x`-prefixed 64-hex string | +| `u64` / `u128` | decimal string | +| `u8` | number | +| `String` | string | +| `vector` | plain byte array (`number[]`) | +| `Option` | inner value, or `null` for None | diff --git a/docs/reference/core.mdx b/docs/reference/core.mdx index 00c7670b..01d7880e 100644 --- a/docs/reference/core.mdx +++ b/docs/reference/core.mdx @@ -922,6 +922,8 @@ type OmniAddress = | `hlevm:${string}` | `abs:${string}` | `strk:${string}` + | `fogo:${string}` + | `aptos:${string}` ``` **Examples:** @@ -950,6 +952,8 @@ enum ChainKind { HyperEvm = 9, Strk = 10, Abs = 11, + Fogo = 12, + Aptos = 13, } ``` @@ -960,7 +964,7 @@ enum ChainKind { Chain prefix strings used in OmniAddress format. ```typescript -type ChainPrefix = "eth" | "near" | "sol" | "arb" | "base" | "bnb" | "btc" | "zcash" | "pol" | "hlevm" | "abs" | "strk" +type ChainPrefix = "eth" | "near" | "sol" | "arb" | "base" | "bnb" | "btc" | "zcash" | "pol" | "hlevm" | "abs" | "strk" | "fogo" | "aptos" ``` --- @@ -980,7 +984,7 @@ type Network = "mainnet" | "testnet" API chain name strings. ```typescript -type Chain = "Eth" | "Near" | "Sol" | "Arb" | "Base" | "Bnb" | "Btc" | "Zcash" | "Pol" | "Abs" | "HlEvm" | "Strk" +type Chain = "Eth" | "Near" | "Sol" | "Arb" | "Base" | "Bnb" | "Btc" | "Zcash" | "Pol" | "Abs" | "HlEvm" | "Strk" | "Fogo" | "Aptos" ``` --- @@ -1342,13 +1346,20 @@ interface ChainAddresses { base: EvmAddresses bnb: EvmAddresses pol: EvmAddresses + abs: EvmAddresses near: NearAddresses sol: SolanaAddresses btc: BtcAddresses zcash: ZcashAddresses + strk: StarknetAddresses + fogo?: SolanaAddresses + aptos?: AptosAddresses } ``` +`fogo` and `aptos` are optional: they are only present on networks where the +corresponding bridge contract is deployed. + --- ### EvmAddresses @@ -1425,6 +1436,31 @@ interface ZcashAddresses { --- +### StarknetAddresses + +Starknet chain configuration. + +```typescript +interface StarknetAddresses { + bridge: string +} +``` + +--- + +### AptosAddresses + +Aptos chain configuration. + +```typescript +interface AptosAddresses { + /** Account address the `omni_bridge` Move package is published under. */ + bridge: string +} +``` + +--- + ### WormholeNetwork Wormhole network type. diff --git a/packages/aptos/README.md b/packages/aptos/README.md new file mode 100644 index 00000000..3b80dd4d --- /dev/null +++ b/packages/aptos/README.md @@ -0,0 +1,67 @@ +# @omni-bridge/aptos + +Aptos transaction builder for the Omni Bridge SDK. Builds entry-function payloads for the `omni_bridge` Move package, compatible with `InputEntryFunctionData` from `@aptos-labs/ts-sdk`. + +## Install + +```bash +npm install @omni-bridge/core @omni-bridge/aptos +``` + +> **Note:** The Aptos bridge contract is not yet deployed, so no bridge address ships in the SDK config. `bridge.validateTransfer()` throws `UNSUPPORTED_CHAIN` for Aptos-source transfers until then, and the full validate-then-build flow below only works once the address ships. Against your own deployment you can already use the builder payload APIs (pass the module address via `bridgeAddress`), the event helpers, and the address utilities. + +## Usage + +```typescript +import { createBridge } from "@omni-bridge/core" +import { createAptosBuilder } from "@omni-bridge/aptos" +import { Account, Aptos, AptosConfig, Ed25519PrivateKey, Network } from "@aptos-labs/ts-sdk" + +const bridge = createBridge({ network: "mainnet" }) +const builder = createAptosBuilder({ network: "mainnet", bridgeAddress: "0x..." }) +const account = Account.fromPrivateKey({ privateKey: new Ed25519PrivateKey("0x...") }) + +// 1. Validate +const validated = await bridge.validateTransfer({ + token: "aptos:0x000000000000000000000000000000000000000000000000000000000000000a", // APT + amount: 100_000_000n, // 1 APT (8 decimals) + fee: 0n, + nativeFee: 0n, + sender: "aptos:0xYourAddress...", + recipient: "near:alice.near", +}) + +// 2. Build the entry-function payload (no approval step needed on Aptos) +const payload = builder.buildTransfer({ + token: "0xa", + amount: validated.params.amount, + fee: validated.params.fee, + nativeFee: validated.params.nativeFee, + recipient: validated.params.recipient, +}) + +// 3. Sign and submit with @aptos-labs/ts-sdk (or a wallet adapter) +const aptos = new Aptos(new AptosConfig({ network: Network.MAINNET })) +const transaction = await aptos.transaction.build.simple({ + sender: account.accountAddress, + data: payload, +}) +const pending = await aptos.signAndSubmitTransaction({ signer: account, transaction }) +``` + +## API + +- `createAptosBuilder({ network, bridgeAddress? })` — payload builder factory + - `buildTransfer(params)` — `init_transfer` payload + - `buildLogMetadata(token)` — `log_metadata` payload + - `buildDeployToken(signature, metadata)` — `deploy_token` payload from a NEAR `LogMetadataEvent` + - `buildFinalization(signature, payload)` — `fin_transfer` payload from a NEAR `SignTransferEvent` +- Event helpers (fullnode REST): `getAptosInitTransferEvent`, `getAptosEventLog`, `getAptosInitTransferLog`, `getAptosDeployTokenLog`, `getAptosFinTransferLog`, `parseAptosInitTransferEvent`, `isAptosTransferFinalised` +- Address utilities: `normalizeAptosAddress`, `aptosAddressToBytes`, `deriveAptosBridgeObjectAddress`, `deriveAptosBridgedTokenAddress` +- `normalizeAptosEventData` — canonical sorted-key JSON of event data for MPC proof construction + +See `docs/guides/aptos.mdx` and `docs/reference/aptos.mdx` in the repository for details. + +## License + +MIT diff --git a/packages/aptos/package.json b/packages/aptos/package.json new file mode 100644 index 00000000..5fca16c5 --- /dev/null +++ b/packages/aptos/package.json @@ -0,0 +1,40 @@ +{ + "name": "@omni-bridge/aptos", + "version": "0.9.0", + "description": "Aptos transaction builder for Omni Bridge", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/Near-One/bridge-sdk-js", + "directory": "packages/aptos" + }, + "license": "MIT", + "author": "NEAR One", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@omni-bridge/core": "workspace:*", + "@scure/base": "^2.0.0" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/packages/aptos/src/builder.ts b/packages/aptos/src/builder.ts new file mode 100644 index 00000000..dd6d0b46 --- /dev/null +++ b/packages/aptos/src/builder.ts @@ -0,0 +1,185 @@ +/** + * Aptos transaction builder for Omni Bridge + * + * Builds entry-function payloads for the `omni_bridge` Move package, + * matching the Rust bridge-sdk-rs AptosBridgeClient transaction + * construction logic. + * + * Returns plain objects compatible with `InputEntryFunctionData` from + * `@aptos-labs/ts-sdk` — pass straight to the transaction builder or a + * wallet adapter: + * const payload = builder.buildTransfer(params) + * const txn = await aptos.transaction.build.simple({ sender, data: payload }) + * // or: await signAndSubmitTransaction({ data: payload }) + */ + +import { getAddresses, type Network } from "@omni-bridge/core" +import { normalizeAptosAddress, splitSignature, utf8ToBytes } from "./encoding.js" + +/** Move module that hosts the bridge entry functions. */ +const MODULE_NAME = "omni_bridge" + +/** + * Entry-function payload, structurally compatible with `@aptos-labs/ts-sdk` + * `InputEntryFunctionData`. + * + * Argument encoding follows the conventions of the ts-sdk argument + * conversion: addresses as canonical `0x`-prefixed hex strings, u64/u128 as + * decimal strings, u8 as numbers, `vector` as plain byte arrays, and + * `Option` as the inner value or `null` for None. + */ +export interface AptosFunctionPayload { + function: `${string}::${string}::${string}` + typeArguments: string[] + functionArguments: (string | number | number[] | null)[] +} + +export interface AptosBuilderConfig { + network: Network + /** Override the `omni_bridge` module address resolved from network config. */ + bridgeAddress?: string +} + +export interface AptosTokenMetadata { + /** Source-chain token id signed in the MetadataPayload (e.g. NEAR account id). */ + token: string + name: string + symbol: string + decimals: number +} + +export interface AptosTransferPayload { + destinationNonce: bigint + originChain: number + originNonce: bigint + /** Fungible Asset metadata object address of the token on Aptos. */ + tokenAddress: string + amount: bigint + /** Aptos account address of the recipient. */ + recipient: string + feeRecipient?: string | undefined + message?: string | undefined +} + +export interface AptosBuilder { + /** Canonical (zero-padded) address the `omni_bridge` package is published under. */ + readonly bridgeAddress: string + + /** + * Build an `init_transfer` payload. + * + * No approve step is needed: the Move contract pulls funds directly from + * the transaction signer. `fee` is token-denominated and must be strictly + * less than `amount`; `nativeFee` is charged separately in APT (the + * Fungible Asset at `0xa`). `amount` and `nativeFee` must fit in u64. + */ + buildTransfer(params: { + /** Fungible Asset metadata object address (APT itself is `0xa`). */ + token: string + amount: bigint + fee: bigint + nativeFee: bigint + /** Destination as an OmniAddress string, e.g. `near:alice.near`. */ + recipient: string + message?: string + }): AptosFunctionPayload + + /** Build a `log_metadata` payload for an existing Fungible Asset. */ + buildLogMetadata(token: string): AptosFunctionPayload + + /** Build a `deploy_token` payload from a LogMetadataEvent signature. */ + buildDeployToken(signature: Uint8Array, metadata: AptosTokenMetadata): AptosFunctionPayload + + /** Build a `fin_transfer` payload from a SignTransferEvent. */ + buildFinalization(signature: Uint8Array, payload: AptosTransferPayload): AptosFunctionPayload +} + +class AptosBuilderImpl implements AptosBuilder { + readonly bridgeAddress: string + + constructor(config: AptosBuilderConfig) { + if (config.bridgeAddress) { + this.bridgeAddress = normalizeAptosAddress(config.bridgeAddress) + } else { + const addresses = getAddresses(config.network) + if (!addresses.aptos) { + throw new Error(`No Aptos bridge address configured for ${config.network}`) + } + this.bridgeAddress = normalizeAptosAddress(addresses.aptos.bridge) + } + } + + private entryFunction(name: string): `${string}::${string}::${string}` { + return `${this.bridgeAddress}::${MODULE_NAME}::${name}` + } + + buildTransfer(params: { + token: string + amount: bigint + fee: bigint + nativeFee: bigint + recipient: string + message?: string + }): AptosFunctionPayload { + return { + function: this.entryFunction("init_transfer"), + typeArguments: [], + functionArguments: [ + normalizeAptosAddress(params.token), + params.amount.toString(), + params.fee.toString(), + params.nativeFee.toString(), + params.recipient, + utf8ToBytes(params.message ?? ""), + ], + } + } + + buildLogMetadata(token: string): AptosFunctionPayload { + return { + function: this.entryFunction("log_metadata"), + typeArguments: [], + functionArguments: [normalizeAptosAddress(token)], + } + } + + buildDeployToken(signature: Uint8Array, metadata: AptosTokenMetadata): AptosFunctionPayload { + const { rs, v } = splitSignature(signature) + return { + function: this.entryFunction("deploy_token"), + typeArguments: [], + functionArguments: [ + Array.from(rs), + v, + metadata.token, + metadata.name, + metadata.symbol, + metadata.decimals, + ], + } + } + + buildFinalization(signature: Uint8Array, payload: AptosTransferPayload): AptosFunctionPayload { + const { rs, v } = splitSignature(signature) + return { + function: this.entryFunction("fin_transfer"), + typeArguments: [], + functionArguments: [ + Array.from(rs), + v, + payload.destinationNonce.toString(), + payload.originChain, + payload.originNonce.toString(), + normalizeAptosAddress(payload.tokenAddress), + payload.amount.toString(), + normalizeAptosAddress(payload.recipient), + payload.feeRecipient ? payload.feeRecipient : null, + payload.message && payload.message.length > 0 ? utf8ToBytes(payload.message) : null, + ], + } + } +} + +export function createAptosBuilder(config: AptosBuilderConfig): AptosBuilder { + return new AptosBuilderImpl(config) +} diff --git a/packages/aptos/src/encoding.ts b/packages/aptos/src/encoding.ts new file mode 100644 index 00000000..5831c445 --- /dev/null +++ b/packages/aptos/src/encoding.ts @@ -0,0 +1,155 @@ +/** + * Aptos encoding utilities (internal helpers + exported address derivation) + * + * Address handling mirrors `omni_types::H256`: parsing accepts an optional + * `0x` prefix and short-form hex (left-zero-padded to 32 bytes), display is + * always `0x` + 64 lowercase hex chars. + */ + +import { sha3_256 } from "@noble/hashes/sha3.js" +import { hex } from "@scure/base" + +/** Seed of the named object that holds the bridge state and token custody. */ +const BRIDGE_OBJECT_SEED = "omni_bridge::state" + +/** Aptos `object::create_object_address` scheme byte (OBJECT_FROM_SEED). */ +const OBJECT_FROM_SEED_SCHEME = 0xfe + +/** + * Normalize an Aptos account address to its canonical form: + * `0x` + 64 lowercase hex characters. + * + * Accepts short-form addresses (`0xa`, `0xCAFE`) with or without the `0x` + * prefix and left-pads them with zeros, matching the on-chain address + * semantics and `omni_types::H256` parsing. + */ +export function normalizeAptosAddress(address: string): string { + const stripped = address.startsWith("0x") || address.startsWith("0X") ? address.slice(2) : address + if (stripped.length === 0 || stripped.length > 64) { + throw new Error(`Invalid Aptos address length: ${address}`) + } + if (!/^[0-9a-fA-F]+$/.test(stripped)) { + throw new Error(`Invalid Aptos address: ${address}`) + } + return `0x${stripped.padStart(64, "0").toLowerCase()}` +} + +/** + * Decode an Aptos account address into its 32 raw bytes. + */ +export function aptosAddressToBytes(address: string): Uint8Array { + return hex.decode(normalizeAptosAddress(address).slice(2)) +} + +/** + * Split a 65-byte Ethereum-style MPC signature into `(r||s, v)` — the form + * the `omni_bridge` Move contract expects (`signature_rs: vector`, + * `signature_v: u8`). + */ +export function splitSignature(signature: Uint8Array): { rs: Uint8Array; v: number } { + if (signature.length !== 65) { + throw new Error(`Signature must be 65 bytes, got ${signature.length}`) + } + const v = signature[64] + if (v === undefined) { + throw new Error("Signature is missing the recovery byte") + } + return { rs: signature.slice(0, 64), v } +} + +/** + * Encode bytes as a `0x`-prefixed hex string (the fullnode REST encoding of + * `vector` event fields). + */ +export function bytesToHex(bytes: Uint8Array): string { + return `0x${hex.encode(bytes)}` +} + +/** + * Encode a UTF-8 string as a plain byte array for `vector` arguments. + * + * Note: `vector` payload arguments must be number arrays (or + * `Uint8Array`), NOT hex strings — the ts-sdk argument converter treats a + * string for `vector` as UTF-8 text, so a `"0x…"` string would be + * encoded as the literal characters. + */ +export function utf8ToBytes(s: string): number[] { + return Array.from(new TextEncoder().encode(s)) +} + +/** + * Decode a `vector` event field (`0x…` hex) to text. Bridge messages + * carry UTF-8; the raw hex string is kept if the bytes aren't valid UTF-8. + * Mirrors the Rust SDK's `decode_message`. + */ +export function decodeUtf8Message(hexStr: string): string { + const stripped = hexStr.startsWith("0x") ? hexStr.slice(2) : hexStr + if (stripped.length === 0) { + return "" + } + try { + const bytes = hex.decode(stripped) + // ignoreBOM keeps a leading U+FEFF, matching Rust's String::from_utf8. + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes) + } catch { + return hexStr + } +} + +/** + * Aptos named-object address derivation: + * `sha3_256(creator_address(32) || seed || 0xFE)`. + */ +function createObjectAddress(creator: string, seed: Uint8Array): string { + const creatorBytes = aptosAddressToBytes(creator) + const input = new Uint8Array(creatorBytes.length + seed.length + 1) + input.set(creatorBytes, 0) + input.set(seed, creatorBytes.length) + input[input.length - 1] = OBJECT_FROM_SEED_SCHEME + return bytesToHex(sha3_256(input)) +} + +/** + * Compute the bridge state object address for an `omni_bridge` deployment. + * + * This deterministic named object (seed `"omni_bridge::state"`) holds the + * bridge state and custodies locked tokens. It is also the address that gets + * registered as the Aptos factory on the NEAR bridge contract. + */ +export function deriveBridgeObjectAddress(moduleAddress: string): string { + return createObjectAddress(moduleAddress, new TextEncoder().encode(BRIDGE_OBJECT_SEED)) +} + +/** + * Compute the deterministic Fungible Asset metadata object address of a + * bridge-deployed token. The seed is the NEAR token id's raw UTF-8 bytes, + * and the creator is the bridge state object (see + * {@link deriveBridgeObjectAddress}). + */ +export function deriveBridgedTokenAddress( + bridgeObjectAddress: string, + nearTokenId: string, +): string { + return createObjectAddress(bridgeObjectAddress, new TextEncoder().encode(nearTokenId)) +} + +/** + * Canonical string form of an event's `data`: object keys sorted recursively + * so consumers hash identical bytes regardless of provider key ordering. + * Mirrors the MPC node's (and Rust SDK's) `normalize_event_data`. + */ +export function normalizeEventData(value: unknown): string { + return JSON.stringify(sortKeys(value)) +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortKeys) + } + if (value !== null && typeof value === "object") { + const entries = Object.entries(value as Record) + entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return Object.fromEntries(entries.map(([k, v]) => [k, sortKeys(v)])) + } + return value +} diff --git a/packages/aptos/src/events.ts b/packages/aptos/src/events.ts new file mode 100644 index 00000000..be6902cd --- /dev/null +++ b/packages/aptos/src/events.ts @@ -0,0 +1,273 @@ +/** + * Aptos event parsing for Omni Bridge + * + * Extracts and parses bridge events from committed Aptos transactions via + * the fullnode REST API (v1). Matches the Rust bridge-sdk-rs + * AptosBridgeClient event parsing logic. + */ + +import { decodeUtf8Message, normalizeAptosAddress, normalizeEventData } from "./encoding.js" + +/** Move module that hosts the bridge entry functions. */ +const MODULE_NAME = "omni_bridge" + +/** + * Parsed InitTransfer event data. + */ +export interface AptosInitTransferEvent { + sender: string + tokenAddress: string + originNonce: bigint + amount: bigint + fee: bigint + nativeFee: bigint + /** OmniAddress string of the destination, e.g. `near:alice.near`. */ + recipient: string + /** UTF-8 decoded message; raw `0x…` hex kept if not valid UTF-8. */ + message: string +} + +/** + * Raw Aptos event log with the metadata needed for MPC proof construction. + * `data` is the canonical sorted-key JSON form the MPC nodes reconstruct. + */ +export interface AptosEventLog { + /** Event GUID account address, zero-padded to canonical form. */ + accountAddress: string + sequenceNumber: bigint + /** Move event type tag, e.g. `0x…::omni_bridge::InitTransfer` (verbatim). */ + typeTag: string + /** Canonical JSON string of the event data (recursively sorted keys). */ + data: string + /** Index of the event in the transaction's `events` array. */ + eventIndex: number +} + +interface AptosTransactionEvent { + guid: { account_address: string } + sequence_number: string + type: string + data: unknown +} + +interface AptosCommittedTransaction { + hash: string + success?: boolean + vm_status?: string + events?: AptosTransactionEvent[] +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init) + if (!response.ok) { + const body = await response.text() + throw new Error(`Aptos RPC request failed (${response.status}): ${body}`) + } + return response.json() +} + +function trimBaseUrl(rpcUrl: string): string { + return rpcUrl.replace(/\/+$/, "") +} + +/** + * Parse a u64/u128 REST field, accepting only canonical decimal strings — + * the same strings Rust's `u64`/`u128` `FromStr` accepts (BigInt alone would + * also accept hex/binary/signed/empty forms the protocol never produces). + */ +function parseDecimalBigInt(raw: string, context: string): bigint { + if (!/^\d+$/.test(raw)) { + throw new Error(`${context} is not an integer: ${raw}`) + } + return BigInt(raw) +} + +/** + * Fetch a committed transaction by hash from an Aptos fullnode REST endpoint + * (`rpcUrl` must include the `/v1` segment). + */ +async function getCommittedTransaction( + rpcUrl: string, + txHash: string, +): Promise { + // Validate before interpolating into the URL path — a crafted "hash" could + // otherwise re-target the request ("../", "?", "#"). + if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) { + throw new Error(`Invalid Aptos transaction hash: ${txHash}`) + } + const tx = (await fetchJson( + `${trimBaseUrl(rpcUrl)}/transactions/by_hash/${txHash}`, + )) as AptosCommittedTransaction + if (tx.success === undefined) { + throw new Error(`Transaction ${txHash} is still pending`) + } + if (tx.success !== true) { + throw new Error(`Transaction ${txHash} failed: ${tx.vm_status ?? "unknown VM status"}`) + } + return tx +} + +/** Split a Move event type tag `0xaddr::module::Struct` into its parts. */ +function parseEventType(eventType: string): [string, string, string] | null { + const firstSep = eventType.indexOf("::") + if (firstSep === -1) return null + const secondSep = eventType.indexOf("::", firstSep + 2) + if (secondSep === -1) return null + return [ + eventType.slice(0, firstSep), + eventType.slice(firstSep + 2, secondSep), + eventType.slice(secondSep + 2), + ] +} + +function isBridgeEvent(event: AptosTransactionEvent, bridgeAddress: string, eventName: string) { + const parts = parseEventType(event.type) + if (!parts) return false + const [address, module, name] = parts + if (module !== MODULE_NAME || name !== eventName) return false + try { + return normalizeAptosAddress(address) === normalizeAptosAddress(bridgeAddress) + } catch { + return false + } +} + +/** + * Extract a specific `omni_bridge` event log from a committed transaction. + * + * @param rpcUrl - Aptos fullnode REST endpoint, including the `/v1` segment + * @param bridgeAddress - Address the `omni_bridge` package is published under + * @param txHash - Transaction hash (`0x`-prefixed) + * @param eventName - Event struct name, e.g. `"InitTransfer"` + */ +export async function getEventLog( + rpcUrl: string, + bridgeAddress: string, + txHash: string, + eventName: string, +): Promise { + const tx = await getCommittedTransaction(rpcUrl, txHash) + const events = tx.events ?? [] + + const eventIndex = events.findIndex((event) => isBridgeEvent(event, bridgeAddress, eventName)) + if (eventIndex === -1) { + throw new Error(`${eventName} event not found in transaction ${txHash}`) + } + // findIndex above guarantees the element exists. + const event = events[eventIndex] as AptosTransactionEvent + if (event.data === undefined) { + throw new Error(`${eventName} event in transaction ${txHash} has no data field`) + } + + return { + accountAddress: normalizeAptosAddress(event.guid.account_address), + sequenceNumber: parseDecimalBigInt(event.sequence_number, "event sequence_number"), + typeTag: event.type, + data: normalizeEventData(event.data), + eventIndex, + } +} + +/** + * Get the InitTransfer event log from a committed transaction. + */ +export async function getInitTransferLog( + rpcUrl: string, + bridgeAddress: string, + txHash: string, +): Promise { + return getEventLog(rpcUrl, bridgeAddress, txHash, "InitTransfer") +} + +/** + * Get the DeployToken event log from a committed transaction. + */ +export async function getDeployTokenLog( + rpcUrl: string, + bridgeAddress: string, + txHash: string, +): Promise { + return getEventLog(rpcUrl, bridgeAddress, txHash, "DeployToken") +} + +/** + * Get the FinTransfer event log from a committed transaction. + */ +export async function getFinTransferLog( + rpcUrl: string, + bridgeAddress: string, + txHash: string, +): Promise { + return getEventLog(rpcUrl, bridgeAddress, txHash, "FinTransfer") +} + +/** + * Decode the InitTransfer event of a committed transaction. + */ +export async function getInitTransferEvent( + rpcUrl: string, + bridgeAddress: string, + txHash: string, +): Promise { + const log = await getInitTransferLog(rpcUrl, bridgeAddress, txHash) + return parseInitTransferEvent(JSON.parse(log.data)) +} + +/** + * Parse an InitTransfer event from its decoded `data` object, as returned by + * the fullnode REST API (u64/u128 as decimal strings, `vector` as + * `0x`-prefixed hex). + */ +export function parseInitTransferEvent(data: unknown): AptosInitTransferEvent { + if (data === null || typeof data !== "object") { + throw new Error("InitTransfer event data is not an object") + } + const record = data as Record + + const stringField = (key: string): string => { + const value = record[key] + if (typeof value !== "string") { + throw new Error(`InitTransfer event missing string field ${key}`) + } + return value + } + const bigIntField = (key: string): bigint => + parseDecimalBigInt(stringField(key), `InitTransfer event field ${key}`) + + return { + sender: normalizeAptosAddress(stringField("sender")), + tokenAddress: normalizeAptosAddress(stringField("token_address")), + originNonce: bigIntField("origin_nonce"), + amount: bigIntField("amount"), + fee: bigIntField("fee"), + nativeFee: bigIntField("native_fee"), + recipient: stringField("recipient"), + message: decodeUtf8Message(stringField("message")), + } +} + +/** + * Check if a transfer with the given destination nonce has been finalised on + * Aptos. Calls the `is_transfer_finalised` view function. + */ +export async function isTransferFinalised( + rpcUrl: string, + bridgeAddress: string, + nonce: bigint, +): Promise { + const result = (await fetchJson(`${trimBaseUrl(rpcUrl)}/view`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + function: `${normalizeAptosAddress(bridgeAddress)}::${MODULE_NAME}::is_transfer_finalised`, + type_arguments: [], + arguments: [nonce.toString()], + }), + })) as unknown[] + + const value = result[0] + if (typeof value !== "boolean") { + throw new Error("is_transfer_finalised view returned no boolean") + } + return value +} diff --git a/packages/aptos/src/index.ts b/packages/aptos/src/index.ts new file mode 100644 index 00000000..e995aeb0 --- /dev/null +++ b/packages/aptos/src/index.ts @@ -0,0 +1,36 @@ +/** + * @omni-bridge/aptos + * + * Aptos transaction builder for Omni Bridge SDK + * Builds entry-function payloads for the `omni_bridge` Move package, + * compatible with @aptos-labs/ts-sdk `InputEntryFunctionData` + */ + +export { + type AptosBuilder, + type AptosBuilderConfig, + type AptosFunctionPayload, + type AptosTokenMetadata, + type AptosTransferPayload, + createAptosBuilder, +} from "./builder.js" + +export { + aptosAddressToBytes, + deriveBridgedTokenAddress as deriveAptosBridgedTokenAddress, + deriveBridgeObjectAddress as deriveAptosBridgeObjectAddress, + normalizeAptosAddress, + normalizeEventData as normalizeAptosEventData, +} from "./encoding.js" + +export { + type AptosEventLog, + type AptosInitTransferEvent, + getDeployTokenLog as getAptosDeployTokenLog, + getEventLog as getAptosEventLog, + getFinTransferLog as getAptosFinTransferLog, + getInitTransferEvent as getAptosInitTransferEvent, + getInitTransferLog as getAptosInitTransferLog, + isTransferFinalised as isAptosTransferFinalised, + parseInitTransferEvent as parseAptosInitTransferEvent, +} from "./events.js" diff --git a/packages/aptos/tests/builder.test.ts b/packages/aptos/tests/builder.test.ts new file mode 100644 index 00000000..e32acd06 --- /dev/null +++ b/packages/aptos/tests/builder.test.ts @@ -0,0 +1,219 @@ +import { ChainKind } from "@omni-bridge/core" +import { beforeEach, describe, expect, it } from "vitest" +import { type AptosBuilder, createAptosBuilder } from "../src/builder.js" + +const BRIDGE = "0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf" +const TOKEN = "0x2ebb2ccac5e027a87fa0e2e5f656a3a4238d6a48d93ec9b610d570fc0aa0df12" +// Canonical APT Fungible Asset metadata object address. +const APT = "0x000000000000000000000000000000000000000000000000000000000000000a" + +describe("createAptosBuilder", () => { + it("throws when no bridge address is configured for the network", () => { + // The Aptos bridge contract is not deployed yet — config has no address. + expect(() => createAptosBuilder({ network: "testnet" })).toThrow( + "No Aptos bridge address configured for testnet", + ) + }) + + it("creates builder with custom bridge address", () => { + const builder = createAptosBuilder({ network: "testnet", bridgeAddress: BRIDGE }) + expect(builder.bridgeAddress).toBe(BRIDGE) + }) + + it("normalizes short-form bridge addresses", () => { + const builder = createAptosBuilder({ network: "testnet", bridgeAddress: "0xCAFE" }) + expect(builder.bridgeAddress).toBe( + "0x000000000000000000000000000000000000000000000000000000000000cafe", + ) + }) +}) + +describe("AptosBuilder.buildTransfer", () => { + let builder: AptosBuilder + + beforeEach(() => { + builder = createAptosBuilder({ network: "testnet", bridgeAddress: BRIDGE }) + }) + + it("builds an init_transfer payload with arguments in Move signature order", () => { + const payload = builder.buildTransfer({ + token: TOKEN, + amount: 1000n, + fee: 10n, + nativeFee: 5n, + recipient: "near:alice.testnet", + }) + + expect(payload.function).toBe(`${BRIDGE}::omni_bridge::init_transfer`) + expect(payload.typeArguments).toEqual([]) + expect(payload.functionArguments).toEqual([ + TOKEN, + "1000", + "10", + "5", + "near:alice.testnet", + [], // empty message + ]) + }) + + it("encodes the message as UTF-8 bytes", () => { + const payload = builder.buildTransfer({ + token: APT, + amount: 100n, + fee: 0n, + nativeFee: 0n, + recipient: "near:alice.testnet", + message: "hello", + }) + + expect(payload.functionArguments[5]).toEqual([0x68, 0x65, 0x6c, 0x6c, 0x6f]) + }) + + it("normalizes short-form token addresses", () => { + const payload = builder.buildTransfer({ + token: "0xa", + amount: 100n, + fee: 0n, + nativeFee: 0n, + recipient: "near:alice.testnet", + }) + + expect(payload.functionArguments[0]).toBe(APT) + }) +}) + +describe("AptosBuilder.buildLogMetadata", () => { + it("builds a log_metadata payload", () => { + const builder = createAptosBuilder({ network: "testnet", bridgeAddress: BRIDGE }) + const payload = builder.buildLogMetadata(TOKEN) + + expect(payload.function).toBe(`${BRIDGE}::omni_bridge::log_metadata`) + expect(payload.typeArguments).toEqual([]) + expect(payload.functionArguments).toEqual([TOKEN]) + }) +}) + +describe("AptosBuilder.buildDeployToken", () => { + it("splits the 65-byte signature into rs and v", () => { + const builder = createAptosBuilder({ network: "testnet", bridgeAddress: BRIDGE }) + + const signature = new Uint8Array(65) + signature[0] = 0xaa + signature[63] = 0xbb + signature[64] = 27 + + const payload = builder.buildDeployToken(signature, { + token: "wrap.testnet", + name: "Wrapped NEAR", + symbol: "wNEAR", + decimals: 24, + }) + + expect(payload.function).toBe(`${BRIDGE}::omni_bridge::deploy_token`) + const rs = payload.functionArguments[0] as number[] + expect(rs.length).toBe(64) + expect(rs[0]).toBe(0xaa) + expect(rs[63]).toBe(0xbb) + expect(payload.functionArguments.slice(1)).toEqual([ + 27, + "wrap.testnet", + "Wrapped NEAR", + "wNEAR", + 24, + ]) + }) + + it("rejects signatures that are not 65 bytes", () => { + const builder = createAptosBuilder({ network: "testnet", bridgeAddress: BRIDGE }) + expect(() => + builder.buildDeployToken(new Uint8Array(64), { + token: "wrap.testnet", + name: "Wrapped NEAR", + symbol: "wNEAR", + decimals: 24, + }), + ).toThrow("Signature must be 65 bytes") + }) +}) + +describe("AptosBuilder.buildFinalization", () => { + const RECIPIENT = "0x9c8b1b73d49e6a9e3b8e8c3d9e1f5a7b2c4d6e8f0a1b3c5d7e9f1a3b5c7d9e1f" + + it("encodes fee_recipient as Some and message as None", () => { + const builder = createAptosBuilder({ network: "testnet", bridgeAddress: BRIDGE }) + + const signature = new Uint8Array(65) + signature[64] = 28 + + const payload = builder.buildFinalization(signature, { + destinationNonce: 1n, + originChain: ChainKind.Near, + originNonce: 123n, + tokenAddress: TOKEN, + amount: 1000000n, + recipient: RECIPIENT, + feeRecipient: "relayer.near", + }) + + expect(payload.function).toBe(`${BRIDGE}::omni_bridge::fin_transfer`) + expect(payload.functionArguments).toEqual([ + new Array(64).fill(0), + 28, + "1", + ChainKind.Near, // 1 + "123", + TOKEN, + "1000000", + RECIPIENT, + "relayer.near", + null, // message: None + ]) + }) + + it("encodes both options as None", () => { + const builder = createAptosBuilder({ network: "testnet", bridgeAddress: BRIDGE }) + const payload = builder.buildFinalization(new Uint8Array(65), { + destinationNonce: 7n, + originChain: ChainKind.Sol, + originNonce: 456n, + tokenAddress: "0x1", + amount: 500n, + recipient: "0x2", + }) + + expect(payload.functionArguments.slice(-2)).toEqual([null, null]) + // Short-form addresses are normalized. + expect(payload.functionArguments[5]).toBe(`0x${"0".repeat(63)}1`) + expect(payload.functionArguments[7]).toBe(`0x${"0".repeat(63)}2`) + }) + + it("encodes a non-empty message as Some UTF-8 bytes", () => { + const builder = createAptosBuilder({ network: "testnet", bridgeAddress: BRIDGE }) + const payload = builder.buildFinalization(new Uint8Array(65), { + destinationNonce: 7n, + originChain: ChainKind.Eth, + originNonce: 456n, + tokenAddress: "0x1", + amount: 500n, + recipient: "0x2", + message: "hi", + }) + + expect(payload.functionArguments[9]).toEqual([0x68, 0x69]) + }) + + it("treats an empty message as None (Rust SDK parity)", () => { + const builder = createAptosBuilder({ network: "testnet", bridgeAddress: BRIDGE }) + const payload = builder.buildFinalization(new Uint8Array(65), { + destinationNonce: 7n, + originChain: ChainKind.Eth, + originNonce: 456n, + tokenAddress: "0x1", + amount: 500n, + recipient: "0x2", + message: "", + }) + + expect(payload.functionArguments[9]).toBe(null) + }) +}) diff --git a/packages/aptos/tests/encoding.test.ts b/packages/aptos/tests/encoding.test.ts new file mode 100644 index 00000000..9775bf06 --- /dev/null +++ b/packages/aptos/tests/encoding.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest" +import { + aptosAddressToBytes, + bytesToHex, + decodeUtf8Message, + deriveBridgedTokenAddress, + deriveBridgeObjectAddress, + normalizeAptosAddress, + normalizeEventData, + splitSignature, + utf8ToBytes, +} from "../src/encoding.js" + +describe("normalizeAptosAddress", () => { + it("left-pads short-form addresses to 64 hex chars", () => { + expect(normalizeAptosAddress("0xa")).toBe( + "0x000000000000000000000000000000000000000000000000000000000000000a", + ) + expect(normalizeAptosAddress("0x1")).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000001", + ) + }) + + it("accepts addresses without the 0x prefix", () => { + expect(normalizeAptosAddress("cafe")).toBe( + "0x000000000000000000000000000000000000000000000000000000000000cafe", + ) + }) + + it("lowercases the address", () => { + expect(normalizeAptosAddress("0xCAFE")).toBe( + "0x000000000000000000000000000000000000000000000000000000000000cafe", + ) + }) + + it("keeps already-canonical addresses unchanged", () => { + const canonical = "0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf" + expect(normalizeAptosAddress(canonical)).toBe(canonical) + }) + + it("rejects addresses longer than 32 bytes", () => { + expect(() => normalizeAptosAddress(`0x${"1".repeat(65)}`)).toThrow( + "Invalid Aptos address length", + ) + }) + + it("rejects empty and non-hex addresses", () => { + expect(() => normalizeAptosAddress("")).toThrow("Invalid Aptos address length") + expect(() => normalizeAptosAddress("0x")).toThrow("Invalid Aptos address length") + expect(() => normalizeAptosAddress("0xnothex")).toThrow("Invalid Aptos address") + }) +}) + +describe("aptosAddressToBytes", () => { + it("decodes to exactly 32 bytes with left padding", () => { + const bytes = aptosAddressToBytes("0xa") + expect(bytes.length).toBe(32) + expect(bytes[31]).toBe(0x0a) + expect(bytes.slice(0, 31).every((b) => b === 0)).toBe(true) + }) +}) + +describe("splitSignature", () => { + it("splits a 65-byte signature into rs and v", () => { + const sig = new Uint8Array(65) + sig[0] = 0xaa + sig[63] = 0xbb + sig[64] = 27 + + const { rs, v } = splitSignature(sig) + expect(rs.length).toBe(64) + expect(rs[0]).toBe(0xaa) + expect(rs[63]).toBe(0xbb) + expect(v).toBe(27) + }) + + it("rejects signatures of other lengths", () => { + expect(() => splitSignature(new Uint8Array(64))).toThrow("Signature must be 65 bytes") + expect(() => splitSignature(new Uint8Array(66))).toThrow("Signature must be 65 bytes") + }) +}) + +describe("hex helpers", () => { + it("encodes bytes with 0x prefix", () => { + expect(bytesToHex(new Uint8Array([0xde, 0xad]))).toBe("0xdead") + expect(bytesToHex(new Uint8Array(0))).toBe("0x") + }) + + it("encodes UTF-8 strings as byte arrays", () => { + expect(utf8ToBytes("")).toEqual([]) + expect(utf8ToBytes("hello")).toEqual([0x68, 0x65, 0x6c, 0x6c, 0x6f]) + }) + + it("decodes UTF-8 messages with raw-hex fallback", () => { + expect(decodeUtf8Message("0x")).toBe("") + expect(decodeUtf8Message("0x68656c6c6f")).toBe("hello") + // Invalid UTF-8 falls back to the raw hex string. + expect(decodeUtf8Message("0xff00")).toBe("0xff00") + // A leading BOM is preserved (Rust String::from_utf8 parity). + expect(decodeUtf8Message("0xefbbbf6869")).toBe("\ufeffhi") + }) +}) + +describe("named object address derivation", () => { + // Ground-truth vectors generated with @aptos-labs/ts-sdk v7.1.0 + // `createObjectAddress(creator, seed)` (= sha3_256(creator || seed || 0xFE)). + it("derives the bridge state object address from the module address", () => { + expect(deriveBridgeObjectAddress("0xcafe")).toBe( + "0x6cac04c1fda67a68c01cca6a52719243d08b0fab3abe20058e552c639df1b9df", + ) + }) + + it("derives a bridged token FA address from the NEAR token id", () => { + const bridgeObject = deriveBridgeObjectAddress("0xcafe") + expect(deriveBridgedTokenAddress(bridgeObject, "wrap.testnet")).toBe( + "0xae14bb4d3bc7a04649b5b9a9ce77ae5c3796f056e0b8ad3a912c5db8ac37e246", + ) + // Different token ids must give different addresses. + expect(deriveBridgedTokenAddress(bridgeObject, "usdt.tether-token.near")).not.toBe( + deriveBridgedTokenAddress(bridgeObject, "wrap.testnet"), + ) + }) +}) + +describe("normalizeEventData", () => { + it("sorts object keys recursively", () => { + const value = { z: 1, a: 2, nested: { y: 1, x: 2 } } + expect(normalizeEventData(value)).toBe('{"a":2,"nested":{"x":2,"y":1},"z":1}') + }) + + it("preserves array order while sorting nested objects", () => { + const value = { list: [{ b: 1, a: 2 }, 3] } + expect(normalizeEventData(value)).toBe('{"list":[{"a":2,"b":1},3]}') + }) + + it("passes through primitives", () => { + expect(normalizeEventData("x")).toBe('"x"') + expect(normalizeEventData(7)).toBe("7") + expect(normalizeEventData(null)).toBe("null") + }) +}) diff --git a/packages/aptos/tests/events.test.ts b/packages/aptos/tests/events.test.ts new file mode 100644 index 00000000..443f0652 --- /dev/null +++ b/packages/aptos/tests/events.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { + getEventLog, + getInitTransferEvent, + isTransferFinalised, + parseInitTransferEvent, +} from "../src/events.js" + +const BRIDGE = "0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf" +const RPC = "https://fullnode.testnet.aptoslabs.com/v1" +const TX_HASH = "0x01cdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + +const initTransferData = { + sender: "0xabc", + token_address: "0xa", + origin_nonce: "7", + amount: "1000000", + fee: "100", + native_fee: "50", + recipient: "near:alice.near", + message: "0x", +} + +function mockFetchJson(body: unknown, ok = true, status = 200) { + return vi.fn(async () => ({ + ok, + status, + json: async () => body, + text: async () => JSON.stringify(body), + })) as unknown as typeof fetch +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("parseInitTransferEvent", () => { + it("parses and normalizes all fields", () => { + const event = parseInitTransferEvent(initTransferData) + + expect(event.sender).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000abc", + ) + expect(event.tokenAddress).toBe( + "0x000000000000000000000000000000000000000000000000000000000000000a", + ) + expect(event.originNonce).toBe(7n) + expect(event.amount).toBe(1000000n) + expect(event.fee).toBe(100n) + expect(event.nativeFee).toBe(50n) + expect(event.recipient).toBe("near:alice.near") + expect(event.message).toBe("") + }) + + it("decodes UTF-8 messages", () => { + const event = parseInitTransferEvent({ ...initTransferData, message: "0x68656c6c6f" }) + expect(event.message).toBe("hello") + }) + + it("rejects missing fields", () => { + const { amount: _amount, ...withoutAmount } = initTransferData + expect(() => parseInitTransferEvent(withoutAmount)).toThrow( + "InitTransfer event missing string field amount", + ) + expect(() => parseInitTransferEvent(null)).toThrow("InitTransfer event data is not an object") + }) + + it("rejects non-integer numeric fields", () => { + expect(() => parseInitTransferEvent({ ...initTransferData, amount: "12.5" })).toThrow( + "InitTransfer event field amount is not an integer", + ) + }) + + it("rejects non-canonical decimal strings BigInt would otherwise accept", () => { + // u64/u128 REST fields are always plain decimal; hex/signed/empty forms + // must error like the Rust SDK's u128 parse instead of fabricating values. + for (const amount of ["0x10", "-5", "", " 7 ", "0b101"]) { + expect(() => parseInitTransferEvent({ ...initTransferData, amount })).toThrow( + "InitTransfer event field amount is not an integer", + ) + } + }) +}) + +describe("getEventLog", () => { + const committedTx = { + hash: TX_HASH, + success: true, + events: [ + { + guid: { account_address: "0x0" }, + sequence_number: "0", + type: "0x1::other::InitTransfer", + data: {}, + }, + { + guid: { account_address: "0x0" }, + sequence_number: "5", + // Short-form address must still match the canonical bridge address. + type: `0x5558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf::omni_bridge::InitTransfer`, + data: { z: "1", a: "2" }, + }, + ], + } + + it("finds the bridge event and returns canonical metadata", async () => { + vi.stubGlobal("fetch", mockFetchJson(committedTx)) + + const log = await getEventLog(RPC, BRIDGE, TX_HASH, "InitTransfer") + + expect(log.eventIndex).toBe(1) + expect(log.sequenceNumber).toBe(5n) + expect(log.accountAddress).toBe( + "0x0000000000000000000000000000000000000000000000000000000000000000", + ) + expect(log.typeTag).toBe( + "0x5558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf::omni_bridge::InitTransfer", + ) + // Canonical sorted-key JSON. + expect(log.data).toBe('{"a":"2","z":"1"}') + }) + + it("throws when the event is missing", async () => { + vi.stubGlobal("fetch", mockFetchJson({ hash: TX_HASH, success: true, events: [] })) + await expect(getEventLog(RPC, BRIDGE, TX_HASH, "InitTransfer")).rejects.toThrow( + "InitTransfer event not found", + ) + }) + + it("throws on failed transactions", async () => { + vi.stubGlobal( + "fetch", + mockFetchJson({ hash: TX_HASH, success: false, vm_status: "ABORTED", events: [] }), + ) + await expect(getEventLog(RPC, BRIDGE, TX_HASH, "InitTransfer")).rejects.toThrow( + `Transaction ${TX_HASH} failed: ABORTED`, + ) + }) + + it("throws on pending transactions", async () => { + vi.stubGlobal("fetch", mockFetchJson({ hash: TX_HASH, type: "pending_transaction" })) + await expect(getEventLog(RPC, BRIDGE, TX_HASH, "InitTransfer")).rejects.toThrow( + `Transaction ${TX_HASH} is still pending`, + ) + }) + + it("rejects malformed transaction hashes before building the request URL", async () => { + const fetchMock = mockFetchJson({}) + vi.stubGlobal("fetch", fetchMock) + + for (const hash of ["0xabc", "../by_version/42", `${TX_HASH}?x=1`, "not-a-hash"]) { + await expect(getEventLog(RPC, BRIDGE, hash, "InitTransfer")).rejects.toThrow( + "Invalid Aptos transaction hash", + ) + } + expect(fetchMock).not.toHaveBeenCalled() + }) + + it("throws when the matched event has no data field", async () => { + vi.stubGlobal( + "fetch", + mockFetchJson({ + hash: TX_HASH, + success: true, + events: [ + { + guid: { account_address: "0x0" }, + sequence_number: "5", + type: `${BRIDGE}::omni_bridge::InitTransfer`, + }, + ], + }), + ) + await expect(getEventLog(RPC, BRIDGE, TX_HASH, "InitTransfer")).rejects.toThrow( + "InitTransfer event in transaction", + ) + }) +}) + +describe("getInitTransferEvent", () => { + it("extracts and parses the InitTransfer event", async () => { + vi.stubGlobal( + "fetch", + mockFetchJson({ + hash: TX_HASH, + success: true, + events: [ + { + guid: { account_address: "0x0" }, + sequence_number: "0", + type: `${BRIDGE}::omni_bridge::InitTransfer`, + data: initTransferData, + }, + ], + }), + ) + + const event = await getInitTransferEvent(RPC, BRIDGE, TX_HASH) + expect(event.amount).toBe(1000000n) + expect(event.recipient).toBe("near:alice.near") + }) +}) + +describe("isTransferFinalised", () => { + it("calls the view function and returns the boolean", async () => { + const fetchMock = mockFetchJson([true]) + vi.stubGlobal("fetch", fetchMock) + + await expect(isTransferFinalised(RPC, BRIDGE, 42n)).resolves.toBe(true) + + const [url, init] = (fetchMock as unknown as ReturnType).mock + .calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${RPC}/view`) + expect(JSON.parse(init.body as string)).toEqual({ + function: `${BRIDGE}::omni_bridge::is_transfer_finalised`, + type_arguments: [], + arguments: ["42"], + }) + }) + + it("throws when the view returns no boolean", async () => { + vi.stubGlobal("fetch", mockFetchJson([])) + await expect(isTransferFinalised(RPC, BRIDGE, 42n)).rejects.toThrow( + "is_transfer_finalised view returned no boolean", + ) + }) +}) diff --git a/packages/aptos/tsconfig.json b/packages/aptos/tsconfig.json new file mode 100644 index 00000000..70b2a81c --- /dev/null +++ b/packages/aptos/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "references": [{ "path": "../core" }] +} diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index 295a5d0e..5651860c 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -22,6 +22,7 @@ const ChainSchema = z.enum([ "HlEvm", "Strk", "Fogo", + "Aptos", ]) export type Chain = z.infer @@ -96,6 +97,13 @@ const StarknetTransactionSchema = z.object({ transaction_hash: z.string(), }) +const AptosTransactionSchema = z.object({ + version: z.number().int().min(0), + block_height: z.number().int().min(0), + block_timestamp: z.number().int().min(0), + transaction_hash: z.string(), +}) + const TransactionSchema = z .object({ NearReceipt: NearReceiptTransactionSchema.optional(), @@ -103,6 +111,7 @@ const TransactionSchema = z Solana: SolanaTransactionSchema.optional(), UtxoLog: UtxoLogTransactionSchema.optional(), Starknet: StarknetTransactionSchema.optional(), + Aptos: AptosTransactionSchema.optional(), }) .refine( (data) => { @@ -112,6 +121,7 @@ const TransactionSchema = z data.Solana, data.UtxoLog, data.Starknet, + data.Aptos, ].filter((field) => field !== undefined) return definedFields.length === 1 }, diff --git a/packages/core/src/bridge.ts b/packages/core/src/bridge.ts index a90f190f..dc9e3aed 100644 --- a/packages/core/src/bridge.ts +++ b/packages/core/src/bridge.ts @@ -120,6 +120,7 @@ function chainKindToApiChain(chain: ChainKind): Chain { [ChainKind.Abs]: "Abs", [ChainKind.Strk]: "Strk", [ChainKind.Fogo]: "Fogo", + [ChainKind.Aptos]: "Aptos", } return mapping[chain] } @@ -162,6 +163,15 @@ function getContractAddress(addresses: ChainAddresses, chain: ChainKind): string ) } return addresses.fogo.locker + case ChainKind.Aptos: + if (!addresses.aptos) { + throw new ValidationError( + "Aptos bridge is not yet deployed on this network", + "UNSUPPORTED_CHAIN", + { chain: "Aptos" }, + ) + } + return addresses.aptos.bridge case ChainKind.Btc: return addresses.btc.btcConnector case ChainKind.Zcash: diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index e611e2d8..5a1b8f76 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -42,6 +42,11 @@ export interface StarknetAddresses { bridge: string } +export interface AptosAddresses { + /** Account address the `omni_bridge` Move package is published under. */ + bridge: string +} + export interface ChainAddresses { eth: EvmAddresses arb: EvmAddresses @@ -55,6 +60,7 @@ export interface ChainAddresses { zcash: ZcashAddresses strk: StarknetAddresses fogo?: SolanaAddresses + aptos?: AptosAddresses } const MAINNET_ADDRESSES: ChainAddresses = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0058dd95..79a04d4c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export { // Config export { API_BASE_URLS, + type AptosAddresses, type BtcAddresses, type ChainAddresses, EVM_CHAIN_IDS, @@ -36,6 +37,7 @@ export { getAddresses, type NearAddresses, type SolanaAddresses, + type StarknetAddresses, type ZcashAddresses, } from "./config.js" // Errors diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 114ae6bb..65029142 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -23,6 +23,7 @@ export enum ChainKind { Strk = 10, Abs = 11, Fogo = 12, + Aptos = 13, } // Network configuration @@ -43,6 +44,7 @@ export type OmniAddress = | `abs:${string}` | `strk:${string}` | `fogo:${string}` + | `aptos:${string}` // Common type aliases export type U128 = bigint @@ -168,3 +170,4 @@ export type ChainPrefix = | "abs" | "strk" | "fogo" + | "aptos" diff --git a/packages/core/src/utils/address.ts b/packages/core/src/utils/address.ts index 06c805b9..e8010be8 100644 --- a/packages/core/src/utils/address.ts +++ b/packages/core/src/utils/address.ts @@ -20,6 +20,7 @@ const CHAIN_PREFIX_MAP: Record = { abs: ChainKind.Abs, strk: ChainKind.Strk, fogo: ChainKind.Fogo, + aptos: ChainKind.Aptos, } // Mapping from ChainKind to prefix @@ -37,6 +38,7 @@ const CHAIN_KIND_PREFIX_MAP: Record = { [ChainKind.Abs]: "abs", [ChainKind.Strk]: "strk", [ChainKind.Fogo]: "fogo", + [ChainKind.Aptos]: "aptos", } // Valid chain prefixes diff --git a/packages/core/src/utils/token.ts b/packages/core/src/utils/token.ts index d2b87aba..a2f4e8e3 100644 --- a/packages/core/src/utils/token.ts +++ b/packages/core/src/utils/token.ts @@ -52,6 +52,7 @@ const CHAIN_PREFIXES: Record = { "abs-": ChainKind.Abs, "strk-": ChainKind.Strk, "fogo-": ChainKind.Fogo, + "aptos-": ChainKind.Aptos, } /** diff --git a/packages/core/tests/address.test.ts b/packages/core/tests/address.test.ts index 5007e07b..79a18784 100644 --- a/packages/core/tests/address.test.ts +++ b/packages/core/tests/address.test.ts @@ -19,6 +19,12 @@ describe("Omni Address Utils", () => { expect(omniAddress(ChainKind.Fogo, "dahPEoZGXfyV58JqqH85okdHmpN8U2q8owgPUXSCPxe")).toBe( "fogo:dahPEoZGXfyV58JqqH85okdHmpN8U2q8owgPUXSCPxe", ) + expect( + omniAddress( + ChainKind.Aptos, + "0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf", + ), + ).toBe("aptos:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf") }) it("should work with empty addresses", () => { @@ -48,6 +54,7 @@ describe("Omni Address Utils", () => { "strk:0xstrk789", "zcash:t1Rv4exT7bqhZqi2j7xz8bUHDMxwosrjADU", "fogo:dahPEoZGXfyV58JqqH85okdHmpN8U2q8owgPUXSCPxe", + "aptos:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf", ] const expected = [ @@ -61,6 +68,7 @@ describe("Omni Address Utils", () => { ChainKind.Strk, ChainKind.Zcash, ChainKind.Fogo, + ChainKind.Aptos, ] addresses.forEach((addr, i) => { @@ -81,9 +89,10 @@ describe("Omni Address Utils", () => { "abs:0xabs456", "strk:0xstrk789", "fogo:dahPEoZGXfyV58JqqH85okdHmpN8U2q8owgPUXSCPxe", + "aptos:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf", ] - expect(validAddresses.length).toBe(9) // Just to use the array + expect(validAddresses.length).toBe(10) // Just to use the array }) it("should allow construction via omniAddress helper", () => { @@ -125,6 +134,7 @@ describe("Omni Address Utils", () => { expect(isEvmChain(ChainKind.Near)).toBe(false) expect(isEvmChain(ChainKind.Sol)).toBe(false) expect(isEvmChain(ChainKind.Strk)).toBe(false) + expect(isEvmChain(ChainKind.Aptos)).toBe(false) }) it("should work with type checking", () => { diff --git a/packages/core/tests/api.test.ts b/packages/core/tests/api.test.ts index dddb1389..b0339b05 100644 --- a/packages/core/tests/api.test.ts +++ b/packages/core/tests/api.test.ts @@ -71,6 +71,32 @@ const mockStarknetTransfer = { utxo_transfer: null, } +const mockAptosTransfer = { + id: { + origin_chain: "Aptos", + kind: { + Nonce: 789, + }, + }, + initialized: { + Aptos: { + version: 123456, + block_height: 777, + block_timestamp: 1730000000, + transaction_hash: "0xaptostx", + }, + }, + signed: null, + fast_finalised_on_near: null, + finalised_on_near: null, + fast_finalised: null, + finalised: null, + claimed: null, + transfer_message: null, + updated_fee: [], + utxo_transfer: null, +} + const mockFee = { native_token_fee: 5000, gas_fee: null, @@ -213,6 +239,17 @@ describe("BridgeAPI", () => { const transfers = await api.getTransfer({ originChain: "Strk", originNonce: 456 }) expect(transfers).toEqual([mockStarknetTransfer]) }) + + it("should parse Aptos transaction payloads", async () => { + server.use( + http.get(`${BASE_URL}/api/v3/transfers/transfer`, () => { + return HttpResponse.json([mockAptosTransfer]) + }), + ) + + const transfers = await api.getTransfer({ originChain: "Aptos", originNonce: 789 }) + expect(transfers).toEqual([mockAptosTransfer]) + }) }) describe("findTransfers", () => { diff --git a/packages/core/tests/bridge.test.ts b/packages/core/tests/bridge.test.ts index e2d0a5ea..f1937d85 100644 --- a/packages/core/tests/bridge.test.ts +++ b/packages/core/tests/bridge.test.ts @@ -463,6 +463,67 @@ describe("Bridge.validateTransfer", () => { }) }) + describe("Aptos contract address", () => { + it("validates a NEAR to Aptos transfer (contract address only resolved for source)", async () => { + mockNearView.mockImplementation(async (_contract: string, method: string, args: unknown) => { + if (method === "get_token_decimals") { + return { decimals: 8, origin_decimals: 24 } + } + if (method === "get_bridged_token") { + const { chain, address } = args as { chain: string; address: string } + if (chain === "Aptos" && address === "near:wrap.testnet") { + return "aptos:0x2ebb2ccac5e027a87fa0e2e5f656a3a4238d6a48d93ec9b610d570fc0aa0df12" + } + return null + } + return null + }) + + const params: TransferParams = { + token: "near:wrap.testnet" as OmniAddress, + amount: 1000000000000000000000000n, // 1 wNEAR (24 decimals) + fee: 0n, + nativeFee: 0n, + sender: "near:alice.testnet" as OmniAddress, + recipient: + "aptos:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf" as OmniAddress, + } + + const result = await bridge.validateTransfer(params) + + expect(result.destChain).toBe(ChainKind.Aptos) + expect(result.bridgedToken).toBe( + "aptos:0x2ebb2ccac5e027a87fa0e2e5f656a3a4238d6a48d93ec9b610d570fc0aa0df12", + ) + // The bridged token lookup must use the omni-types serde variant name. + expect(mockNearView).toHaveBeenCalledWith(expect.any(String), "get_bridged_token", { + chain: "Aptos", + address: "near:wrap.testnet", + }) + }) + + it("throws when source chain is Aptos (bridge not yet deployed)", async () => { + const params: TransferParams = { + token: "aptos:0x000000000000000000000000000000000000000000000000000000000000000a" as OmniAddress, + amount: 1000000000n, + fee: 0n, + nativeFee: 0n, + sender: + "aptos:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf" as OmniAddress, + recipient: "near:alice.testnet" as OmniAddress, + } + + await expect(bridge.validateTransfer(params)).rejects.toThrow(ValidationError) + await expect(bridge.validateTransfer(params)).rejects.toThrow( + "Aptos bridge is not yet deployed on this network", + ) + await expect(bridge.validateTransfer(params)).rejects.toMatchObject({ + code: "UNSUPPORTED_CHAIN", + details: { chain: "Aptos" }, + }) + }) + }) + describe("contract address resolution", () => { it("returns correct contract for ETH source", async () => { const params: TransferParams = { diff --git a/packages/core/tests/token.test.ts b/packages/core/tests/token.test.ts index 8b8f9873..33501d7c 100644 --- a/packages/core/tests/token.test.ts +++ b/packages/core/tests/token.test.ts @@ -131,6 +131,11 @@ describe("Token Utils", () => { it("should parse Fogo-prefixed wrapped tokens", () => { expect(parseOriginChain("fogo-ABC123.omdep.near")).toBe(ChainKind.Fogo) }) + + it("should parse Aptos-prefixed wrapped tokens", () => { + expect(parseOriginChain("aptos-0xcccc.omdep.near")).toBe(ChainKind.Aptos) + expect(parseOriginChain("aptos-0xcccc.omnidep.testnet")).toBe(ChainKind.Aptos) + }) }) describe("factory.bridge pattern", () => { diff --git a/packages/near/src/storage.ts b/packages/near/src/storage.ts index d6f93e0f..2d1ee6cc 100644 --- a/packages/near/src/storage.ts +++ b/packages/near/src/storage.ts @@ -24,6 +24,7 @@ const OmniAddressSchema = b.enum({ Strk: b.array(b.u8(), 32), Abs: b.array(b.u8(), 20), Fogo: b.array(b.u8(), 32), + Aptos: b.array(b.u8(), 32), }) /** @@ -97,13 +98,13 @@ function parseOmniAddress(token: string) { } const decodeHex = (addr: string) => Array.from(hex.decode(addr.slice(2))) const decodeBase58 = (addr: string) => Array.from(base58.decode(addr)) - // Starknet addresses are felts and often arrive with leading zero bytes - // stripped (e.g. `strk:0x1234`). The borsh schema needs exactly 32 bytes, - // so left-pad to 64 hex chars before decoding. - const decodeStrk = (addr: string) => { + // Starknet felts and Aptos account addresses often arrive with leading + // zero bytes stripped (e.g. `strk:0x1234`, `aptos:0xa`). The borsh schema + // needs exactly 32 bytes, so left-pad to 64 hex chars before decoding. + const decodePadded32 = (addr: string, chainName: string) => { const stripped = addr.startsWith("0x") || addr.startsWith("0X") ? addr.slice(2) : addr if (stripped.length > 64) { - throw new Error(`Starknet address exceeds 32 bytes: ${addr}`) + throw new Error(`${chainName} address exceeds 32 bytes: ${addr}`) } return Array.from(hex.decode(stripped.padStart(64, "0"))) } @@ -130,11 +131,13 @@ function parseOmniAddress(token: string) { case "zcash": return { Zcash: address } case "strk": - return { Strk: decodeStrk(address) } + return { Strk: decodePadded32(address, "Starknet") } case "abs": return { Abs: decodeHex(address) } case "fogo": return { Fogo: decodeBase58(address) } + case "aptos": + return { Aptos: decodePadded32(address, "Aptos") } default: { const _exhaustive: never = chain throw new Error(`Unknown chain: ${_exhaustive as string}`) diff --git a/packages/near/tests/chain-kind-schema.test.ts b/packages/near/tests/chain-kind-schema.test.ts index 14e34843..1f16c4f8 100644 --- a/packages/near/tests/chain-kind-schema.test.ts +++ b/packages/near/tests/chain-kind-schema.test.ts @@ -21,6 +21,7 @@ describe("ChainKindSchema borsh discriminants", () => { ["Strk", 10], ["Abs", 11], ["Fogo", 12], + ["Aptos", 13], ] for (const [name, expectedByte] of cases) { diff --git a/packages/near/tests/storage.test.ts b/packages/near/tests/storage.test.ts index ff513cc5..7775a9b4 100644 --- a/packages/near/tests/storage.test.ts +++ b/packages/near/tests/storage.test.ts @@ -119,6 +119,25 @@ describe("calculateStorageAccountId", () => { sender: "near:intents.near" as const, msg: "", }, + { + token: "near:wrap.near", + amount: 1000000n, + recipient: + "aptos:0x05558831a603eca8cd69a42d4251f08de3573039b69f23972265cac76639f1cf" as const, + fee: { fee: 0n, native_fee: 0n }, + sender: "near:intents.near" as const, + msg: "", + }, + { + // Aptos short-form addresses (e.g. the canonical APT FA at 0xa) must + // also be left-padded to 32 bytes. + token: "near:wrap.near", + amount: 1000000n, + recipient: "aptos:0xa" as const, + fee: { fee: 0n, native_fee: 0n }, + sender: "near:intents.near" as const, + msg: "", + }, ] const accountIds = messages.map(calculateStorageAccountId) @@ -132,6 +151,24 @@ describe("calculateStorageAccountId", () => { expect(new Set(accountIds).size).toBe(accountIds.length) }) + it("serializes short-form and zero-padded Aptos addresses identically", () => { + const base = { + token: "near:wrap.near" as const, + amount: 1000000n, + fee: { fee: 0n, native_fee: 0n }, + sender: "near:intents.near" as const, + msg: "", + } + + const shortForm = calculateStorageAccountId({ ...base, recipient: "aptos:0xa" }) + const padded = calculateStorageAccountId({ + ...base, + recipient: "aptos:0x000000000000000000000000000000000000000000000000000000000000000a", + }) + + expect(shortForm).toBe(padded) + }) + it("handles zero amounts", () => { const transferMessage = { token: "near:token.near" as const, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 85797fd2..6a99b284 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -30,6 +30,7 @@ "license": "MIT", "author": "NEAR One", "dependencies": { + "@omni-bridge/aptos": "workspace:*", "@omni-bridge/core": "workspace:*", "@omni-bridge/evm": "workspace:*", "@omni-bridge/near": "workspace:*", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 14a6ce8c..cd8d2d9b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,6 +1,7 @@ // @omni-bridge/sdk - Umbrella package // Re-exports all chain packages for convenience +export * from "@omni-bridge/aptos" export * from "@omni-bridge/btc" export * from "@omni-bridge/core" export * from "@omni-bridge/evm" diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 21336c96..52d49868 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../near" }, { "path": "../solana" }, { "path": "../btc" }, - { "path": "../starknet" } + { "path": "../starknet" }, + { "path": "../aptos" } ] } diff --git a/tsconfig.json b/tsconfig.json index efd23076..6cf1b893 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ { "path": "packages/solana" }, { "path": "packages/btc" }, { "path": "packages/starknet" }, + { "path": "packages/aptos" }, { "path": "packages/sdk" } ] }