From 9fbec5c2b2c7e35223c20c390db8143321205202 Mon Sep 17 00:00:00 2001 From: abtestingalpha <104046418+abtestingalpha@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:28:03 -0400 Subject: [PATCH] FE Release 2026-09-22 (#4136) * Publish - @synapsecns/synapse-interface@0.44.4 * fix(sdk-router): enable ETH as SBA bridge destination (#4063) Add SupportedChainId.ETH to SBA_BRIDGE_ENABLED_DESTINATION_CHAINS. The SBA adapter is deployed on ETH and tokens like HIGH are configured, but ETH was missing from the enabled destinations set so all SBA routes to Ethereum returned empty. Co-authored-by: Trajan0x Co-authored-by: Claude Opus 4.6 (1M context) * Publish - @synapsecns/rest-api@1.13.39 - @synapsecns/sdk-router@0.23.3 - @synapsecns/synapse-interface@0.44.5 - @synapsecns/widget-example@0.2.5 - @synapsecns/widget@0.11.5 * Unpause DFK (#4067) * Unpause DFK * fix: use direct DFK RPCs for bridge quote providers (#4068) * fix(terraform-provider-grafanaamg): sweep expired provider tokens before minting (#4073) * fix(terraform-provider-grafanaamg): harden release publishing (#4074) * ci(terraform-provider-grafanaamg): add signing key refresh * fix(terraform-provider-grafanaamg): export protected signing key * fix(terraform-provider-grafanaamg): harden release publishing * fix(terraform-provider-grafanaamg): validate release inputs * fix(terraform-provider-grafanaamg): preserve published releases --------- Co-authored-by: Trajan0x * Publish - @synapsecns/synapse-interface@0.45.0 * fix: restore Aurora and Cronos outbound quotes and RPC fallback (#4132) * fix(sdk-router): restore Aurora and Cronos outbound routes * fix(synapse-interface): fall back after RPC HTTP failures * test(sdk-router): defer RPC calls in skipped swap suite * style: resolve bridge quote lint violations * fix(synapse-interface): replace failing public RPC endpoints * fix(synapse-interface): distinguish RPC outages from execution errors * Publish - @synapsecns/rest-api@1.13.40 - @synapsecns/sdk-router@0.23.4 - @synapsecns/synapse-interface@0.45.1 - @synapsecns/widget-example@0.2.6 - @synapsecns/widget@0.11.6 * Add Syn bridging routes and state-managed bridge support (#4135) * feat(sdk-router): add SYN bridging to HyperEVM and HyperCore * fix(synapse-interface): derive destinations from the selected route * feat(synapse-interface): add SYN routes with native HyperCore token IDs * fix(synapse-interface): repair Ethereum and development RPC access * Update Hyperliquid explorer links * feat(sdk-router): support SYN fallback delivery on HyperEVM * feat(synapse-interface): acknowledge inactive SYN recipients * fix(synapse-interface): track actual SYN delivery destination * refactor(sdk-router): simplify SYN delivery verification * refactor(synapse-interface): address SonarCloud findings * ci: replace deprecated SonarCloud scan actions --------- Co-authored-by: trajan0x <83933037+trajan0x@users.noreply.github.com> Co-authored-by: trajan0x Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Kurt Siegfried Co-authored-by: ChiTimesChi <88190723+ChiTimesChi@users.noreply.github.com> Co-authored-by: ChiTimesChi --- .../workflows/grafanaamg-provider-release.yml | 61 ++- .github/workflows/scan.yml | 6 +- .../terraform-provider-grafanaamg/README.md | 3 +- .../provider/wrapper.go | 104 ++++- .../provider/wrapper_test.go | 171 ++++++++ packages/rest-api/CHANGELOG.md | 8 + packages/rest-api/package.json | 4 +- packages/sdk-router/CHANGELOG.md | 11 + packages/sdk-router/README.md | 40 ++ packages/sdk-router/package.json | 2 +- .../sdk-router/src/constants/addresses.ts | 14 + packages/sdk-router/src/constants/chainIds.ts | 3 + packages/sdk-router/src/oft/index.ts | 2 + packages/sdk-router/src/oft/synModule.ts | 58 +++ .../sdk-router/src/oft/synModuleSet.test.ts | 327 ++++++++++++++ packages/sdk-router/src/oft/synModuleSet.ts | 240 ++++++++++ packages/sdk-router/src/oft/synStatus.test.ts | 255 +++++++++++ packages/sdk-router/src/oft/synStatus.ts | 229 ++++++++++ packages/sdk-router/src/operations/bridge.ts | 7 +- .../sdk-router/src/sba/originRoutes.test.ts | 95 ++++ .../src/sba/supportedTokens.test.ts | 115 +++++ .../sdk-router/src/sba/supportedTokens.ts | 8 +- .../sba/synapseBridgeAdapterModuleSet.test.ts | 17 +- packages/sdk-router/src/sdk.test.ts | 18 +- packages/sdk-router/src/sdk.ts | 5 +- .../src/utils/TokenMetadataFetcher.ts | 8 + packages/synapse-interface/CHANGELOG.md | 27 ++ packages/synapse-interface/README.md | 7 +- ...ndingBridgeTransactionTrackingData.test.ts | 34 +- .../__tests__/getToChainIds.test.ts | 3 +- .../__tests__/synBridgeQuote.test.ts | 183 ++++++++ .../__tests__/synBridgeQuoteReducer.test.ts | 69 +++ .../__tests__/synBridgeRoutes.test.ts | 122 +++++ .../__tests__/synOriginSelector.test.ts | 15 + .../__tests__/synPortfolioBalances.test.ts | 32 ++ .../__tests__/synRecipientWarning.test.tsx | 135 ++++++ .../__tests__/synTransactionStatus.test.tsx | 86 ++++ .../components/Maintenance/Maintenance.tsx | 12 +- .../functions/isValidBridgeModule.ts | 16 +- .../BridgeExchangeRateInfo.tsx | 4 +- .../BridgeTransactionButton.tsx | 25 +- .../StateManagedBridge/BridgeWarnings.tsx | 32 ++ .../StateManagedBridge/OutputContainer.tsx | 18 +- .../hooks/useBridgeValidations.ts | 4 +- .../hooks/useFromChainListArray.ts | 11 +- .../components/_Transaction/_Transaction.tsx | 30 +- .../components/_Transaction/_Transactions.tsx | 12 +- .../_Transaction/helpers/useBridgeTxStatus.ts | 35 +- .../helpers/useBridgeTxUpdater.ts | 63 ++- .../constants/chains/extraWagmiChains.ts | 4 +- .../constants/chains/master.tsx | 34 +- .../constants/existingBridgeRoutes.ts | 19 +- .../constants/tokens/bridgeable.ts | 9 +- .../synapse-interface/messages/en-US.json | 3 + packages/synapse-interface/package.json | 5 +- .../pages/state-managed-bridge/index.tsx | 97 ++-- .../scripts/checkRpcHealth.js | 2 +- .../slices/_transactions/reducer.ts | 11 +- .../slices/bridgeQuote/reducer.ts | 21 +- .../slices/bridgeQuote/thunks.ts | 16 +- .../utils/actions/fetchPortfolioBalances.tsx | 5 +- .../utils/getBridgeModuleNames.ts | 4 +- ...getPendingBridgeTransactionTrackingData.ts | 15 +- .../synapse-interface/utils/hyperliquid.ts | 13 + .../utils/providers/SynapseProvider.tsx | 5 +- .../TransportAwareJsonRpcProvider.test.cjs | 415 ++++++++++++++++++ .../TransportAwareJsonRpcProvider.ts | 123 ++++++ .../utils/routeMaker/getToChainIds.ts | 15 +- .../synapse-interface/utils/types/index.tsx | 9 + packages/widget-example/CHANGELOG.md | 8 + packages/widget-example/package.json | 4 +- packages/widget/CHANGELOG.md | 8 + packages/widget/package.json | 4 +- 73 files changed, 3418 insertions(+), 217 deletions(-) create mode 100644 packages/sdk-router/src/oft/synModule.ts create mode 100644 packages/sdk-router/src/oft/synModuleSet.test.ts create mode 100644 packages/sdk-router/src/oft/synModuleSet.ts create mode 100644 packages/sdk-router/src/oft/synStatus.test.ts create mode 100644 packages/sdk-router/src/oft/synStatus.ts create mode 100644 packages/sdk-router/src/sba/originRoutes.test.ts create mode 100644 packages/sdk-router/src/sba/supportedTokens.test.ts create mode 100644 packages/synapse-interface/__tests__/synBridgeQuote.test.ts create mode 100644 packages/synapse-interface/__tests__/synBridgeQuoteReducer.test.ts create mode 100644 packages/synapse-interface/__tests__/synBridgeRoutes.test.ts create mode 100644 packages/synapse-interface/__tests__/synOriginSelector.test.ts create mode 100644 packages/synapse-interface/__tests__/synPortfolioBalances.test.ts create mode 100644 packages/synapse-interface/__tests__/synRecipientWarning.test.tsx create mode 100644 packages/synapse-interface/__tests__/synTransactionStatus.test.tsx create mode 100644 packages/synapse-interface/utils/hyperliquid.ts create mode 100644 packages/synapse-interface/utils/providers/TransportAwareJsonRpcProvider.test.cjs create mode 100644 packages/synapse-interface/utils/providers/TransportAwareJsonRpcProvider.ts diff --git a/.github/workflows/grafanaamg-provider-release.yml b/.github/workflows/grafanaamg-provider-release.yml index 40387dc295..dc5890af7b 100644 --- a/.github/workflows/grafanaamg-provider-release.yml +++ b/.github/workflows/grafanaamg-provider-release.yml @@ -32,6 +32,23 @@ jobs: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} + - name: Validate GPG signing key + env: + GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} + run: | + set -euo pipefail + + if [ -z "$GPG_FINGERPRINT" ]; then + echo "GPG import did not return a signing-key fingerprint" >&2 + exit 1 + fi + + SIGNATURE_TEST="$(mktemp "${RUNNER_TEMP:-/tmp}/grafanaamg-signing-preflight.XXXXXX")" + trap 'rm -f "$SIGNATURE_TEST"' EXIT + printf 'terraform-provider-grafanaamg release signing preflight\n' \ + | gpg --batch --local-user "$GPG_FINGERPRINT" \ + --yes --detach-sign --output "$SIGNATURE_TEST" + - name: Build release assets env: RELEASE_TAG: ${{ inputs.tag }} @@ -110,28 +127,42 @@ jobs: --notes-file "$BODY" fi - - name: Check Terraform Registry release - id: registry_release + - name: Publish release to Terraform Registry repository env: - GH_TOKEN: ${{ secrets.PUBLISH_TOKEN || secrets.WORKFLOW_PAT || github.token }} + GH_TOKEN: ${{ secrets.PUBLISH_TOKEN || secrets.WORKFLOW_PAT }} RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail TARGET_TAG="${RELEASE_TAG#contrib/terraform-provider-grafanaamg/}" - echo "target_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT" + PROJECT="terraform-provider-grafanaamg" + DIST="$PWD/dist/$PROJECT" + DESTINATION="synapsecns/terraform-provider-grafanaamg" + mapfile -t ASSETS < <(find "$DIST" -maxdepth 1 -type f | sort) + + if [ "${#ASSETS[@]}" -eq 0 ]; then + echo "No release assets found in $DIST" >&2 + exit 1 + fi - if gh release view "$TARGET_TAG" -R synapsecns/terraform-provider-grafanaamg >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" + if [ -z "$GH_TOKEN" ]; then + echo "PUBLISH_TOKEN or WORKFLOW_PAT is required to publish the registry release" >&2 + exit 1 + fi + + if gh release view "$TARGET_TAG" --repo "$DESTINATION" >/dev/null 2>&1; then + RELEASE_IS_DRAFT="$(gh release view "$TARGET_TAG" --repo "$DESTINATION" --json isDraft --jq .isDraft)" + if [ "$RELEASE_IS_DRAFT" != "true" ]; then + echo "Registry release $TARGET_TAG is already published and cannot be changed" >&2 + exit 1 + fi + gh release upload "$TARGET_TAG" "${ASSETS[@]}" --clobber --repo "$DESTINATION" else - echo "exists=false" >> "$GITHUB_OUTPUT" + gh release create "$TARGET_TAG" "${ASSETS[@]}" \ + --draft \ + --repo "$DESTINATION" \ + --title "$TARGET_TAG" \ + --notes "Release ${PROJECT} ${TARGET_TAG#v}." fi - - name: Copy release to Terraform Registry repository - if: steps.registry_release.outputs.exists != 'true' - uses: docker://ghcr.io/synapsecns/sanguine/release-copier-action:latest - with: - github_token: ${{ secrets.PUBLISH_TOKEN || secrets.WORKFLOW_PAT }} - destination_repo: synapsecns/terraform-provider-grafanaamg - tag_name: ${{ inputs.tag }} - strip_prefix: contrib/terraform-provider-grafanaamg/ + gh release edit "$TARGET_TAG" --draft=false --repo "$DESTINATION" diff --git a/.github/workflows/scan.yml b/.github/workflows/scan.yml index 43740aa0e5..215036061d 100644 --- a/.github/workflows/scan.yml +++ b/.github/workflows/scan.yml @@ -11,7 +11,7 @@ concurrency: jobs: sonarcloud: name: SonarCloud - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.repository_owner == 'synapsecns' # these are pretty disruptive # permissions: @@ -19,11 +19,11 @@ jobs: # pull-requests: write # checks: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master + uses: SonarSource/sonarqube-scan-action@v8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/contrib/terraform-provider-grafanaamg/README.md b/contrib/terraform-provider-grafanaamg/README.md index a1d8841fc8..0b9fb33cc3 100644 --- a/contrib/terraform-provider-grafanaamg/README.md +++ b/contrib/terraform-provider-grafanaamg/README.md @@ -34,7 +34,7 @@ export GRAFANA_AMG_SERVICE_ACCOUNT_ID="sa-000000000000000000000" export GRAFANA_AMG_REGION="us-east-1" ``` -The provider uses the default AWS SDK credential chain. The AWS principal must be allowed to call `grafana:CreateWorkspaceServiceAccountToken` and `grafana:DeleteWorkspaceServiceAccountToken` for the configured workspace and service account. +The provider uses the default AWS SDK credential chain. The AWS principal must be allowed to call `grafana:CreateWorkspaceServiceAccountToken`, `grafana:DeleteWorkspaceServiceAccountToken`, and `grafana:ListWorkspaceServiceAccountTokens` for the configured workspace and service account. If `GRAFANA_AUTH` is already set, the wrapper does not create an AMG token and delegates directly to the upstream Grafana provider. @@ -48,6 +48,7 @@ If `GRAFANA_AUTH` is already set, the wrapper does not create an AMG token and d | `GRAFANA_AMG_TOKEN_TTL_SECONDS` | No | `3600` | Lifetime for each generated service account token. | | `GRAFANA_AMG_TOKEN_NAME_PREFIX` | No | `terraform-provider-grafanaamg` | Prefix for generated token names. | | `GRAFANA_AMG_DELETE_TOKEN_ON_STOP` | No | `true` | Delete generated tokens when Terraform stops the provider. Tokens still expire by TTL if cleanup is interrupted. | +| `GRAFANA_AMG_SWEEP_EXPIRED_TOKENS` | No | `true` | Before minting, delete **expired** tokens whose name matches the token name prefix. AMG counts expired tokens against the workspace service-account token quota until they are deleted, so tokens leaked by interrupted runs otherwise accumulate until every run fails with `ServiceQuotaExceededException`. The sweep never touches unexpired tokens or tokens without the prefix, and a sweep failure is logged but never fails the run. | ## Release diff --git a/contrib/terraform-provider-grafanaamg/provider/wrapper.go b/contrib/terraform-provider-grafanaamg/provider/wrapper.go index de1a6baf64..1be1709d0f 100644 --- a/contrib/terraform-provider-grafanaamg/provider/wrapper.go +++ b/contrib/terraform-provider-grafanaamg/provider/wrapper.go @@ -14,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/grafana" + "github.com/aws/aws-sdk-go-v2/service/grafana/types" "github.com/hashicorp/terraform-plugin-go/tfprotov5" ) @@ -26,6 +27,7 @@ const ( envTokenTTLSeconds = "GRAFANA_AMG_TOKEN_TTL_SECONDS" // #nosec G101 -- environment variable name, not a credential. envTokenNamePrefix = "GRAFANA_AMG_TOKEN_NAME_PREFIX" // #nosec G101 -- environment variable name, not a credential. envDeleteToken = "GRAFANA_AMG_DELETE_TOKEN_ON_STOP" // #nosec G101 -- environment variable name, not a credential. + envSweepExpired = "GRAFANA_AMG_SWEEP_EXPIRED_TOKENS" // #nosec G101 -- environment variable name, not a credential. defaultTokenTTLSeconds = int32(3600) defaultTokenNamePrefix = "terraform-provider-grafanaamg" // #nosec G101 -- token name prefix, not a credential. @@ -53,11 +55,13 @@ type amgConfig struct { tokenTTLSeconds int32 tokenNamePrefix string deleteToken bool + sweepExpired bool } type grafanaAPI interface { CreateWorkspaceServiceAccountToken(ctx context.Context, input *grafana.CreateWorkspaceServiceAccountTokenInput, optFns ...func(*grafana.Options)) (*grafana.CreateWorkspaceServiceAccountTokenOutput, error) DeleteWorkspaceServiceAccountToken(ctx context.Context, input *grafana.DeleteWorkspaceServiceAccountTokenInput, optFns ...func(*grafana.Options)) (*grafana.DeleteWorkspaceServiceAccountTokenOutput, error) + ListWorkspaceServiceAccountTokens(ctx context.Context, input *grafana.ListWorkspaceServiceAccountTokensInput, optFns ...func(*grafana.Options)) (*grafana.ListWorkspaceServiceAccountTokensOutput, error) } var newGrafanaClient = grafanaClient @@ -83,6 +87,10 @@ func (s *Server) ConfigureProvider(ctx context.Context, req *tfprotov5.Configure return resp, nil } + if cfg.sweepExpired { + sweepExpiredTokens(ctx, cfg) + } + token, err := s.createToken(ctx, cfg) if err != nil { return diagnosticResponse("Failed to create Amazon Managed Grafana token.", err.Error()), nil @@ -194,6 +202,80 @@ func (s *Server) createToken(ctx context.Context, cfg amgConfig) (mintedToken, e }, nil } +// sweepExpiredTokens best-effort deletes expired tokens previously minted by +// this provider (matched by the configured name prefix). AMG counts expired +// tokens against the workspace's service-account token quota until they are +// explicitly deleted, so tokens leaked by interrupted runs (a crash or kill +// between ConfigureProvider and StopProvider) accumulate until every +// subsequent run fails token creation with ServiceQuotaExceededException. +// Sweeping before each mint keeps the account at one live token per +// concurrent run. Failures are logged, never fatal: the sweep must not break +// a run that would otherwise succeed. +func sweepExpiredTokens(ctx context.Context, cfg amgConfig) { + client, err := newGrafanaClient(ctx, cfg.region) + if err != nil { + log.Printf("skipping expired AMG token sweep: %v", err) + return + } + + now := time.Now() + swept := 0 + + var nextToken *string + for { + out, listErr := client.ListWorkspaceServiceAccountTokens(ctx, &grafana.ListWorkspaceServiceAccountTokensInput{ + ServiceAccountId: aws.String(cfg.serviceAccountID), + WorkspaceId: aws.String(cfg.workspaceID), + NextToken: nextToken, + }) + if listErr != nil { + log.Printf("skipping expired AMG token sweep: list tokens: %v", listErr) + return + } + + for _, token := range out.ServiceAccountTokens { + if !isSweepable(token, cfg.tokenNamePrefix, now) { + continue + } + + _, deleteErr := client.DeleteWorkspaceServiceAccountToken(ctx, &grafana.DeleteWorkspaceServiceAccountTokenInput{ + ServiceAccountId: aws.String(cfg.serviceAccountID), + TokenId: token.Id, + WorkspaceId: aws.String(cfg.workspaceID), + }) + if deleteErr != nil { + log.Printf("failed to sweep expired AMG token %s: %v", aws.ToString(token.Id), deleteErr) + continue + } + swept++ + } + + if aws.ToString(out.NextToken) == "" { + break + } + nextToken = out.NextToken + } + + if swept > 0 { + log.Printf("swept %d expired AMG service account token(s) minted by this provider", swept) + } +} + +// isSweepable reports whether a token is an expired leftover minted by this +// provider. Only prefix-matched tokens are considered so other tokens on a +// shared service account (human-created, other tooling) are never touched. +func isSweepable(token types.ServiceAccountTokenSummary, namePrefix string, now time.Time) bool { + if token.Id == nil || token.ExpiresAt == nil { + return false + } + + if !token.ExpiresAt.Before(now) { + return false + } + + return strings.HasPrefix(aws.ToString(token.Name), namePrefix) +} + func deleteToken(ctx context.Context, token createdToken) error { client, err := newGrafanaClient(ctx, token.region) if err != nil { @@ -243,7 +325,12 @@ func loadAMGConfig() (amgConfig, bool, error) { return amgConfig{}, false, err } - deleteOnStop, err := parseDeleteOnStop() + deleteOnStop, err := parseBoolEnv(envDeleteToken, true) + if err != nil { + return amgConfig{}, false, err + } + + sweepExpired, err := parseBoolEnv(envSweepExpired, true) if err != nil { return amgConfig{}, false, err } @@ -260,6 +347,7 @@ func loadAMGConfig() (amgConfig, bool, error) { tokenTTLSeconds: ttl, tokenNamePrefix: prefix, deleteToken: deleteOnStop, + sweepExpired: sweepExpired, }, true, nil } @@ -277,18 +365,18 @@ func parseTokenTTL() (int32, error) { return int32(parsedTTL), nil } -func parseDeleteOnStop() (bool, error) { - rawDelete := strings.TrimSpace(os.Getenv(envDeleteToken)) - if rawDelete == "" { - return true, nil +func parseBoolEnv(name string, defaultValue bool) (bool, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return defaultValue, nil } - parsedDelete, err := strconv.ParseBool(rawDelete) + parsed, err := strconv.ParseBool(raw) if err != nil { - return false, fmt.Errorf("%s must be a boolean", envDeleteToken) + return false, fmt.Errorf("%s must be a boolean", name) } - return parsedDelete, nil + return parsed, nil } func diagnosticResponse(summary string, err string) *tfprotov5.ConfigureProviderResponse { diff --git a/contrib/terraform-provider-grafanaamg/provider/wrapper_test.go b/contrib/terraform-provider-grafanaamg/provider/wrapper_test.go index 9e068e6e25..2c77168f91 100644 --- a/contrib/terraform-provider-grafanaamg/provider/wrapper_test.go +++ b/contrib/terraform-provider-grafanaamg/provider/wrapper_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "testing" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/grafana" @@ -41,6 +42,14 @@ type fakeGrafanaClient struct { createErr error deleteInput *grafana.DeleteWorkspaceServiceAccountTokenInput deleteErr error + // deletedTokenIDs records every delete, in order (deleteInput only + // keeps the last one). + deletedTokenIDs []string + // listOuts are returned in order, one per ListWorkspaceServiceAccountTokens + // call, so tests can exercise pagination. + listOuts []*grafana.ListWorkspaceServiceAccountTokensOutput + listErr error + listCalls int } func (f *fakeGrafanaClient) CreateWorkspaceServiceAccountToken(_ context.Context, input *grafana.CreateWorkspaceServiceAccountTokenInput, _ ...func(*grafana.Options)) (*grafana.CreateWorkspaceServiceAccountTokenOutput, error) { @@ -51,10 +60,27 @@ func (f *fakeGrafanaClient) CreateWorkspaceServiceAccountToken(_ context.Context func (f *fakeGrafanaClient) DeleteWorkspaceServiceAccountToken(_ context.Context, input *grafana.DeleteWorkspaceServiceAccountTokenInput, _ ...func(*grafana.Options)) (*grafana.DeleteWorkspaceServiceAccountTokenOutput, error) { f.deleteInput = input + f.deletedTokenIDs = append(f.deletedTokenIDs, aws.ToString(input.TokenId)) return &grafana.DeleteWorkspaceServiceAccountTokenOutput{}, f.deleteErr } +func (f *fakeGrafanaClient) ListWorkspaceServiceAccountTokens(_ context.Context, _ *grafana.ListWorkspaceServiceAccountTokensInput, _ ...func(*grafana.Options)) (*grafana.ListWorkspaceServiceAccountTokensOutput, error) { + f.listCalls++ + if f.listErr != nil { + return nil, f.listErr + } + + if len(f.listOuts) == 0 { + return &grafana.ListWorkspaceServiceAccountTokensOutput{}, nil + } + + out := f.listOuts[0] + f.listOuts = f.listOuts[1:] + + return out, nil +} + func withGrafanaClient(t *testing.T, client grafanaAPI) { t.Helper() @@ -125,6 +151,129 @@ func TestConfigureProviderCreatesTokenAndDelegates(t *testing.T) { } } +func TestSweepDeletesOnlyExpiredPrefixMatchedTokens(t *testing.T) { + past := time.Now().Add(-time.Hour) + future := time.Now().Add(time.Hour) + client := &fakeGrafanaClient{ + listOuts: []*grafana.ListWorkspaceServiceAccountTokensOutput{{ + ServiceAccountTokens: []types.ServiceAccountTokenSummary{ + // Expired + prefix match: swept. + {Id: aws.String("expired-ours"), Name: aws.String("terraform-provider-grafanaamg-1"), ExpiresAt: &past}, + // Expired but foreign name: never touched. + {Id: aws.String("expired-foreign"), Name: aws.String("human-created"), ExpiresAt: &past}, + // Prefix match but still live (e.g. a concurrent run): kept. + {Id: aws.String("live-ours"), Name: aws.String("terraform-provider-grafanaamg-2"), ExpiresAt: &future}, + // Defensive: no expiry recorded -> kept. + {Id: aws.String("no-expiry"), Name: aws.String("terraform-provider-grafanaamg-3")}, + }, + }}, + } + withGrafanaClient(t, client) + + sweepExpiredTokens(context.Background(), amgConfig{ + workspaceID: "g-123", + serviceAccountID: "sa-123", + tokenNamePrefix: defaultTokenNamePrefix, + sweepExpired: true, + }) + + if len(client.deletedTokenIDs) != 1 || client.deletedTokenIDs[0] != "expired-ours" { + t.Fatalf("expected exactly [expired-ours] deleted, got %v", client.deletedTokenIDs) + } +} + +func TestSweepFollowsPagination(t *testing.T) { + past := time.Now().Add(-time.Hour) + client := &fakeGrafanaClient{ + listOuts: []*grafana.ListWorkspaceServiceAccountTokensOutput{ + { + ServiceAccountTokens: []types.ServiceAccountTokenSummary{ + {Id: aws.String("page1"), Name: aws.String("terraform-provider-grafanaamg-1"), ExpiresAt: &past}, + }, + NextToken: aws.String("more"), + }, + { + ServiceAccountTokens: []types.ServiceAccountTokenSummary{ + {Id: aws.String("page2"), Name: aws.String("terraform-provider-grafanaamg-2"), ExpiresAt: &past}, + }, + }, + }, + } + withGrafanaClient(t, client) + + sweepExpiredTokens(context.Background(), amgConfig{ + workspaceID: "g-123", + serviceAccountID: "sa-123", + tokenNamePrefix: defaultTokenNamePrefix, + sweepExpired: true, + }) + + if client.listCalls != 2 { + t.Fatalf("expected 2 list calls, got %d", client.listCalls) + } + if len(client.deletedTokenIDs) != 2 { + t.Fatalf("expected 2 deletions across pages, got %v", client.deletedTokenIDs) + } +} + +func TestConfigureProviderSweepFailureDoesNotFailRun(t *testing.T) { + t.Setenv(envWorkspaceID, "g-123") + t.Setenv(envServiceAccountID, "sa-123") + t.Setenv(envGrafanaAuth, "") + + client := &fakeGrafanaClient{ + listErr: errors.New("list boom"), + createOut: &grafana.CreateWorkspaceServiceAccountTokenOutput{ + ServiceAccountToken: &types.ServiceAccountTokenSummaryWithKey{ + Id: aws.String("token-123"), + Key: aws.String("secret"), + }, + }, + } + withGrafanaClient(t, client) + + upstream := &fakeProviderServer{configureResp: &tfprotov5.ConfigureProviderResponse{}} + resp, err := New(upstream).ConfigureProvider(context.Background(), &tfprotov5.ConfigureProviderRequest{}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if hasErrors(resp) { + t.Fatal("a failed sweep must not fail provider configuration") + } + if client.listCalls != 1 { + t.Fatalf("expected the sweep to have been attempted, got %d list calls", client.listCalls) + } + if upstream.configureCalls != 1 { + t.Fatalf("expected one configure call, got %d", upstream.configureCalls) + } +} + +func TestConfigureProviderSweepDisabled(t *testing.T) { + t.Setenv(envWorkspaceID, "g-123") + t.Setenv(envServiceAccountID, "sa-123") + t.Setenv(envGrafanaAuth, "") + t.Setenv(envSweepExpired, "false") + + client := &fakeGrafanaClient{ + createOut: &grafana.CreateWorkspaceServiceAccountTokenOutput{ + ServiceAccountToken: &types.ServiceAccountTokenSummaryWithKey{ + Id: aws.String("token-123"), + Key: aws.String("secret"), + }, + }, + } + withGrafanaClient(t, client) + + upstream := &fakeProviderServer{configureResp: &tfprotov5.ConfigureProviderResponse{}} + _, err := New(upstream).ConfigureProvider(context.Background(), &tfprotov5.ConfigureProviderRequest{}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if client.listCalls != 0 { + t.Fatalf("expected no list calls with sweep disabled, got %d", client.listCalls) + } +} + func TestConfigureProviderDelegatesWhenGrafanaAuthSet(t *testing.T) { t.Setenv(envWorkspaceID, "g-123") t.Setenv(envServiceAccountID, "sa-123") @@ -242,6 +391,7 @@ func TestLoadAMGConfigDefaults(t *testing.T) { t.Setenv(envTokenTTLSeconds, "") t.Setenv(envTokenNamePrefix, "") t.Setenv(envDeleteToken, "") + t.Setenv(envSweepExpired, "") cfg, enabled, err := loadAMGConfig() if err != nil { @@ -265,6 +415,9 @@ func TestLoadAMGConfigDefaults(t *testing.T) { if !cfg.deleteToken { t.Fatal("expected token cleanup to default to true") } + if !cfg.sweepExpired { + t.Fatal("expected expired-token sweep to default to true") + } } func TestLoadAMGConfigOverrides(t *testing.T) { @@ -274,6 +427,7 @@ func TestLoadAMGConfigOverrides(t *testing.T) { t.Setenv(envTokenTTLSeconds, "120") t.Setenv(envTokenNamePrefix, "ci") t.Setenv(envDeleteToken, "false") + t.Setenv(envSweepExpired, "false") cfg, enabled, err := loadAMGConfig() if err != nil { @@ -294,6 +448,23 @@ func TestLoadAMGConfigOverrides(t *testing.T) { if cfg.deleteToken { t.Fatal("expected token cleanup override to be false") } + if cfg.sweepExpired { + t.Fatal("expected expired-token sweep override to be false") + } +} + +func TestLoadAMGConfigRejectsInvalidSweepExpired(t *testing.T) { + t.Setenv(envWorkspaceID, "g-123") + t.Setenv(envServiceAccountID, "sa-123") + t.Setenv(envSweepExpired, "not-bool") + + _, enabled, err := loadAMGConfig() + if err == nil { + t.Fatal("expected an error") + } + if enabled { + t.Fatal("expected AMG config to be disabled on invalid input") + } } func TestLoadAMGConfigRejectsInvalidTTL(t *testing.T) { diff --git a/packages/rest-api/CHANGELOG.md b/packages/rest-api/CHANGELOG.md index e5d80e1f67..9dbaa53d87 100644 --- a/packages/rest-api/CHANGELOG.md +++ b/packages/rest-api/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.13.40](https://github.com/synapsecns/sanguine/compare/@synapsecns/rest-api@1.13.39...@synapsecns/rest-api@1.13.40) (2026-09-21) + +**Note:** Version bump only for package @synapsecns/rest-api + + + + + ## [1.13.39](https://github.com/synapsecns/sanguine/compare/@synapsecns/rest-api@1.13.38...@synapsecns/rest-api@1.13.39) (2026-05-22) **Note:** Version bump only for package @synapsecns/rest-api diff --git a/packages/rest-api/package.json b/packages/rest-api/package.json index e1cf5ad12f..cd4497a7d3 100644 --- a/packages/rest-api/package.json +++ b/packages/rest-api/package.json @@ -1,6 +1,6 @@ { "name": "@synapsecns/rest-api", - "version": "1.13.39", + "version": "1.13.40", "private": "true", "engines": { "node": ">=18.17.0" @@ -22,7 +22,7 @@ "@ethersproject/bignumber": "^5.7.0", "@ethersproject/providers": "^5.7.2", "@ethersproject/units": "5.7.0", - "@synapsecns/sdk-router": "^0.23.3", + "@synapsecns/sdk-router": "^0.23.4", "@synapsecns/synapse-constants": "^1.8.13", "bignumber": "^1.1.0", "cross-fetch": "^4.0.0", diff --git a/packages/sdk-router/CHANGELOG.md b/packages/sdk-router/CHANGELOG.md index 4ac48b157a..51ec29c20b 100644 --- a/packages/sdk-router/CHANGELOG.md +++ b/packages/sdk-router/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.23.4](https://github.com/synapsecns/sanguine/compare/@synapsecns/sdk-router@0.23.3...@synapsecns/sdk-router@0.23.4) (2026-09-21) + + +### Bug Fixes + +* restore Aurora and Cronos outbound quotes and RPC fallback ([#4132](https://github.com/synapsecns/sanguine/issues/4132)) ([3e98c89](https://github.com/synapsecns/sanguine/commit/3e98c89c98fb3b6dfe6766b713add604be85619e)) + + + + + ## [0.23.3](https://github.com/synapsecns/sanguine/compare/@synapsecns/sdk-router@0.23.2...@synapsecns/sdk-router@0.23.3) (2026-05-22) diff --git a/packages/sdk-router/README.md b/packages/sdk-router/README.md index 24ba571aa0..665b28a9fa 100644 --- a/packages/sdk-router/README.md +++ b/packages/sdk-router/README.md @@ -277,3 +277,43 @@ const status: boolean = await synapseSDK.getBridgeTxStatus( synapseTxId ) ``` + +### SYN bridging + +`bridgeV2` and `intent` support SYN on Ethereum → HyperEVM, HyperEVM → Ethereum, +and Ethereum → HyperCore under module `SYN`. These routes require SYN +on both sides; swaps, HyperCore-origin routes, and legacy V1 APIs are unsupported. + +Initialize providers for Ethereum (1) and HyperEVM (999). `HYPERCORE_CHAIN_ID` +(1337) identifies the non-EVM mainnet destination: do not supply an RPC provider. +Its `SYN_ADDRESS_MAP` entry is the native token ID +`0xf5f05eb8b9aa92365465f06daf5889c9`, with **8-decimal quote units**. Origin +amounts and OFT transaction amounts remain in SYN's 18-decimal EVM units. + +```ts +const quotes = await sdk.bridgeV2({ + fromChainId: 1, + toChainId: HYPERCORE_CHAIN_ID, + fromToken: SYN_ADDRESS_MAP[1], + toToken: SYN_ADDRESS_MAP[HYPERCORE_CHAIN_ID], + fromAmount: '1000000000000000000', // 1 SYN + fromSender: walletAddress, + toRecipient: walletAddress, +}) +``` + +The adapter enforces receive/compose options; the SDK supplies empty extra options. +Quotes include `nativeFee` in the transaction value, round output to OFT shared +decimals, and refund source dust. Approve the quote's `routerAddress` as usual. +Use `sdk.synModuleSet.isHyperCoreAccountActive(recipient)` to check activation +before submitting. Inactive recipients may still bridge: the composer automatically +returns SYN to the same recipient on HyperEVM when the Core transfer fails. The +interface requires acknowledgement of that fallback. Disconnected quotes omit +calldata. Composer availability and capacity are checked at quote time. + +Completion requires finalized LayerZero delivery and, for HyperCore, successful +composition plus either the matching CoreWriter SpotSend or the exact SYN fallback +transfer on HyperEVM. `sdk.synModuleSet.getBridgeDeliveryChainId(destChainId, txHash)` +returns the verified destination (1337 for Core submission, 999 for fallback), or +`undefined` while pending. The existing boolean status reports completion of either +outcome. Core submission does not independently verify the subsequent balance update. diff --git a/packages/sdk-router/package.json b/packages/sdk-router/package.json index f00075b63f..a0df44d8c8 100644 --- a/packages/sdk-router/package.json +++ b/packages/sdk-router/package.json @@ -1,7 +1,7 @@ { "name": "@synapsecns/sdk-router", "description": "An SDK for interacting with the Synapse Protocol", - "version": "0.23.3", + "version": "0.23.4", "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/packages/sdk-router/src/constants/addresses.ts b/packages/sdk-router/src/constants/addresses.ts index 8935fb079b..152ea0f713 100644 --- a/packages/sdk-router/src/constants/addresses.ts +++ b/packages/sdk-router/src/constants/addresses.ts @@ -1,6 +1,7 @@ import { CCTP_SUPPORTED_CHAIN_IDS, GASZIP_SUPPORTED_CHAIN_IDS, + HYPERCORE_CHAIN_ID, INTENTS_SUPPORTED_CHAIN_IDS, RELAY_SUPPORTED_CHAIN_IDS, RFQ_SUPPORTED_CHAIN_IDS, @@ -136,6 +137,19 @@ export const LZ_EID_MAP: Record = { [SupportedChainId.UNICHAIN]: 30320, } +// Mainnet deployments from contracts-adapter on feat/syn-adapter. +export const SYN_ADDRESS_MAP: AddressMap = { + [SupportedChainId.ETH]: '0x0f2D719407FdBeFF09D87557AbB7232601FD9F29', + [SupportedChainId.HYPEREVM]: '0x6Cd6C5fDE1621053eD4BC7C09ADc76612D8C663f', + [HYPERCORE_CHAIN_ID]: '0xf5f05eb8b9aa92365465f06daf5889c9', +} +export const SYN_OFT_ADDRESS_MAP: AddressMap = { + [SupportedChainId.ETH]: '0x50000064684A225e32216c4FC4f17B3206000005', + [SupportedChainId.HYPEREVM]: '0x50000064684A225e32216c4FC4f17B3206000005', +} +export const SYN_COMPOSER_ADDRESS = '0x944753BbC3FA5581b1fc5F3ee6EFFB06299ec1D3' +export const SYN_CORE_TOKEN_INDEX = 873 + /** * SynapseIntentRouter contract address for all chains except ones from SYNAPSE_INTENT_ROUTER_EXCEPTION_MAP. */ diff --git a/packages/sdk-router/src/constants/chainIds.ts b/packages/sdk-router/src/constants/chainIds.ts index 80951b61bc..ea0e1e6978 100644 --- a/packages/sdk-router/src/constants/chainIds.ts +++ b/packages/sdk-router/src/constants/chainIds.ts @@ -1,3 +1,6 @@ +// Mainnet HyperCore routing identifier (Relay/LI.FI), not an EVM chain or RPC network. +export const HYPERCORE_CHAIN_ID = 1337 + export enum SupportedChainId { ETH = 1, OPTIMISM = 10, diff --git a/packages/sdk-router/src/oft/index.ts b/packages/sdk-router/src/oft/index.ts index 6237763f1b..320e779d48 100644 --- a/packages/sdk-router/src/oft/index.ts +++ b/packages/sdk-router/src/oft/index.ts @@ -1,2 +1,4 @@ export * from './usdtModule' export * from './usdtModuleSet' +export * from './synModule' +export * from './synModuleSet' diff --git a/packages/sdk-router/src/oft/synModule.ts b/packages/sdk-router/src/oft/synModule.ts new file mode 100644 index 0000000000..471535760b --- /dev/null +++ b/packages/sdk-router/src/oft/synModule.ts @@ -0,0 +1,58 @@ +import { BigNumber, Contract, providers, utils } from 'ethers' + +import { SYN_COMPOSER_ADDRESS, SYN_CORE_TOKEN_INDEX } from '../constants' +import { OftSendParams, UsdtModule } from './usdtModule' + +export type SynSendParams = OftSendParams & { + minAmount?: BigNumber + hyperCoreRecipient?: string +} + +export const SYN_COMPOSER_ABI = [ + 'function coreUserExists(address) view returns (tuple(bool exists))', + 'function ERC20_ASSET_BRIDGE() view returns (address)', + 'function quoteHyperCoreAmount(uint64,int8,address,uint256) view returns (tuple(uint256 evm,uint64 core,uint64 coreBalanceAssetBridge))', +] + +export class SynModule extends UsdtModule { + public getSendParamTuple( + params: SynSendParams + ): ReturnType { + return [ + params.toEid, + utils.hexZeroPad( + params.hyperCoreRecipient ? SYN_COMPOSER_ADDRESS : params.toRecipient, + 32 + ), + params.amount, + params.minAmount ?? 0, + '0x', // The adapter enforces receive/compose execution options. + params.hyperCoreRecipient + ? utils.defaultAbiCoder.encode( + ['uint256', 'address'], + [0, params.hyperCoreRecipient] + ) + : '0x', + '0x', + ] + } +} + +export const getSynComposer = (provider: providers.Provider): Contract => { + return new Contract(SYN_COMPOSER_ADDRESS, SYN_COMPOSER_ABI, provider) +} + +export const quoteSynHyperCore = async ( + composer: Contract, + amount: BigNumber +): Promise => { + const assetBridge = await composer.ERC20_ASSET_BRIDGE() + const result = await composer.quoteHyperCoreAmount( + SYN_CORE_TOKEN_INDEX, + 10, + assetBridge, + amount + ) + // Destination quotes use native HyperCore units; OFT calldata remains in EVM units. + return result.core +} diff --git a/packages/sdk-router/src/oft/synModuleSet.test.ts b/packages/sdk-router/src/oft/synModuleSet.test.ts new file mode 100644 index 0000000000..e7d041e5d4 --- /dev/null +++ b/packages/sdk-router/src/oft/synModuleSet.test.ts @@ -0,0 +1,327 @@ +import { BigNumber, providers, utils } from 'ethers' + +import { + HYPERCORE_CHAIN_ID, + isChainIdSupported, + SYN_ADDRESS_MAP, + SYN_COMPOSER_ADDRESS, + SYN_OFT_ADDRESS_MAP, + TOKEN_ZAP_V1_ADDRESS_MAP, +} from '../constants' +import { SynapseSDK } from '../sdk' +import { SynapseIntentRouterSet } from '../sir/synapseIntentRouterSet' +import { AMOUNT_NOT_PRESENT, decodeZapData, NoOpEngine } from '../swap' +import { SYN_COMPOSER_ABI } from './synModule' +import { UsdtModule } from './usdtModule' + +const sender = '0x1111111111111111111111111111111111111111' +const recipient = '0x2222222222222222222222222222222222222222' +const unit = BigNumber.from(10).pow(12) +const coreUnit = BigNumber.from(10).pow(10) +const amount = utils.parseEther('1.123456789') +const rounded = amount.div(unit).mul(unit) +const nativeFee = BigNumber.from(12345) +const composerInterface = new utils.Interface(SYN_COMPOSER_ABI) +const oftInterface = UsdtModule.oftInterface + +// Mock the RPC boundary, preserving ABI encoding/decoding, routing, SIR and zap construction. +const setup = () => { + let active = true + let composerActive = true + let capacity = true + const quoteSendParams: any[] = [] + const providersByChain = [1, 999].map((chainId) => { + const provider = new providers.StaticJsonRpcProvider( + 'http://localhost:1', + chainId + ) + jest.spyOn(provider, 'call').mockImplementation(async (tx) => { + if ( + (await tx.to)?.toLowerCase() === + SYN_ADDRESS_MAP[chainId].toLowerCase() && + (await tx.data) === '0x313ce567' + ) { + return utils.defaultAbiCoder.encode(['uint8'], [18]) + } + if ((await tx.to)?.toLowerCase() === SYN_COMPOSER_ADDRESS.toLowerCase()) { + const parsed = composerInterface.parseTransaction({ + data: String(await tx.data), + }) + if (parsed.name === 'coreUserExists') { + const exists = + parsed.args[0].toLowerCase() === SYN_COMPOSER_ADDRESS.toLowerCase() + ? composerActive + : active + return composerInterface.encodeFunctionResult(parsed.name, [[exists]]) + } + if (parsed.name === 'ERC20_ASSET_BRIDGE') { + return composerInterface.encodeFunctionResult(parsed.name, [ + recipient, + ]) + } + if (parsed.name === 'quoteHyperCoreAmount') { + if (!capacity) { + throw new Error('TransferAmtExceedsAssetBridgeBalance') + } + const value = BigNumber.from(parsed.args[3]) + return composerInterface.encodeFunctionResult(parsed.name, [ + [value, value.div(coreUnit), 1000000000], + ]) + } + } + if ( + (await tx.to)?.toLowerCase() === + SYN_OFT_ADDRESS_MAP[chainId].toLowerCase() + ) { + const parsed = oftInterface.parseTransaction({ + data: String(await tx.data), + }) + if (parsed.name === 'quoteOFT') { + const value = BigNumber.from(parsed.args[0][2]).div(unit).mul(unit) + return oftInterface.encodeFunctionResult(parsed.name, [ + [0, utils.parseEther('100')], + [], + [value, value], + ]) + } + if (parsed.name === 'quoteSend') { + quoteSendParams.push(parsed.args[0]) + return oftInterface.encodeFunctionResult(parsed.name, [ + [nativeFee, 0], + ]) + } + } + throw new Error(`Unexpected RPC call ${String(await tx.data)}`) + }) + return provider + }) + const sdk = new SynapseSDK([1, 999], providersByChain) + expect(sdk.allModuleSets).toContain(sdk.synModuleSet) + sdk.allModuleSets = [sdk.synModuleSet] + // Other swap engines cannot improve a SYN->SYN identity route. + const noOp = new NoOpEngine() + ;(sdk.swapEngineSet as any).engines = { [noOp.id]: noOp } + const params = (fromChainId: number, toChainId: number) => ({ + fromChainId, + toChainId, + fromToken: SYN_ADDRESS_MAP[fromChainId], + toToken: SYN_ADDRESS_MAP[toChainId], + fromAmount: amount.toString(), + fromSender: sender, + toRecipient: recipient, + }) + return { + sdk, + params, + quoteSendParams, + setActive: (value: boolean) => { + active = value + }, + setComposerActive: (value: boolean) => { + composerActive = value + }, + setCapacity: (value: boolean) => { + capacity = value + }, + } +} + +afterEach(() => jest.restoreAllMocks()) + +it.each([ + [1, 999], + [999, 1], + [1, HYPERCORE_CHAIN_ID], +])( + 'quotes and builds executable SYN route %i -> %i through public bridgeV2', + async (from, to) => { + const { sdk, params, quoteSendParams } = setup() + const [quote] = await sdk.bridgeV2(params(from, to)) + expect(quote.moduleNames).toEqual(['SYN']) + expect(quote.toChainId).toBe(to) + expect(quote.toToken).toBe(SYN_ADDRESS_MAP[to]) + const destinationAmount = + to === HYPERCORE_CHAIN_ID ? rounded.div(coreUnit) : rounded + expect(quote.expectedToAmount).toBe(destinationAmount.toString()) + expect(quote.minToAmount).toBe(destinationAmount.toString()) + expect(quote.nativeFee).toBe(nativeFee.toString()) + expect(quote.tx!.value).toBe(nativeFee.toString()) + const intent = SynapseIntentRouterSet.sirInterface.parseTransaction({ + data: quote.tx!.data, + }) + expect(intent.name).toBe('completeIntentWithBalanceChecks') + expect(intent.args.zapRecipient).toBe(TOKEN_ZAP_V1_ADDRESS_MAP[from]) + expect(intent.args.amountIn).toEqual(amount) + const zap = decodeZapData(intent.args.steps[0].zapData) + expect(zap.amountPosition).toBe(AMOUNT_NOT_PRESENT) + expect(zap.forwardTo?.toLowerCase()).toBe(sender) + expect(zap.finalToken?.toLowerCase()).toBe( + params(from, to).fromToken.toLowerCase() + ) + const send = oftInterface.parseTransaction({ data: zap.payload! }) + expect(send.args[0]).toEqual(quoteSendParams[0]) + expect(send.args[0][2]).toEqual(amount) + expect(send.args[0][3]).toEqual(rounded) + expect(send.args[1][0]).toEqual(nativeFee) + expect(send.args[2]).toBe(sender) + // Quotes and sends rely on the adapter's enforced SEND / SEND_AND_CALL options. + expect(send.args[0][4]).toBe('0x') + expect(send.args[0][1].toLowerCase()).toBe( + utils + .hexZeroPad( + to === HYPERCORE_CHAIN_ID ? SYN_COMPOSER_ADDRESS : recipient, + 32 + ) + .toLowerCase() + ) + expect(send.args[0][5]).toBe( + to === HYPERCORE_CHAIN_ID + ? utils.defaultAbiCoder.encode(['uint256', 'address'], [0, recipient]) + : '0x' + ) + } +) + +it('returns estimates without calldata when disconnected, including HyperCore metadata without a virtual RPC', async () => { + const { sdk, params } = setup() + const [quote] = await sdk.bridgeV2({ + ...params(1, HYPERCORE_CHAIN_ID), + fromSender: undefined, + toRecipient: undefined, + }) + expect(quote.tx).toBeUndefined() + expect(quote.expectedToAmount).toBe(rounded.div(coreUnit).toString()) + expect(quote.toToken).toBe('0xf5f05eb8b9aa92365465f06daf5889c9') + expect(isChainIdSupported(HYPERCORE_CHAIN_ID)).toBe(false) + expect(sdk.providers[HYPERCORE_CHAIN_ID]).toBeUndefined() + await expect( + sdk.tokenMetadataFetcher.getTokenDecimals( + HYPERCORE_CHAIN_ID, + SYN_ADDRESS_MAP[HYPERCORE_CHAIN_ID] + ) + ).resolves.toBe(8) +}) + +it('returns native HyperCore token units and decimals through public intent without changing OFT calldata', async () => { + const { sdk, params } = setup() + const [quote] = await sdk.intent(params(1, HYPERCORE_CHAIN_ID)) + expect(quote.toChainId).toBe(1337) + expect(quote.toToken).toBe('0xf5f05eb8b9aa92365465f06daf5889c9') + expect(quote.fromAmount).toBe(amount.toString()) + expect(quote.expectedToAmount).toBe('112345600') + expect(quote.minToAmount).toBe('112345600') + expect(quote.steps).toHaveLength(1) + const [step] = quote.steps + expect(step.fromTokenDecimals).toBe(18) + expect(step.toTokenDecimals).toBe(8) + expect(step.toToken).toBe(quote.toToken) + expect(step.expectedToAmount).toBe(quote.expectedToAmount) + expect(step.minToAmount).toBe(quote.minToAmount) + const intent = SynapseIntentRouterSet.sirInterface.parseTransaction({ + data: step.tx!.data, + }) + expect(intent.args.amountIn).toEqual(amount) + const zap = decodeZapData(intent.args.steps[0].zapData) + const send = oftInterface.parseTransaction({ data: zap.payload! }) + expect(send.args[0][2]).toEqual(amount) + expect(send.args[0][3]).toEqual(rounded) + expect(send.args[0][4]).toBe('0x') + expect(sdk.providers[HYPERCORE_CHAIN_ID]).toBeUndefined() +}) + +it('rejects the former 998 destination and the linked EVM token as a Core token ID', async () => { + const { sdk, params } = setup() + await expect( + sdk.bridgeV2({ ...params(1, HYPERCORE_CHAIN_ID), toChainId: 998 }) + ).resolves.toEqual([]) + await expect( + sdk.bridgeV2({ + ...params(1, HYPERCORE_CHAIN_ID), + toToken: SYN_ADDRESS_MAP[999], + }) + ).resolves.toEqual([]) +}) + +it('exposes recipient activation while allowing a quote with the automatic HyperEVM fallback', async () => { + const { sdk, params, setActive, quoteSendParams } = setup() + await expect( + sdk.synModuleSet.isHyperCoreAccountActive(recipient) + ).resolves.toBe(true) + setActive(false) + await expect( + sdk.synModuleSet.isHyperCoreAccountActive(recipient) + ).resolves.toBe(false) + const [quote] = await sdk.bridgeV2(params(1, HYPERCORE_CHAIN_ID)) + expect(quote.tx).toBeDefined() + expect(quoteSendParams[0][5]).toBe( + utils.defaultAbiCoder.encode(['uint256', 'address'], [0, recipient]) + ) +}) + +it('rejects unavailable activation checks and an inactive composer', async () => { + const { sdk, params, setComposerActive } = setup() + ;( + sdk.synModuleSet.composer!.provider.call as jest.Mock + ).mockRejectedValueOnce(new Error('RPC unavailable')) + await expect( + sdk.synModuleSet.isHyperCoreAccountActive(recipient) + ).rejects.toThrow('RPC unavailable') + setComposerActive(false) + await expect(sdk.bridgeV2(params(1, HYPERCORE_CHAIN_ID))).rejects.toThrow( + 'temporarily unavailable' + ) + sdk.synModuleSet.composer = undefined + await expect( + sdk.synModuleSet.isHyperCoreAccountActive(recipient) + ).rejects.toThrow('HyperEVM provider is required') +}) + +it('omits zero-after-dust amounts and capacity failures', async () => { + const { sdk, params, setCapacity } = setup() + await expect( + sdk.bridgeV2({ ...params(1, 999), fromAmount: unit.sub(1).toString() }) + ).resolves.toEqual([]) + setCapacity(false) + await expect(sdk.bridgeV2(params(1, HYPERCORE_CHAIN_ID))).resolves.toEqual([]) +}) + +it('does not request a dust refund when the complete input is bridged', async () => { + const { sdk, params } = setup() + const [quote] = await sdk.bridgeV2({ + ...params(1, 999), + fromAmount: rounded.toString(), + }) + const intent = SynapseIntentRouterSet.sirInterface.parseTransaction({ + data: quote.tx!.data, + }) + const zap = decodeZapData(intent.args.steps[0].zapData) + expect(zap.forwardTo).toBe(utils.getAddress('0x' + '0'.repeat(40))) +}) + +it.each([ + [999, HYPERCORE_CHAIN_ID], + [HYPERCORE_CHAIN_ID, 999], + [HYPERCORE_CHAIN_ID, 1], +])('excludes unsupported path %i -> %i', async (from, to) => { + const { sdk, params } = setup() + await expect(sdk.bridgeV2(params(from, to))).resolves.toEqual([]) +}) + +it('does not expose non-SYN tokens, origin swaps, or legacy V1 quotes', async () => { + const { sdk, params } = setup() + await expect( + sdk.bridgeV2({ ...params(1, 999), fromToken: sender }) + ).resolves.toEqual([]) + await expect( + sdk.bridgeV2({ ...params(1, 999), toToken: recipient }) + ).resolves.toEqual([]) + await expect( + sdk.allBridgeQuotes( + 1, + 999, + SYN_ADDRESS_MAP[1], + SYN_ADDRESS_MAP[999], + amount + ) + ).resolves.toEqual([]) +}) diff --git a/packages/sdk-router/src/oft/synModuleSet.ts b/packages/sdk-router/src/oft/synModuleSet.ts new file mode 100644 index 0000000000..c91d30cd5b --- /dev/null +++ b/packages/sdk-router/src/oft/synModuleSet.ts @@ -0,0 +1,240 @@ +import { Zero } from '@ethersproject/constants' +import { BigNumber, Contract } from 'ethers' + +import { + HYPERCORE_CHAIN_ID, + LZ_EID_MAP, + SupportedChainId, + SYN_ADDRESS_MAP, + SYN_COMPOSER_ADDRESS, + SYN_OFT_ADDRESS_MAP, +} from '../constants' +import { + BridgeRoute, + BridgeRouteV2, + BridgeTokenCandidate, + FeeConfig, + GetBridgeRouteV2Parameters, + GetBridgeTokenCandidatesParameters, + Query, + SynapseModuleSet, +} from '../module' +import { ChainProvider } from '../router' +import { + AMOUNT_NOT_PRESENT, + encodeZapData, + USER_SIMULATED_ADDRESS, +} from '../swap' +import { isSameAddress, logger } from '../utils' +import { + getSynComposer, + quoteSynHyperCore, + SynModule, + SynSendParams, +} from './synModule' +import { getSynBridgeDeliveryChainId } from './synStatus' + +export class SynModuleSet extends SynapseModuleSet { + public readonly moduleName = 'SYN' + public readonly allEvents = [] + public readonly isBridgeV2Supported = true + public modules: { [chainId: number]: SynModule } = {} + public composer?: Contract + + constructor(chains: ChainProvider[]) { + super() + chains.forEach(({ chainId, provider }) => { + const address = SYN_OFT_ADDRESS_MAP[chainId] + if (address) { + this.modules[chainId] = new SynModule(chainId, provider, address) + } + if (chainId === SupportedChainId.HYPEREVM) { + this.composer = getSynComposer(provider) + } + }) + } + + public getModule(chainId: number): SynModule | undefined { + // HyperCore uses the HyperEVM adapter/provider, never its own EVM RPC. + return this.modules[ + chainId === HYPERCORE_CHAIN_ID ? SupportedChainId.HYPEREVM : chainId + ] + } + + public async getBridgeTxStatus( + destChainId: number, + txHash: string + ): Promise { + return ( + (await this.getBridgeDeliveryChainId(destChainId, txHash)) !== undefined + ) + } + + public getBridgeDeliveryChainId( + destChainId: number, + txHash: string + ): Promise { + return getSynBridgeDeliveryChainId( + destChainId, + txHash, + this.modules[SupportedChainId.HYPEREVM]?.oftContract.provider + ) + } + + public async isHyperCoreAccountActive(recipient: string): Promise { + if (!this.composer) { + throw new Error( + 'HyperEVM provider is required to check HyperCore activation.' + ) + } + const account = await this.composer.coreUserExists(recipient) + return account.exists + } + + public getEstimatedTime(fromChainId: number): number { + // Wiring requires 64 Ethereum blocks or 100 HyperEVM blocks, plus execution. + return fromChainId === SupportedChainId.ETH ? 840 : 160 + } + + public async getGasDropAmount(): Promise { + return Zero + } + + public async getBridgeTokenCandidates({ + fromChainId, + toChainId, + fromToken, + toToken, + }: GetBridgeTokenCandidatesParameters): Promise { + const supported = + (fromChainId === SupportedChainId.ETH && + [SupportedChainId.HYPEREVM, HYPERCORE_CHAIN_ID].includes(toChainId)) || + (fromChainId === SupportedChainId.HYPEREVM && + toChainId === SupportedChainId.ETH) + const originToken = SYN_ADDRESS_MAP[fromChainId] + const destToken = SYN_ADDRESS_MAP[toChainId] + // SYN-only: origin swaps and all other pathways are outside this integration. + if ( + !supported || + !this.getModule(fromChainId) || + !this.getModule(toChainId) || + !isSameAddress(fromToken, originToken) || + (toToken && !isSameAddress(toToken, destToken)) + ) { + return [] + } + return [ + { + originChainId: fromChainId, + destChainId: toChainId, + originToken, + destToken, + }, + ] + } + + public async getBridgeRouteV2( + params: GetBridgeRouteV2Parameters + ): Promise { + if (!this.validateBridgeRouteV2Params(params)) { + return undefined + } + const { bridgeToken, originSwapRoute, fromSender, toRecipient } = params + const candidates = await this.getBridgeTokenCandidates({ + fromChainId: bridgeToken.originChainId, + toChainId: bridgeToken.destChainId, + fromToken: bridgeToken.originToken, + toToken: params.toToken, + }) + if ( + !candidates.length || + originSwapRoute.steps.length > 0 || + !originSwapRoute.minToAmount.eq(originSwapRoute.expectedToAmount) + ) { + return undefined + } + const hyperCore = bridgeToken.destChainId === HYPERCORE_CHAIN_ID + const recipient = toRecipient || USER_SIMULATED_ADDRESS + if (hyperCore) { + if (!(await this.isHyperCoreAccountActive(SYN_COMPOSER_ADDRESS))) { + throw new Error('SYN bridging to HyperCore is temporarily unavailable.') + } + } + const module = this.modules[bridgeToken.originChainId] + const sendParams: SynSendParams = { + toEid: + LZ_EID_MAP[ + hyperCore ? SupportedChainId.HYPEREVM : bridgeToken.destChainId + ], + toRecipient: recipient, + amount: originSwapRoute.expectedToAmount, + fromSender: fromSender || USER_SIMULATED_ADDRESS, + hyperCoreRecipient: hyperCore ? recipient : undefined, + } + try { + const expectedOftAmount = await module.getDestinationQuote(sendParams) + if (expectedOftAmount.isZero()) { + return undefined + } + const expectedToAmount = hyperCore + ? await quoteSynHyperCore(this.composer!, expectedOftAmount) + : expectedOftAmount + if (expectedToAmount.isZero()) { + return undefined + } + sendParams.minAmount = expectedOftAmount + const nativeFee = await module.getNativeFee(sendParams) + const zapData = + fromSender && toRecipient + ? encodeZapData({ + target: module.address, + payload: module.populateOftSend(sendParams, nativeFee).data, + // SYN routes have no origin swap: keep the quoted amount fixed, even if + // TokenZap already holds tokens. Refund OFT dust to avoid SIR__UnspentFunds. + amountPosition: AMOUNT_NOT_PRESENT, + ...(originSwapRoute.expectedToAmount.gt(expectedOftAmount) + ? { + finalToken: bridgeToken.originToken, + forwardTo: fromSender, + } + : {}), + }) + : undefined + return { + bridgeToken, + toToken: bridgeToken.destToken, + expectedToAmount, + minToAmount: expectedToAmount, + nativeFee, + zapData, + } + } catch (error) { + logger.error( + `Failed to quote SYN for ${bridgeToken.originChainId} -> ${bridgeToken.destChainId}: ${error}` + ) + return undefined + } + } + + public async getBridgeRoutes(): Promise { + return [] + } + public async getFeeData(): Promise<{ + feeAmount: BigNumber + feeConfig: FeeConfig + }> { + return { + feeAmount: Zero, + feeConfig: { bridgeFee: 0, minFee: Zero, maxFee: Zero }, + } + } + public getDefaultPeriods(): { originPeriod: number; destPeriod: number } { + return { originPeriod: 0, destPeriod: 0 } + } + public applySlippage( + originQuery: Query, + destQuery: Query + ): { originQuery: Query; destQuery: Query } { + return { originQuery, destQuery } + } +} diff --git a/packages/sdk-router/src/oft/synStatus.test.ts b/packages/sdk-router/src/oft/synStatus.test.ts new file mode 100644 index 0000000000..784835ac4e --- /dev/null +++ b/packages/sdk-router/src/oft/synStatus.test.ts @@ -0,0 +1,255 @@ +import { Interface } from '@ethersproject/abi' +import { Provider } from '@ethersproject/abstract-provider' +import { utils } from 'ethers' + +import { + HYPERCORE_CHAIN_ID, + SYN_ADDRESS_MAP, + SYN_COMPOSER_ADDRESS, + SupportedChainId, +} from '../constants' +import { getSynBridgeDeliveryChainId, getSynBridgeStatus } from './synStatus' + +const sourceTx = '0x' + '11'.repeat(32) +const composeTx = '0x' + '22'.repeat(32) +const recipient = '0x1234567890123456789012345678901234567890' +const coreWriter = '0x3333333333333333333333333333333333333333' +const coreWriterInterface = new Interface([ + 'event RawAction(address indexed user, bytes data)', +]) +const tokenInterface = new Interface([ + 'event Transfer(address indexed from, address indexed to, uint256 value)', +]) + +const fallbackLog = ( + overrides: { + token?: string + from?: string + to?: string + amount?: string + } = {} +) => ({ + address: overrides.token ?? SYN_ADDRESS_MAP[SupportedChainId.HYPEREVM], + ...tokenInterface.encodeEventLog(tokenInterface.getEvent('Transfer'), [ + overrides.from ?? SYN_COMPOSER_ADDRESS, + overrides.to ?? recipient, + overrides.amount ?? utils.parseEther('1').toString(), + ]), +}) + +const payload = utils.solidityPack( + ['bytes32', 'uint64', 'bytes32', 'bytes'], + [ + utils.hexZeroPad(SYN_COMPOSER_ADDRESS, 32), + 1000000, + utils.hexZeroPad(recipient, 32), + utils.defaultAbiCoder.encode(['uint256', 'address'], [0, recipient]), + ] +) + +const coreMessage = (composeStatus = 'SUCCEEDED') => ({ + pathway: { dstEid: 30367 }, + status: { name: 'DELIVERED' }, + source: { tx: { payload } }, + destination: { + status: 'SUCCEEDED', + lzCompose: { status: composeStatus, txs: [{ txHash: composeTx }] }, + }, +}) + +const rawActionLog = (amount = 100000000) => { + const action = utils.hexConcat([ + '0x01000006', + utils.defaultAbiCoder.encode( + ['address', 'uint64', 'uint64'], + [recipient, 873, amount] + ), + ]) + return { + address: coreWriter, + ...coreWriterInterface.encodeEventLog( + coreWriterInterface.getEvent('RawAction'), + [SYN_COMPOSER_ADDRESS, action] + ), + } +} + +describe('SYN bridge status', () => { + let provider: Provider + + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: [coreMessage()] }), + }) as any + provider = { + getTransactionReceipt: jest.fn().mockResolvedValue({ + status: 1, + logs: [rawActionLog()], + }), + } as unknown as Provider + }) + + afterEach(() => jest.restoreAllMocks()) + + it('waits for a finalized ordinary OFT receive', async () => { + expect( + await getSynBridgeDeliveryChainId(SupportedChainId.HYPEREVM, sourceTx) + ).toBe(SupportedChainId.HYPEREVM) + expect(await getSynBridgeStatus(SupportedChainId.HYPEREVM, sourceTx)).toBe( + true + ) + ;(global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [{ ...coreMessage(), status: { name: 'CONFIRMING' } }], + }), + }) + expect(await getSynBridgeStatus(SupportedChainId.HYPEREVM, sourceTx)).toBe( + false + ) + }) + + it('requires a finalized compose for the HyperCore route', async () => { + ;(global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: [coreMessage('WAITING')] }), + }) + expect( + await getSynBridgeDeliveryChainId(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBeUndefined() + expect(provider.getTransactionReceipt).not.toHaveBeenCalled() + }) + + it('requires a HyperEVM provider to inspect the compose receipt', async () => { + expect(await getSynBridgeStatus(HYPERCORE_CHAIN_ID, sourceTx)).toBe(false) + }) + + it('recognizes the matching CoreWriter SpotSend submission', async () => { + expect( + await getSynBridgeDeliveryChainId(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(HYPERCORE_CHAIN_ID) + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(provider.getTransactionReceipt).toHaveBeenCalledTimes(1) + expect( + await getSynBridgeStatus(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(true) + expect(provider.getTransactionReceipt).toHaveBeenCalledWith(composeTx) + }) + + it('does not complete a compose with no verified settlement', async () => { + ;(provider.getTransactionReceipt as jest.Mock).mockResolvedValueOnce({ + status: 1, + logs: [], + }) + expect( + await getSynBridgeStatus(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(false) + }) + + it('completes the exact automatic SYN fallback on HyperEVM', async () => { + ;(provider.getTransactionReceipt as jest.Mock).mockResolvedValue({ + status: 1, + logs: [fallbackLog()], + }) + expect( + await getSynBridgeDeliveryChainId(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(SupportedChainId.HYPEREVM) + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(provider.getTransactionReceipt).toHaveBeenCalledTimes(1) + expect( + await getSynBridgeStatus(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(true) + }) + + it('ignores malformed logs before a matching fallback in the same receipt', async () => { + ;(provider.getTransactionReceipt as jest.Mock).mockResolvedValueOnce({ + status: 1, + logs: [ + { ...fallbackLog(), data: '0x' }, + { ...rawActionLog(), data: '0x' }, + fallbackLog(), + ], + }) + expect( + await getSynBridgeDeliveryChainId(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(SupportedChainId.HYPEREVM) + }) + + it.each([ + { token: recipient }, + { from: recipient }, + { to: SYN_COMPOSER_ADDRESS }, + { amount: '999999999999999999' }, + ])('rejects an unrelated or partial fallback %j', async (overrides) => { + ;(provider.getTransactionReceipt as jest.Mock).mockResolvedValueOnce({ + status: 1, + logs: [fallbackLog(overrides)], + }) + expect( + await getSynBridgeStatus(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(false) + }) + + it('does not complete a failed compose receipt even with a matching transfer', async () => { + ;(provider.getTransactionReceipt as jest.Mock).mockResolvedValueOnce({ + status: 0, + logs: [fallbackLog()], + }) + expect( + await getSynBridgeStatus(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(false) + }) + + it.each([ + utils.hexDataSlice(payload, 0, 135), + utils.hexConcat([ + utils.hexZeroPad(recipient, 32), + utils.hexDataSlice(payload, 32), + ]), + utils.hexConcat([ + utils.hexDataSlice(payload, 0, 32), + '0x0000000000000000', + utils.hexDataSlice(payload, 40), + ]), + utils.hexConcat([ + utils.hexDataSlice(payload, 0, 72), + utils.defaultAbiCoder.encode(['uint256', 'address'], [1, recipient]), + ]), + utils.hexConcat([ + utils.hexDataSlice(payload, 0, 104), + '0x01', + utils.hexDataSlice(payload, 105), + ]), + ])( + 'rejects an invalid source payload before accepting a fallback', + async (invalidPayload) => { + ;(global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { ...coreMessage(), source: { tx: { payload: invalidPayload } } }, + ], + }), + }) + ;(provider.getTransactionReceipt as jest.Mock).mockResolvedValueOnce({ + status: 1, + logs: [fallbackLog()], + }) + expect( + await getSynBridgeStatus(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(false) + expect(provider.getTransactionReceipt).not.toHaveBeenCalled() + } + ) + + it('rejects an unrelated CoreWriter action in the same receipt', async () => { + ;(provider.getTransactionReceipt as jest.Mock).mockResolvedValueOnce({ + status: 1, + logs: [rawActionLog(999)], + }) + expect( + await getSynBridgeStatus(HYPERCORE_CHAIN_ID, sourceTx, provider) + ).toBe(false) + }) +}) diff --git a/packages/sdk-router/src/oft/synStatus.ts b/packages/sdk-router/src/oft/synStatus.ts new file mode 100644 index 0000000000..fee7199d28 --- /dev/null +++ b/packages/sdk-router/src/oft/synStatus.ts @@ -0,0 +1,229 @@ +import { Interface } from '@ethersproject/abi' +import { Log, Provider } from '@ethersproject/abstract-provider' +import { BigNumber, utils } from 'ethers' + +import { + HYPERCORE_CHAIN_ID, + LZ_EID_MAP, + SYN_ADDRESS_MAP, + SYN_COMPOSER_ADDRESS, + SYN_CORE_TOKEN_INDEX, + SupportedChainId, +} from '../constants' +import { getWithTimeout } from '../utils' + +const LZ_API_URL = 'https://scan.layerzero-api.com/v1' +const LZ_API_TIMEOUT = 5000 +const CORE_WRITER = '0x3333333333333333333333333333333333333333' +const SPOT_SEND_HEADER = '0x01000006' +const coreWriterInterface = new Interface([ + 'event RawAction(address indexed user, bytes data)', +]) +const tokenInterface = new Interface([ + 'event Transfer(address indexed from, address indexed to, uint256 value)', +]) + +interface LzMessage { + pathway?: { dstEid?: number } + status?: { name?: string } + source?: { tx?: { payload?: string } } + destination?: { + status?: string + lzCompose?: { status?: string; txs?: { txHash?: string }[] } + } +} + +interface LzResponse { + data?: LzMessage[] +} + +/** + * Checks finalized OFT receipt. For HyperCore, verifies the matching SpotSend + * submission or automatic SYN fallback to the same recipient on HyperEVM. + */ +export const getSynBridgeDeliveryChainId = async ( + destChainId: number, + txHash: string, + hyperEvmProvider?: Provider +): Promise => { + const isCore = destChainId === HYPERCORE_CHAIN_ID + const dstEid = LZ_EID_MAP[isCore ? SupportedChainId.HYPEREVM : destChainId] + if (!dstEid) { + return undefined + } + + const response = await getWithTimeout( + 'LZ API', + `${LZ_API_URL}/messages/tx/${txHash}`, + LZ_API_TIMEOUT + ) + if (!response) { + return undefined + } + + try { + const messages: LzResponse = await response.json() + for (const message of messages.data ?? []) { + if ( + message.pathway?.dstEid !== dstEid || + message.status?.name !== 'DELIVERED' || + message.destination?.status !== 'SUCCEEDED' + ) { + continue + } + if (!isCore) { + return destChainId + } + if ( + hyperEvmProvider && + message.destination.lzCompose?.status === 'SUCCEEDED' + ) { + const deliveryChainId = await getComposeDeliveryChainId( + message, + hyperEvmProvider + ) + if (deliveryChainId !== undefined) { + return deliveryChainId + } + } + } + } catch { + // A malformed API response or unavailable destination RPC is not delivery. + } + return undefined +} + +export const getSynBridgeStatus = async ( + destChainId: number, + txHash: string, + hyperEvmProvider?: Provider +): Promise => + (await getSynBridgeDeliveryChainId(destChainId, txHash, hyperEvmProvider)) !== + undefined + +/** A successful lzCompose must submit the Core action or return the full amount on HyperEVM. */ +const getComposeDeliveryChainId = async ( + message: LzMessage, + provider: Provider +): Promise => { + const details = getComposePayloadDetails(message.source?.tx?.payload) + if (!details) { + return undefined + } + const { recipient, amountSD } = details + // SYN has 6 shared decimals and 8 HyperCore weiDecimals. + const expectedCoreAmount = amountSD.mul(100) + const expectedEvmAmount = amountSD.mul(BigNumber.from(10).pow(12)) + + for (const { txHash } of message.destination?.lzCompose?.txs ?? []) { + if (!txHash) { + continue + } + const receipt = await provider.getTransactionReceipt(txHash) + if (receipt?.status !== 1) { + continue + } + for (const log of receipt.logs) { + const deliveryChainId = getComposeLogDeliveryChainId( + log, + recipient, + expectedCoreAmount, + expectedEvmAmount + ) + if (deliveryChainId !== undefined) { + return deliveryChainId + } + } + } + return undefined +} + +const getComposePayloadDetails = ( + payload?: string +): { recipient: string; amountSD: BigNumber } | undefined => { + if ( + !payload || + !utils.isHexString(payload) || + utils.hexDataLength(payload) !== 136 + ) { + return undefined + } + + const composerWord = utils.hexDataSlice(payload, 0, 32) + if ( + composerWord.toLowerCase() !== + utils.hexZeroPad(SYN_COMPOSER_ADDRESS, 32).toLowerCase() + ) { + return undefined + } + // OFT payload: bytes32 composer, uint64 amountSD, bytes32 sender, + // abi.encode(uint256 minMsgValue, address Core recipient). + const [minMsgValue, recipient] = utils.defaultAbiCoder.decode( + ['uint256', 'address'], + utils.hexDataSlice(payload, 72) + ) + if (!BigNumber.from(minMsgValue).isZero()) { + return undefined + } + const amountSD = BigNumber.from(utils.hexDataSlice(payload, 32, 40)) + if (amountSD.isZero()) { + return undefined + } + return { recipient, amountSD } +} + +const getComposeLogDeliveryChainId = ( + log: Log, + recipient: string, + expectedCoreAmount: BigNumber, + expectedEvmAmount: BigNumber +): number | undefined => { + try { + if ( + log.address.toLowerCase() === + SYN_ADDRESS_MAP[SupportedChainId.HYPEREVM].toLowerCase() + ) { + const transfer = tokenInterface.parseLog(log) + if ( + transfer.args.from.toLowerCase() === + SYN_COMPOSER_ADDRESS.toLowerCase() && + transfer.args.to.toLowerCase() === recipient.toLowerCase() && + BigNumber.from(transfer.args.value).eq(expectedEvmAmount) + ) { + return SupportedChainId.HYPEREVM + } + return undefined + } + if (log.address.toLowerCase() !== CORE_WRITER) { + return undefined + } + const parsed = coreWriterInterface.parseLog(log) + if ( + parsed.name !== 'RawAction' || + parsed.args.user.toLowerCase() !== SYN_COMPOSER_ADDRESS.toLowerCase() + ) { + return undefined + } + const action: string = parsed.args.data + if ( + utils.hexDataLength(action) !== 100 || + utils.hexDataSlice(action, 0, 4) !== SPOT_SEND_HEADER + ) { + return undefined + } + const [to, index, amount] = utils.defaultAbiCoder.decode( + ['address', 'uint64', 'uint64'], + utils.hexDataSlice(action, 4) + ) + if ( + to.toLowerCase() === recipient.toLowerCase() && + BigNumber.from(index).eq(SYN_CORE_TOKEN_INDEX) && + BigNumber.from(amount).eq(expectedCoreAmount) + ) { + return HYPERCORE_CHAIN_ID + } + } catch { + // Ignore unrelated or malformed logs in the compose receipt. + } + return undefined +} diff --git a/packages/sdk-router/src/operations/bridge.ts b/packages/sdk-router/src/operations/bridge.ts index 34c66cc795..a6ba18778d 100644 --- a/packages/sdk-router/src/operations/bridge.ts +++ b/packages/sdk-router/src/operations/bridge.ts @@ -1,7 +1,11 @@ import { BigNumber, BigNumberish, PopulatedTransaction, utils } from 'ethers' import invariant from 'tiny-invariant' -import { areIntentsSupported, isChainIdSupported } from '../constants/chainIds' +import { + areIntentsSupported, + HYPERCORE_CHAIN_ID, + isChainIdSupported, +} from '../constants/chainIds' import { SynapseModuleSet, Query, @@ -148,6 +152,7 @@ async function _collectV2Quotes( bridgeV2Modules: SynapseModuleSet[] ): Promise { const isBridgeV2DestinationSupported = + params.toChainId === HYPERCORE_CHAIN_ID || isChainIdSupported(params.toChainId) || areIntentsSupported(params.toChainId) // The origin must support the shared intent path, while destinations may be diff --git a/packages/sdk-router/src/sba/originRoutes.test.ts b/packages/sdk-router/src/sba/originRoutes.test.ts new file mode 100644 index 0000000000..ad2766ef6c --- /dev/null +++ b/packages/sdk-router/src/sba/originRoutes.test.ts @@ -0,0 +1,95 @@ +import { Provider } from '@ethersproject/abstract-provider' +import { BigNumber, utils } from 'ethers' +import { mock } from 'jest-mock-extended' + +import { + SupportedChainId, + SYNAPSE_INTENT_ROUTER_ADDRESS_MAP, +} from '../constants' +import { SynapseSDK } from '../sdk' + +const ETH_NUSD = '0x1B84765dE8B7566e4cEAF4D0fD3c5aF52D3DdE4F' +const AMOUNT = utils.parseEther('100').toString() + +describe('SBA origin routes omitted from the original token snapshot', () => { + beforeEach(() => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ chains: [] }), + } as Response) + }) + + afterEach(() => jest.restoreAllMocks()) + + it.each<[SupportedChainId, string, string]>([ + [ + SupportedChainId.AURORA, + '0x07379565cD8B0CaE7c60Dc78e7f601b34AF2A21c', + ETH_NUSD, + ], + [ + SupportedChainId.CRONOS, + '0x396c9c192dd323995346632581BEF92a31AC623b', + ETH_NUSD, + ], + ])( + 'quotes chain %s to Ethereum with and without a wallet, preserving destination restrictions', + async (chainId, fromToken, toToken) => { + const sdk = new SynapseSDK( + [chainId, SupportedChainId.ETH], + [mock(), mock()] + ) + sdk.allModuleSets = [sdk.synapseBridgeAdapterModuleSet] + const module = sdk.synapseBridgeAdapterModuleSet.modules[chainId] + jest.spyOn(module, 'getNativeFee').mockResolvedValue(BigNumber.from(77)) + jest.spyOn(module, 'getEstimatedTime').mockResolvedValue(120) + + const quotes = await sdk.bridgeV2({ + fromChainId: chainId, + toChainId: SupportedChainId.ETH, + fromToken, + toToken, + fromAmount: AMOUNT, + }) + + expect(quotes).toHaveLength(1) + expect(quotes[0]).toMatchObject({ + expectedToAmount: AMOUNT, + minToAmount: AMOUNT, + toToken, + nativeFee: '77', + estimatedTime: 120, + moduleNames: ['SynapseBridge'], + routerAddress: SYNAPSE_INTENT_ROUTER_ADDRESS_MAP[chainId], + }) + expect(module.getNativeFee).toHaveBeenCalledWith(30101) + + const sender = '0x00000000000000000000000000000000000000a1' + const connectedQuotes = await sdk.bridgeV2({ + fromChainId: chainId, + toChainId: SupportedChainId.ETH, + fromToken, + toToken, + fromAmount: AMOUNT, + fromSender: sender, + toRecipient: sender, + }) + expect(connectedQuotes).toHaveLength(1) + expect(connectedQuotes[0].tx).toMatchObject({ + to: SYNAPSE_INTENT_ROUTER_ADDRESS_MAP[chainId], + value: '77', + }) + expect(connectedQuotes[0].tx?.data).toMatch(/^0x[0-9a-f]+$/i) + + await expect( + sdk.bridgeV2({ + fromChainId: SupportedChainId.ETH, + toChainId: chainId, + fromToken: toToken, + toToken: fromToken, + fromAmount: AMOUNT, + }) + ).resolves.toEqual([]) + } + ) +}) diff --git a/packages/sdk-router/src/sba/supportedTokens.test.ts b/packages/sdk-router/src/sba/supportedTokens.test.ts new file mode 100644 index 0000000000..6a31cb427c --- /dev/null +++ b/packages/sdk-router/src/sba/supportedTokens.test.ts @@ -0,0 +1,115 @@ +import fs from 'fs' +import path from 'path' + +import { SupportedChainId } from '../constants' +import { ETH_NATIVE_TOKEN_ADDRESS } from '../utils' +import { getSbaRemoteToken, getSbaSupportedTokens } from './supportedTokens' + +type AdapterTokenConfig = Record< + string, + Record +> + +const adapterTokens = JSON.parse( + fs.readFileSync( + path.resolve( + __dirname, + '../../../contracts-adapter/configs/global/tokens.json' + ), + 'utf8' + ) +) as AdapterTokenConfig + +const chainIds: Record = { + arbitrum: SupportedChainId.ARBITRUM, + aurora: SupportedChainId.AURORA, + avalanche: SupportedChainId.AVALANCHE, + base: SupportedChainId.BASE, + blast: SupportedChainId.BLAST, + bnb: SupportedChainId.BSC, + canto: SupportedChainId.CANTO, + cronos: SupportedChainId.CRONOS, + dfk: SupportedChainId.DFK, + ethereum: SupportedChainId.ETH, + fantom: SupportedChainId.FANTOM, + harmony: SupportedChainId.HARMONY, + kaia: SupportedChainId.KLAYTN, + metis: SupportedChainId.METIS, + moonbeam: SupportedChainId.MOONBEAM, + moonriver: SupportedChainId.MOONRIVER, + optimism: SupportedChainId.OPTIMISM, + polygon: SupportedChainId.POLYGON, +} + +// These underlying tokens are the chain's wrapped native asset. The lookup +// exposes the native address when they are the destination. +const wrappedNativeChains: Record = { + AVAX: 'avalanche', + FTM: 'fantom', + JEWEL: 'dfk', + KLAY: 'kaia', + MATIC: 'polygon', + Metis: 'metis', + nETH: 'ethereum', +} + +describe('SBA supported token snapshot', () => { + it('matches adapter tokens on supported chains, excluding GMX', () => { + const expectedByPair = new Map() + + Object.entries(adapterTokens).forEach(([tokenId, chains]) => { + // Moonbeam and Moonriver remain outside the supported token snapshot. + const chainEntries = Object.entries(chains).filter( + ([chain]) => chain !== 'moonbeam' && chain !== 'moonriver' + ) + if (tokenId === 'GMX' || chainEntries.length < 2) { + return + } + + chainEntries.forEach(([originChain, origin]) => { + chainEntries.forEach(([destChain, dest]) => { + if (originChain === destChain) { + return + } + + const originChainId = chainIds[originChain] + const destChainId = chainIds[destChain] + expect(originChainId).toBeDefined() + expect(destChainId).toBeDefined() + + const effectiveDestToken = + wrappedNativeChains[tokenId] === destChain + ? ETH_NATIVE_TOKEN_ADDRESS + : dest.tokenAddress + const pairKey = `${originChainId}:${destChainId}` + const expected = expectedByPair.get(pairKey) ?? [] + expected.push( + `${origin.tokenAddress.toLowerCase()}:${effectiveDestToken.toLowerCase()}` + ) + expectedByPair.set(pairKey, expected) + + expect( + getSbaRemoteToken( + originChainId, + origin.tokenAddress, + destChainId + )?.toLowerCase() + ).toBe(effectiveDestToken.toLowerCase()) + }) + }) + }) + + Object.values(chainIds).forEach((originChainId) => { + Object.values(chainIds).forEach((destChainId) => { + const pairKey = `${originChainId}:${destChainId}` + const actual = getSbaSupportedTokens(originChainId, destChainId) + .map( + ({ originToken, destToken }) => + `${originToken.toLowerCase()}:${destToken.toLowerCase()}` + ) + .sort() + expect(actual).toEqual((expectedByPair.get(pairKey) ?? []).sort()) + }) + }) + }) +}) diff --git a/packages/sdk-router/src/sba/supportedTokens.ts b/packages/sdk-router/src/sba/supportedTokens.ts index 336bc3d922..cb10f3d7ad 100644 --- a/packages/sdk-router/src/sba/supportedTokens.ts +++ b/packages/sdk-router/src/sba/supportedTokens.ts @@ -26,7 +26,7 @@ type SupportedTokenRemoteLookup = Partial< > const EXPECTED_TOKEN_ID_COUNT = 37 -const EXPECTED_ORIGIN_ENTRY_COUNT = 149 +const EXPECTED_ORIGIN_ENTRY_COUNT = 155 // wrappedNativeChainId marks the snapshot chain entry confirmed to be the local // wrapped native token via live on-chain research on March 18, 2026. @@ -224,12 +224,14 @@ const SBA_SUPPORTED_TOKEN_SNAPSHOT: readonly SupportedTokenSnapshotEntry[] = [ tokenId: 'SYN', chains: { [SupportedChainId.ARBITRUM]: '0x080F6AEd32Fc474DD5717105Dba5ea57268F46eb', + [SupportedChainId.AURORA]: '0xd80d8688b02B3FD3afb81cDb124F188BB5aD0445', [SupportedChainId.AVALANCHE]: '0x1f1E7c893855525b303f99bDF5c3c05Be09ca251', [SupportedChainId.BASE]: '0x432036208d2717394d2614d6697c46DF3Ed69540', [SupportedChainId.BLAST]: '0x9592f08387134e218327E6E8423400eb845EdE0E', [SupportedChainId.BSC]: '0xa4080f1778e69467E905B8d6F72f6e441f9e9484', [SupportedChainId.CANTO]: '0x555982d2E211745b96736665e19D9308B615F78e', + [SupportedChainId.CRONOS]: '0xFD0F80899983b8D46152aa1717D76cba71a31616', [SupportedChainId.ETH]: '0x0f2D719407FdBeFF09D87557AbB7232601FD9F29', [SupportedChainId.FANTOM]: '0xE55e19Fb4F2D85af758950957714292DAC1e25B2', [SupportedChainId.HARMONY]: '0xE55e19Fb4F2D85af758950957714292DAC1e25B2', @@ -266,6 +268,7 @@ const SBA_SUPPORTED_TOKEN_SNAPSHOT: readonly SupportedTokenSnapshotEntry[] = [ tokenId: 'UST', chains: { [SupportedChainId.ARBITRUM]: '0x13780E6d5696DD91454F6d3BbC2616687fEa43d0', + [SupportedChainId.AURORA]: '0xb1Da21B0531257a7E5aEfa0cd3CbF23AfC674cE1', [SupportedChainId.AVALANCHE]: '0xE97097dE8d6A17Be3c39d53AE63347706dCf8f43', [SupportedChainId.BSC]: '0xb7A6c5f0cc98d24Cf4B2011842e64316Ff6d042c', @@ -318,6 +321,7 @@ const SBA_SUPPORTED_TOKEN_SNAPSHOT: readonly SupportedTokenSnapshotEntry[] = [ [SupportedChainId.AVALANCHE]: '0x321E7092a180BB43555132ec53AaA65a5bF84251', [SupportedChainId.BSC]: '0x88918495892BAF4536611E38E75D771Dc6Ec0863', + [SupportedChainId.CRONOS]: '0xbB0A63A6CA2071c6C4bcAC11a1A317b20E3E999C', [SupportedChainId.ETH]: '0x0ab87046fBb341D058F17CBC4c1133F25a20a52f', [SupportedChainId.FANTOM]: '0x91fa20244Fb509e8289CA630E5db3E9166233FDc', [SupportedChainId.HARMONY]: '0x67C10C397dD0Ba417329543c1a40eb48AAa7cd00', @@ -349,11 +353,13 @@ const SBA_SUPPORTED_TOKEN_SNAPSHOT: readonly SupportedTokenSnapshotEntry[] = [ tokenId: 'nUSD', chains: { [SupportedChainId.ARBITRUM]: '0x2913E812Cf0dcCA30FB28E6Cac3d2DCFF4497688', + [SupportedChainId.AURORA]: '0x07379565cD8B0CaE7c60Dc78e7f601b34AF2A21c', [SupportedChainId.AVALANCHE]: '0xCFc37A6AB183dd4aED08C204D1c2773c0b1BDf46', [SupportedChainId.BLAST]: '0x3194B0A295D87fDAA54DF852c248F7a6BAF6c6e0', [SupportedChainId.BSC]: '0x23b891e5C62E0955ae2bD185990103928Ab817b3', [SupportedChainId.CANTO]: '0xD8836aF2e565D3Befce7D906Af63ee45a57E8f80', + [SupportedChainId.CRONOS]: '0x396c9c192dd323995346632581BEF92a31AC623b', [SupportedChainId.DFK]: '0x3AD9DFE640E1A9Cc1D9B0948620820D975c3803a', [SupportedChainId.ETH]: '0x1B84765dE8B7566e4cEAF4D0fD3c5aF52D3DdE4F', [SupportedChainId.FANTOM]: '0xED2a7edd7413021d440b09D654f3b87712abAB66', diff --git a/packages/sdk-router/src/sba/synapseBridgeAdapterModuleSet.test.ts b/packages/sdk-router/src/sba/synapseBridgeAdapterModuleSet.test.ts index 4a5ef34dbd..16898db80f 100644 --- a/packages/sdk-router/src/sba/synapseBridgeAdapterModuleSet.test.ts +++ b/packages/sdk-router/src/sba/synapseBridgeAdapterModuleSet.test.ts @@ -193,12 +193,19 @@ describe('SynapseBridgeAdapterModuleSet', () => { }) it('returns no candidates when the destination chain is outside the destination enablement set', async () => { + const restrictedModuleSet = new SynapseBridgeAdapterModuleSet([ + { chainId: SupportedChainId.ETH, provider: ethProvider }, + { + chainId: SupportedChainId.AURORA, + provider: mock(), + }, + ]) await expect( - moduleSet.getBridgeTokenCandidates({ - fromChainId: SupportedChainId.HARMONY, - toChainId: SupportedChainId.ETH, - fromToken: HARMONY_NETH, - toToken: ETH_NATIVE_TOKEN_ADDRESS, + restrictedModuleSet.getBridgeTokenCandidates({ + fromChainId: SupportedChainId.ETH, + toChainId: SupportedChainId.AURORA, + fromToken: '0x1B84765dE8B7566e4cEAF4D0fD3c5aF52D3DdE4F', + toToken: '0x07379565cD8B0CaE7c60Dc78e7f601b34AF2A21c', }) ).resolves.toEqual([]) }) diff --git a/packages/sdk-router/src/sdk.test.ts b/packages/sdk-router/src/sdk.test.ts index a31a9c5757..d73406c713 100644 --- a/packages/sdk-router/src/sdk.test.ts +++ b/packages/sdk-router/src/sdk.test.ts @@ -53,11 +53,11 @@ const createSwapQuoteTests = ( chainId: number, token: string, amount: BigNumber, - resultPromise: Promise + getResult: () => Promise ) => { let result: SwapQuote beforeAll(async () => { - result = await resultPromise + result = await getResult() }) it('Fetches a swap quote', async () => { @@ -389,19 +389,19 @@ describe('SynapseSDK', () => { describe.skip('Swap', () => { const synapse = new SynapseSDK([SupportedChainId.ARBITRUM], [arbProvider]) const amount = BigNumber.from(10).pow(9) - const resultPromise: Promise = synapse.swapQuote( - SupportedChainId.ARBITRUM, - ARB_USDC, - ARB_USDC_E, - amount - ) createSwapQuoteTests( synapse, SupportedChainId.ARBITRUM, ARB_USDC, amount, - resultPromise + () => + synapse.swapQuote( + SupportedChainId.ARBITRUM, + ARB_USDC, + ARB_USDC_E, + amount + ) ) }) diff --git a/packages/sdk-router/src/sdk.ts b/packages/sdk-router/src/sdk.ts index 4037d06709..2c9fc8b5a4 100644 --- a/packages/sdk-router/src/sdk.ts +++ b/packages/sdk-router/src/sdk.ts @@ -4,7 +4,7 @@ import invariant from 'tiny-invariant' import { GasZipModuleSet } from './gaszip' import { SynapseModuleSet } from './module' -import { UsdtModuleSet } from './oft' +import { SynModuleSet, UsdtModuleSet } from './oft' import * as operations from './operations' import { RelayModuleSet } from './relay' import { FastBridgeRouterSet } from './rfq' @@ -22,6 +22,7 @@ class SynapseSDK { public gasZipModuleSet: GasZipModuleSet public relayModuleSet: RelayModuleSet public usdtModuleSet: UsdtModuleSet + public synModuleSet: SynModuleSet public synapseBridgeAdapterModuleSet: SynapseBridgeAdapterModuleSet public sirSet: SynapseIntentRouterSet @@ -61,6 +62,7 @@ class SynapseSDK { this.gasZipModuleSet = new GasZipModuleSet(chainProviders) this.relayModuleSet = new RelayModuleSet(chainProviders) this.usdtModuleSet = new UsdtModuleSet(chainProviders) + this.synModuleSet = new SynModuleSet(chainProviders) this.synapseBridgeAdapterModuleSet = new SynapseBridgeAdapterModuleSet( chainProviders ) @@ -69,6 +71,7 @@ class SynapseSDK { this.gasZipModuleSet, this.relayModuleSet, this.usdtModuleSet, + this.synModuleSet, this.synapseBridgeAdapterModuleSet, ] this.sirSet = new SynapseIntentRouterSet(chainProviders) diff --git a/packages/sdk-router/src/utils/TokenMetadataFetcher.ts b/packages/sdk-router/src/utils/TokenMetadataFetcher.ts index 31e0495c55..f049ff3ebd 100644 --- a/packages/sdk-router/src/utils/TokenMetadataFetcher.ts +++ b/packages/sdk-router/src/utils/TokenMetadataFetcher.ts @@ -2,6 +2,7 @@ import { Provider } from '@ethersproject/abstract-provider' import { Contract } from '@ethersproject/contracts' import { isNativeToken } from './addressUtils' +import { HYPERCORE_CHAIN_ID, SYN_ADDRESS_MAP } from '../constants' import { logger } from './logger' import erc20ABI from '../abi/IERC20Metadata.json' import { marshallChainToken } from '../rfq/ticker' @@ -33,6 +34,13 @@ export class TokenMetadataFetcher { chainId: number, token: string ): Promise { + // HyperCore SYN uses its native token identifier and 8-decimal balance units. + if ( + chainId === HYPERCORE_CHAIN_ID && + token.toLowerCase() === SYN_ADDRESS_MAP[HYPERCORE_CHAIN_ID].toLowerCase() + ) { + return 8 + } if (isNativeToken(token)) { return 18 } diff --git a/packages/synapse-interface/CHANGELOG.md b/packages/synapse-interface/CHANGELOG.md index 27253da3d8..921f7585de 100644 --- a/packages/synapse-interface/CHANGELOG.md +++ b/packages/synapse-interface/CHANGELOG.md @@ -3,6 +3,33 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.45.1](https://github.com/synapsecns/sanguine/compare/@synapsecns/synapse-interface@0.45.0...@synapsecns/synapse-interface@0.45.1) (2026-09-21) + + +### Bug Fixes + +* restore Aurora and Cronos outbound quotes and RPC fallback ([#4132](https://github.com/synapsecns/sanguine/issues/4132)) ([3e98c89](https://github.com/synapsecns/sanguine/commit/3e98c89c98fb3b6dfe6766b713add604be85619e)) + + + + + +# [0.45.0](https://github.com/synapsecns/sanguine/compare/@synapsecns/synapse-interface@0.44.5...@synapsecns/synapse-interface@0.45.0) (2026-09-17) + + +### Bug Fixes + +* **synapse-interface:** retire DFK chain ([#4118](https://github.com/synapsecns/sanguine/issues/4118)) ([caad5f9](https://github.com/synapsecns/sanguine/commit/caad5f9a8a03150329a586a7d40eebae154ec2a8)) + + +### Features + +* **synapse-interface:** redesign Hypercall announcement banner ([#4072](https://github.com/synapsecns/sanguine/issues/4072))sl ([c27fd95](https://github.com/synapsecns/sanguine/commit/c27fd95b14021396d208b7693b00a75f09dd4d6f)) + + + + + ## [0.44.5](https://github.com/synapsecns/sanguine/compare/@synapsecns/synapse-interface@0.44.4...@synapsecns/synapse-interface@0.44.5) (2026-05-22) **Note:** Version bump only for package @synapsecns/synapse-interface diff --git a/packages/synapse-interface/README.md b/packages/synapse-interface/README.md index d933d8205c..7b0d341138 100644 --- a/packages/synapse-interface/README.md +++ b/packages/synapse-interface/README.md @@ -98,6 +98,7 @@ Use `paused-bridge-modules.json` to pause a specific bridge module from a specif - `SynapseRFQ` - `SynapseCCTP` - `SynapseBridge` +- `SYN` ### Bridge Module Pause Props @@ -108,7 +109,7 @@ Origin chain ID where the bridge module should be paused. Use only in `paused-br Destination chain ID where the bridge module should be paused. Use only in `paused-bridge-modules-to-chain.json`. `bridgeModuleName` -Accepts `SynapseRFQ`, `SynapseBridge`, or `SynapseCCTP`. +Accepts `SynapseRFQ`, `SynapseBridge`, `SynapseCCTP`, or `SYN`. ### Example @@ -121,3 +122,7 @@ Accepts `SynapseRFQ`, `SynapseBridge`, or `SynapseCCTP`. 1. Add the new locale code to `next.config.js`. 2. Include the new locale in the `LanguageSelector`. 3. Populate `/messages/{locale.json}` translations for the new locale. The keys in this should match `en-US.json`. See some of the other language files for reference. + +## SYN bridging + +SYN uses the existing bridge UI for Ethereum ↔ HyperEVM and Ethereum → HyperCore. Select **Hyperliquid** for HyperCore (non-EVM routing identifier `1337`); HyperEVM is `999`. HyperCore SYN uses its native 16-byte token ID and 8-decimal amounts. HyperCore recipients must already be activated. SYN uses the composer in one source transaction, while USDC retains its Arbitrum deposit flow. Module pauses can target `SYN`, including destination `1337`. See the [SDK integration notes](../sdk-router/README.md#syn-bridging) for quote units, fees, and tracking. diff --git a/packages/synapse-interface/__tests__/getPendingBridgeTransactionTrackingData.test.ts b/packages/synapse-interface/__tests__/getPendingBridgeTransactionTrackingData.test.ts index 124f372efa..d5b3387cd9 100644 --- a/packages/synapse-interface/__tests__/getPendingBridgeTransactionTrackingData.test.ts +++ b/packages/synapse-interface/__tests__/getPendingBridgeTransactionTrackingData.test.ts @@ -14,6 +14,35 @@ const mockToken = { } as any describe('getPendingBridgeTransactionTrackingData', () => { + it('tracks SYN composer deposits as pending bridge transactions', () => { + const transaction = { + id: 111, + originChain: mockChain, + originToken: { routeSymbol: 'SYN', symbol: 'SYN' } as any, + originValue: '1', + destinationChain: HYPERLIQUID, + transactionHash: '0xsyn', + isSubmitted: true, + estimatedTime: 840, + bridgeModuleName: 'SYN', + routerAddress: zeroAddress, + } + expect( + getPendingBridgeTransactionTrackingData(transaction, zeroAddress) + ).toBeNull() + expect( + getPendingBridgeTransactionTrackingData( + { ...transaction, timestamp: 222 }, + zeroAddress + ) + ).toMatchObject({ + destinationChain: HYPERLIQUID, + bridgeModuleName: 'SYN', + timestamp: 222, + status: 'pending', + }) + }) + it('does not promote regular bridge transactions until a tracked timestamp exists', () => { const trackedTransaction = getPendingBridgeTransactionTrackingData( { @@ -62,14 +91,14 @@ describe('getPendingBridgeTransactionTrackingData', () => { }) }) - it('keeps the Hyperliquid path working without an explicit tracked timestamp', () => { + it('keeps saved Hyperliquid deposits working without an explicit tracked timestamp', () => { const trackedTransaction = getPendingBridgeTransactionTrackingData( { id: 333, originChain: mockChain, originToken: mockToken, originValue: '5', - destinationChain: HYPERLIQUID, + destinationChain: { ...HYPERLIQUID, id: 998 }, transactionHash: '0xhyper', isSubmitted: true, }, @@ -78,6 +107,7 @@ describe('getPendingBridgeTransactionTrackingData', () => { expect(trackedTransaction).toMatchObject({ originTxHash: '0xhyper', + destinationChain: HYPERLIQUID, estimatedTime: 0, timestamp: 333, bridgeModuleName: '', diff --git a/packages/synapse-interface/__tests__/getToChainIds.test.ts b/packages/synapse-interface/__tests__/getToChainIds.test.ts index fce9833937..9b150eacfb 100644 --- a/packages/synapse-interface/__tests__/getToChainIds.test.ts +++ b/packages/synapse-interface/__tests__/getToChainIds.test.ts @@ -99,8 +99,7 @@ describe('getToChainIds', () => { }) expect(chainIds).toEqual([ - 56, 1, 10, 137, 288, 8453, 42161, 43114, 7700, 25, 2000, 8217, 53935, - 1313161554, 1088, 250, 1666600000, + 10, 25, 56, 137, 288, 2000, 7700, 8217, 8453, 42161, 43114, 53935, ]) }) diff --git a/packages/synapse-interface/__tests__/synBridgeQuote.test.ts b/packages/synapse-interface/__tests__/synBridgeQuote.test.ts new file mode 100644 index 0000000000..3c4a50655a --- /dev/null +++ b/packages/synapse-interface/__tests__/synBridgeQuote.test.ts @@ -0,0 +1,183 @@ +/** @jest-environment node */ + +import { fetchBridgeQuote } from '@/slices/bridgeQuote/thunks' +import { SYN } from '@/constants/tokens/bridgeable' +import { getErc20TokenAllowance } from '@/actions/getErc20TokenAllowance' + +jest.mock('../actions/getErc20TokenAllowance', () => ({ + getErc20TokenAllowance: jest.fn().mockResolvedValue(10n ** 18n), +})) +jest.mock('../contexts/SegmentAnalyticsProvider', () => ({ + segmentAnalyticsEvent: jest.fn(), +})) + +const sender = '0x1111111111111111111111111111111111111111' +const recipient = '0x2222222222222222222222222222222222222222' +const routerAddress = '0x3333333333333333333333333333333333333333' +const tx = { to: routerAddress, value: '100', data: '0x1234' } + +const quote = (fromChainId = 1, toChainId = 1337) => ({ + id: 'syn-quote', + fromChainId, + toChainId, + routerAddress, + expectedToAmount: toChainId === 1337 ? '100000000' : '1000000000000000000', + minToAmount: toChainId === 1337 ? '100000000' : '1000000000000000000', + moduleNames: ['SYN'], + estimatedTime: 840, + gasDropAmount: '0', + tx, +}) + +const runQuote = ({ + synapseSDK, + fromChainId = 1, + toChainId = 1337, + address = sender, + destinationAddress, +}: { + synapseSDK: any + fromChainId?: number + toChainId?: number + address?: string | null + destinationAddress?: string +}) => + fetchBridgeQuote({ + synapseSDK, + fromChainId, + toChainId, + fromToken: SYN, + toToken: SYN, + debouncedFromValue: '1', + requestId: 1, + currentTimestamp: 123, + address: address as any, + destinationAddress: destinationAddress as any, + pausedModulesList: [], + })(jest.fn(), jest.fn(), undefined) + +describe('SYN bridge quote state', () => { + beforeEach(() => jest.clearAllMocks()) + + it.each([ + ['inactive custom recipient', recipient, false], + ['active connected recipient', undefined, true], + ])( + 'records the %s check against the actual recipient', + async (_, custom, isActive) => { + const isHyperCoreAccountActive = jest.fn().mockResolvedValue(isActive) + const synapseSDK = { + bridgeV2: jest.fn().mockResolvedValue([quote()]), + synModuleSet: { isHyperCoreAccountActive }, + } + + const result = await runQuote({ + synapseSDK, + destinationAddress: custom, + }) + const expectedRecipient = custom ?? sender + + expect(isHyperCoreAccountActive).toHaveBeenCalledWith(expectedRecipient) + expect(synapseSDK.bridgeV2).toHaveBeenCalledWith( + expect.objectContaining({ toRecipient: expectedRecipient }) + ) + expect(fetchBridgeQuote.fulfilled.match(result)).toBe(true) + expect(result.payload).toMatchObject({ + hyperCoreRecipient: { address: expectedRecipient, isActive }, + }) + } + ) + + it.each([ + ['a disconnected quote', 1, 1337, null], + ['a non-HyperCore route', 1, 999, sender], + ])( + 'skips the activation check for %s', + async (_, fromChainId, toChainId, address) => { + const isHyperCoreAccountActive = jest.fn() + const synapseSDK = { + bridgeV2: jest.fn().mockResolvedValue([quote(fromChainId, toChainId)]), + synModuleSet: { isHyperCoreAccountActive }, + } + + const result = await runQuote({ + synapseSDK, + fromChainId, + toChainId, + address, + }) + + expect(fetchBridgeQuote.fulfilled.match(result)).toBe(true) + expect(isHyperCoreAccountActive).not.toHaveBeenCalled() + expect(result.payload).toHaveProperty('hyperCoreRecipient', undefined) + } + ) + + it('rejects the quote when the activation check fails', async () => { + const isHyperCoreAccountActive = jest + .fn() + .mockRejectedValue(new Error('activation RPC unavailable')) + const result = await runQuote({ + synapseSDK: { + bridgeV2: jest.fn().mockResolvedValue([quote()]), + synModuleSet: { isHyperCoreAccountActive }, + }, + destinationAddress: recipient, + }) + + expect(isHyperCoreAccountActive).toHaveBeenCalledWith(recipient) + expect(fetchBridgeQuote.rejected.match(result)).toBe(true) + if (fetchBridgeQuote.rejected.match(result)) { + expect(result.error.message).toBe('activation RPC unavailable') + } + }) + + it.each([ + [1, 999], + [999, 1], + [1, 1337], + ])( + 'preserves the SDK destination and transaction for %s -> %s', + async (fromChainId, toChainId) => { + const synapseSDK = { + bridgeV2: jest.fn().mockResolvedValue([quote(fromChainId, toChainId)]), + synModuleSet: { + isHyperCoreAccountActive: jest.fn().mockResolvedValue(true), + }, + } + const result = await runQuote({ + synapseSDK, + fromChainId, + toChainId, + destinationAddress: recipient, + }) + + expect(synapseSDK.bridgeV2).toHaveBeenCalledWith( + expect.objectContaining({ + fromChainId, + toChainId, + fromToken: SYN.addresses[fromChainId], + toToken: SYN.addresses[toChainId], + fromSender: sender, + toRecipient: recipient, + }) + ) + expect(fetchBridgeQuote.fulfilled.match(result)).toBe(true) + expect(result.payload).toMatchObject({ + bridgeModuleName: 'SYN', + originChainId: fromChainId, + destChainId: toChainId, + outputAmountString: '1.0', + exchangeRate: 10n ** 18n, + tx, + }) + expect(getErc20TokenAllowance).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: fromChainId, + tokenAddress: SYN.addresses[fromChainId], + spender: routerAddress, + }) + ) + } + ) +}) diff --git a/packages/synapse-interface/__tests__/synBridgeQuoteReducer.test.ts b/packages/synapse-interface/__tests__/synBridgeQuoteReducer.test.ts new file mode 100644 index 0000000000..62f5b9fd1e --- /dev/null +++ b/packages/synapse-interface/__tests__/synBridgeQuoteReducer.test.ts @@ -0,0 +1,69 @@ +/** @jest-environment node */ + +import reducer, { resetBridgeQuote } from '@/slices/bridgeQuote/reducer' +import { fetchBridgeQuote } from '@/slices/bridgeQuote/thunks' +import { EMPTY_BRIDGE_QUOTE } from '@/constants/bridge' + +jest.mock('../actions/getErc20TokenAllowance', () => ({ + getErc20TokenAllowance: jest.fn(), +})) +jest.mock('../contexts/SegmentAnalyticsProvider', () => ({ + segmentAnalyticsEvent: jest.fn(), +})) + +const args = {} as any +const completedQuote = { ...EMPTY_BRIDGE_QUOTE, id: 'completed' } as any + +describe('bridge quote request ordering', () => { + it('ignores fulfilled and rejected results from stale requests', () => { + const pending = reducer( + undefined, + fetchBridgeQuote.pending('current-request', args) + ) + const afterFulfilled = reducer( + pending, + fetchBridgeQuote.fulfilled(completedQuote, 'stale-request', args) + ) + const afterRejected = reducer( + afterFulfilled, + fetchBridgeQuote.rejected( + new Error('stale failure'), + 'stale-request', + args + ) + ) + + expect(afterRejected).toMatchObject({ + bridgeQuote: EMPTY_BRIDGE_QUOTE, + currentRequestId: 'current-request', + isLoading: true, + }) + }) + + it('ignores a request result after the quote is reset', () => { + const pending = reducer( + undefined, + fetchBridgeQuote.pending('cancelled-request', args) + ) + const reset = reducer(pending, resetBridgeQuote()) + const afterFulfilled = reducer( + reset, + fetchBridgeQuote.fulfilled(completedQuote, 'cancelled-request', args) + ) + const afterRejected = reducer( + reset, + fetchBridgeQuote.rejected( + new Error('cancelled failure'), + 'cancelled-request', + args + ) + ) + + expect(afterFulfilled).toMatchObject({ + bridgeQuote: EMPTY_BRIDGE_QUOTE, + currentRequestId: undefined, + isLoading: false, + }) + expect(afterRejected).toEqual(afterFulfilled) + }) +}) diff --git a/packages/synapse-interface/__tests__/synBridgeRoutes.test.ts b/packages/synapse-interface/__tests__/synBridgeRoutes.test.ts new file mode 100644 index 0000000000..f9bbb5ab77 --- /dev/null +++ b/packages/synapse-interface/__tests__/synBridgeRoutes.test.ts @@ -0,0 +1,122 @@ +import { HYPERCORE_CHAIN_ID } from '@synapsecns/sdk-router' + +import { EXISTING_BRIDGE_ROUTES } from '@/constants/existingBridgeRoutes' +import { SYN } from '@/constants/tokens/bridgeable' +import { Token } from '@/utils/types' +import { HYPERLIQUID } from '@/constants/chains/master' +import { getFromChainIds } from '@/utils/routeMaker/getFromChainIds' +import { getToChainIds } from '@/utils/routeMaker/getToChainIds' +import { getToTokens } from '@/utils/routeMaker/getToTokens' +import { getPausedBridgeModuleNamesForRoute } from '@/utils/getPausedBridgeModuleNamesForRoute' +import { isValidBridgeModule } from '@/components/Maintenance/functions/isValidBridgeModule' +import { + getBridgeDestinationChainId, + isHyperliquidUsdcDeposit, +} from '@/utils/hyperliquid' + +describe('SYN routes in the existing bridge selectors', () => { + it('supports module and ALL pauses on the virtual destination', () => { + expect(isValidBridgeModule('SYN')).toBe(true) + for (const bridgeModuleName of ['SYN', 'ALL']) { + const pausedModules = [{ toChainId: 1337, bridgeModuleName }] + expect( + getPausedBridgeModuleNamesForRoute({ + pausedModules, + fromChainId: 1, + toChainId: 1337, + }) + ).toContain('SYN') + expect( + getPausedBridgeModuleNamesForRoute({ + pausedModules, + fromChainId: 1, + toChainId: 999, + }) + ).not.toContain('SYN') + } + }) + + it.each([ + [1, 999], + [999, 1], + [1, 1337], + ])( + 'offers SYN from %s to %s with token metadata', + (fromChainId, toChainId) => { + expect( + getToTokens({ + fromChainId, + toChainId, + fromTokenRouteSymbol: 'SYN', + toTokenRouteSymbol: null, + }) + ).toContain(`SYN-${toChainId}`) + expect(SYN.addresses[toChainId]).toBeDefined() + expect(SYN.decimals[toChainId]).toBe(toChainId === 1337 ? 8 : 18) + expect(getBridgeDestinationChainId(toChainId, SYN)).toBe(toChainId) + expect(isHyperliquidUsdcDeposit(toChainId, SYN)).toBe(false) + } + ) + + it('uses native token IDs only on HyperCore while preserving EVM address validation', () => { + const tokenId = '0xf5f05eb8b9aa92365465f06daf5889c9' + expect(HYPERLIQUID.id).toBe(HYPERCORE_CHAIN_ID) + expect(SYN.addresses[HYPERLIQUID.id]).toBe(tokenId) + for (const chainId of [1, 999]) { + expect( + () => new Token({ addresses: { [chainId]: tokenId }, priorityRank: 0 }) + ).toThrow() + } + expect( + () => + new Token({ + addresses: { [HYPERLIQUID.id]: '0x1234' }, + priorityRank: 0, + }) + ).toThrow() + }) + + it('keeps HyperCore destination-only and requires Ethereum as its SYN origin', () => { + expect( + getFromChainIds({ + fromChainId: null, + fromTokenRouteSymbol: 'SYN', + toChainId: 1337, + toTokenRouteSymbol: 'SYN', + }) + ).toEqual([1]) + expect(EXISTING_BRIDGE_ROUTES['SYN-1337']).toBeUndefined() + expect(EXISTING_BRIDGE_ROUTES['SYN-999']).toEqual(['SYN-1']) + }) + + it('keeps HyperCore in the destination selector after all fields are selected', () => { + const selectedRoute = { + fromChainId: 1, + fromTokenRouteSymbol: 'SYN', + toChainId: 1337, + toTokenRouteSymbol: 'SYN', + } + expect(getToChainIds(selectedRoute)).toEqual( + getToChainIds({ ...selectedRoute, toChainId: null }) + ) + expect(getToChainIds(selectedRoute)).toContain(1337) + }) + + it('does not advertise the new destinations from other legacy SYN chains', () => { + Object.entries(EXISTING_BRIDGE_ROUTES).forEach(([origin, destinations]) => { + if (origin !== 'SYN-1') { + expect(destinations).not.toContain('SYN-999') + expect(destinations).not.toContain('SYN-1337') + } + }) + expect(EXISTING_BRIDGE_ROUTES['SYN-1']).toContain('SYN-42161') + }) + + it('preserves the USDC deposit routing through Arbitrum', () => { + expect(getBridgeDestinationChainId(1337, { routeSymbol: 'USDC' })).toBe( + 42161 + ) + expect(isHyperliquidUsdcDeposit(1337, { routeSymbol: 'USDC' })).toBe(true) + expect(EXISTING_BRIDGE_ROUTES['USDC-42161']).toContain('USDC-1337') + }) +}) diff --git a/packages/synapse-interface/__tests__/synOriginSelector.test.ts b/packages/synapse-interface/__tests__/synOriginSelector.test.ts new file mode 100644 index 0000000000..11db2a7f72 --- /dev/null +++ b/packages/synapse-interface/__tests__/synOriginSelector.test.ts @@ -0,0 +1,15 @@ +import { useFromChainListArray } from '@/components/StateManagedBridge/hooks/useFromChainListArray' + +jest.mock('../slices/bridge/hooks', () => ({ + useBridgeState: () => ({ fromChainIds: [1, 999] }), +})) +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})) + +it('excludes HyperCore from both supported and fallback source-network lists', () => { + const chains = Object.values(useFromChainListArray()).flat() + expect(chains.some((chain) => chain.id === 1337)).toBe(false) + expect(chains.some((chain) => chain.id === 999)).toBe(true) + expect(chains.some((chain) => chain.id === 1)).toBe(true) +}) diff --git a/packages/synapse-interface/__tests__/synPortfolioBalances.test.ts b/packages/synapse-interface/__tests__/synPortfolioBalances.test.ts new file mode 100644 index 0000000000..95693a12c5 --- /dev/null +++ b/packages/synapse-interface/__tests__/synPortfolioBalances.test.ts @@ -0,0 +1,32 @@ +import { fetchPortfolioBalances } from '@/utils/actions/fetchPortfolioBalances' +import { sortByTokenBalance } from '@/utils/sortTokens' + +jest.mock('../utils/sortTokens', () => ({ + sortByTokenBalance: jest.fn().mockResolvedValue([]), +})) +jest.mock('../constants/tokens', () => ({ + BRIDGABLE_TOKENS: { 1: [], 1337: [], 999: [] }, + POOLS_BY_CHAIN: {}, + NON_BRIDGEABLE_GAS_TOKENS: {}, +})) + +beforeEach(() => jest.clearAllMocks()) + +it('fetches SYN EVM balances without querying the virtual HyperCore chain', async () => { + const result = await fetchPortfolioBalances( + '0x1111111111111111111111111111111111111111' + ) + expect( + jest.mocked(sortByTokenBalance).mock.calls.map((args) => args[1]) + ).toEqual([1, 999]) + expect(Object.keys(result.balances)).toEqual(['1', '999']) +}) + +it('skips EVM balance queries when refreshing a HyperCore destination', async () => { + const result = await fetchPortfolioBalances( + '0x1111111111111111111111111111111111111111', + 1337 + ) + expect(sortByTokenBalance).not.toHaveBeenCalled() + expect(result.balances).toEqual({}) +}) diff --git a/packages/synapse-interface/__tests__/synRecipientWarning.test.tsx b/packages/synapse-interface/__tests__/synRecipientWarning.test.tsx new file mode 100644 index 0000000000..6afaf358de --- /dev/null +++ b/packages/synapse-interface/__tests__/synRecipientWarning.test.tsx @@ -0,0 +1,135 @@ +/** @jest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react' + +import { BridgeTransactionButton } from '@/components/StateManagedBridge/BridgeTransactionButton' +import { SynHyperCoreRecipientWarning } from '@/components/StateManagedBridge/BridgeWarnings' + +const syn = { + symbol: 'SYN', + routeSymbol: 'SYN', + addresses: { 1: '0x1111111111111111111111111111111111111111' }, +} + +jest.mock('viem', () => ({ isAddress: jest.fn(() => true) })) +jest.mock('wagmi', () => ({ + useAccount: () => ({ isConnected: true }), + useAccountEffect: jest.fn(), + useSwitchChain: () => ({ + chains: [{ id: 1, name: 'Ethereum' }], + switchChain: jest.fn(), + }), +})) +jest.mock('@rainbow-me/rainbowkit', () => ({ + useConnectModal: () => ({ openConnectModal: jest.fn() }), +})) +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + values?.symbol ? key.replace('{symbol}', values.symbol) : key, +})) +jest.mock('../store/hooks', () => ({ useAppDispatch: () => jest.fn() })) +jest.mock('../slices/wallet/hooks', () => ({ + useWalletState: () => ({ isWalletPending: false }), +})) +jest.mock('../slices/bridgeQuote/hooks', () => ({ + useBridgeQuoteState: () => ({ + bridgeQuote: { bridgeModuleName: 'SYN' }, + isLoading: false, + }), +})) +jest.mock('../slices/bridge/hooks', () => ({ + useBridgeState: () => ({ + destinationAddress: null, + fromToken: syn, + fromChainId: 1, + toToken: syn, + toChainId: 1337, + debouncedFromValue: '1', + }), + useBridgeDisplayState: () => ({ + showDestinationWarning: false, + isDestinationWarningAccepted: true, + }), +})) +jest.mock( + '../components/StateManagedBridge/hooks/useBridgeValidations', + () => ({ + useBridgeValidations: () => ({ + hasValidInput: true, + hasValidQuote: true, + hasSufficientBalance: true, + doesBridgeStateMatchQuote: true, + onSelectedChain: true, + }), + }) +) +jest.mock( + '../components/StateManagedBridge/hooks/useConfirmNewBridgePrice', + () => ({ + useConfirmNewBridgePrice: () => ({ + isPendingConfirmChange: false, + onUserAcceptChange: jest.fn(), + }), + }) +) +jest.mock('../utils/hyperliquid', () => ({ + isHyperliquidUsdcDeposit: () => false, +})) +jest.mock('../contexts/SegmentAnalyticsProvider', () => ({ + segmentAnalyticsEvent: jest.fn(), +})) +jest.mock('../components/buttons/TransactionButton', () => ({ + TransactionButton: ({ label, disabled, onClick }) => ( + + ), +})) + +describe('inactive HyperCore recipient acknowledgement', () => { + it('keeps the bridge button disabled until the warning is accepted', () => { + const props = { + approveTxn: jest.fn(), + executeBridge: jest.fn(), + isApproved: true, + isBridgePaused: false, + isTyping: false, + isQuoteStale: false, + } + const { rerender } = render( + + ) + + expect(screen.getByRole('button', { name: 'Bridge SYN' })).toHaveProperty( + 'disabled', + true + ) + fireEvent.click(screen.getByRole('button', { name: 'Bridge SYN' })) + expect(props.executeBridge).not.toHaveBeenCalled() + + rerender( + + ) + expect(screen.getByRole('button', { name: 'Bridge SYN' })).toHaveProperty( + 'disabled', + false + ) + fireEvent.click(screen.getByRole('button', { name: 'Bridge SYN' })) + expect(props.executeBridge).toHaveBeenCalledTimes(1) + }) + + it('exposes a labelled checkbox and reports acceptance', () => { + const onAccept = jest.fn() + render( + + ) + + const checkbox = screen.getByRole('checkbox', { + name: 'SynHyperCoreRecipientWarningAccept', + }) + expect(checkbox).toHaveProperty('checked', false) + + fireEvent.click(checkbox) + expect(onAccept).toHaveBeenCalledWith(true) + }) +}) diff --git a/packages/synapse-interface/__tests__/synTransactionStatus.test.tsx b/packages/synapse-interface/__tests__/synTransactionStatus.test.tsx new file mode 100644 index 0000000000..d4ac9e9024 --- /dev/null +++ b/packages/synapse-interface/__tests__/synTransactionStatus.test.tsx @@ -0,0 +1,86 @@ +import { renderToStaticMarkup } from 'react-dom/server' + +import { _Transactions as Transactions } from '@/components/_Transaction/_Transactions' +import { use_TransactionsState } from '@/slices/_transactions/hooks' +import { SYN, USDC } from '@/constants/tokens/bridgeable' +import transactionsReducer, { + completeTransaction, +} from '@/slices/_transactions/reducer' +import { ETH, HYPEREVM, HYPERLIQUID } from '@/constants/chains/master' + +jest.mock('../slices/_transactions/hooks', () => ({ + use_TransactionsState: jest.fn(), +})) +jest.mock('../slices/wallet/hooks', () => ({ + useWalletState: () => ({ isWalletPending: false }), +})) +jest.mock('../utils/hooks/useIntervalTimer', () => ({ + useIntervalTimer: () => 123, +})) +jest.mock('../components/_Transaction/_Transaction', () => ({ + _Transaction: ({ status, destinationChain }) => ( + {status} + ), +})) + +describe('HyperCore transaction display', () => { + it.each([ + [SYN, 'SYN', 'pending', 'pending', 1337], + [SYN, 'SYN', 'completed', 'completed', 1337], + [USDC, '', 'pending', 'completed', 1337], + [USDC, '', 'pending', 'completed', 998], + ])( + 'uses the tracked status for %s through %s (%s)', + (token, bridgeModuleName, storedStatus, displayedStatus, storedChainId) => { + jest.mocked(use_TransactionsState).mockReturnValue({ + transactions: [ + { + address: '0xabc', + originChain: { id: 1 }, + destinationChain: { id: storedChainId, chainSymbol: 'HYPERLIQUID' }, + originToken: token, + destinationToken: token, + originTxHash: '0xsyn', + originValue: '1', + bridgeModuleName, + timestamp: 123, + status: storedStatus, + }, + ], + } as any) + expect( + renderToStaticMarkup() + ).toContain(`${displayedStatus}`) + } + ) + + it('persists an observed HyperEVM fallback as the completed destination', () => { + const originTxHash = '0xfallback' + const state = { + transactions: [ + { + originTxHash, + originChain: ETH, + originToken: SYN, + destinationChain: HYPERLIQUID, + destinationToken: SYN, + status: 'pending', + }, + ], + } + + const nextState = transactionsReducer( + state, + completeTransaction({ + originTxHash, + kappa: originTxHash, + destinationChain: HYPEREVM, + }) + ) + + expect(nextState.transactions[0]).toMatchObject({ + status: 'completed', + destinationChain: HYPEREVM, + }) + }) +}) diff --git a/packages/synapse-interface/components/Maintenance/Maintenance.tsx b/packages/synapse-interface/components/Maintenance/Maintenance.tsx index e53d68314e..9fdf897a38 100644 --- a/packages/synapse-interface/components/Maintenance/Maintenance.tsx +++ b/packages/synapse-interface/components/Maintenance/Maintenance.tsx @@ -5,7 +5,10 @@ import { useBridgeState } from '@/slices/bridge/hooks' import { useSwapState } from '@/slices/swap/hooks' import { useMaintenanceState } from '@/slices/maintenance/hooks' import { isChainIncluded } from '@/utils/isChainIncluded' -import { isValidBridgeModule } from './functions/isValidBridgeModule' +import { + BridgeModuleName, + isValidBridgeModule, +} from './functions/isValidBridgeModule' import { getFurthestFutureDate } from './functions/getFurthestFutureDate' import { useTranslations } from 'next-intl' @@ -30,7 +33,7 @@ interface ChainPause { export interface BridgeModulePause { chainId?: number // Origin chain ID. If undefined, match any origin chain. toChainId?: number // Destination chain ID. If undefined, match any destination chain. - bridgeModuleName: 'SynapseBridge' | 'SynapseRFQ' | 'SynapseCCTP' | 'ALL' + bridgeModuleName: BridgeModuleName } const useMaintenanceData = () => { @@ -63,11 +66,6 @@ const useMaintenanceData = () => { return { ...route, - bridgeModuleName: route.bridgeModuleName as - | 'SynapseBridge' - | 'SynapseRFQ' - | 'SynapseCCTP' - | 'ALL', } }) : [] diff --git a/packages/synapse-interface/components/Maintenance/functions/isValidBridgeModule.ts b/packages/synapse-interface/components/Maintenance/functions/isValidBridgeModule.ts index 02223c974c..756be8027c 100644 --- a/packages/synapse-interface/components/Maintenance/functions/isValidBridgeModule.ts +++ b/packages/synapse-interface/components/Maintenance/functions/isValidBridgeModule.ts @@ -1,5 +1,11 @@ -export const isValidBridgeModule = ( - module: any -): module is 'SynapseBridge' | 'SynapseRFQ' | 'SynapseCCTP' | 'ALL' => { - return ['SynapseBridge', 'SynapseRFQ', 'SynapseCCTP', 'ALL'].includes(module) -} +export const BRIDGE_MODULE_NAMES = [ + 'SynapseBridge', + 'SynapseRFQ', + 'SynapseCCTP', + 'SYN', +] as const + +export type BridgeModuleName = (typeof BRIDGE_MODULE_NAMES)[number] | 'ALL' + +export const isValidBridgeModule = (module: any): module is BridgeModuleName => + module === 'ALL' || BRIDGE_MODULE_NAMES.includes(module) diff --git a/packages/synapse-interface/components/StateManagedBridge/BridgeExchangeRateInfo.tsx b/packages/synapse-interface/components/StateManagedBridge/BridgeExchangeRateInfo.tsx index a411f96ab8..d5f24b4e4c 100644 --- a/packages/synapse-interface/components/StateManagedBridge/BridgeExchangeRateInfo.tsx +++ b/packages/synapse-interface/components/StateManagedBridge/BridgeExchangeRateInfo.tsx @@ -195,7 +195,9 @@ const GasDropLabel = () => { significantDecimals ) - const price = useDefiLlamaPrice({ addresses: { [toChainId]: zeroAddress } }) + const price = useDefiLlamaPrice( + gasDropAmount > 0n ? { addresses: { [toChainId]: zeroAddress } } : null + ) const airdropInDollars = calculateUsdValue(formattedGasDropAmount, price) if (isLoading || gasDropAmount === EMPTY_BRIDGE_QUOTE.gasDropAmount) { diff --git a/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx b/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx index bfe44718d6..203427fec5 100644 --- a/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx +++ b/packages/synapse-interface/components/StateManagedBridge/BridgeTransactionButton.tsx @@ -13,7 +13,7 @@ import { TransactionButton } from '@/components/buttons/TransactionButton' import { useBridgeValidations } from './hooks/useBridgeValidations' import { segmentAnalyticsEvent } from '@/contexts/SegmentAnalyticsProvider' import { useConfirmNewBridgePrice } from './hooks/useConfirmNewBridgePrice' -import { HYPERLIQUID } from '@/constants/chains/master' +import { isHyperliquidUsdcDeposit } from '@/utils/hyperliquid' import { HYPERLIQUID_MINIMUM_DEPOSIT } from '@/constants' export const BridgeTransactionButton = ({ @@ -23,6 +23,7 @@ export const BridgeTransactionButton = ({ isBridgePaused, isTyping, isQuoteStale, + isRecipientWarningAccepted, }) => { const dispatch = useAppDispatch() const { openConnectModal } = useConnectModal() @@ -59,12 +60,9 @@ export const BridgeTransactionButton = ({ const { showDestinationWarning, isDestinationWarningAccepted } = useBridgeDisplayState() + const isUsdcDeposit = isHyperliquidUsdcDeposit(toChainId, toToken) const hasHyperliquidMinDeposit = - toChainId === HYPERLIQUID.id - ? Number(debouncedFromValue) > HYPERLIQUID_MINIMUM_DEPOSIT - ? true - : false - : true + !isUsdcDeposit || Number(debouncedFromValue) > HYPERLIQUID_MINIMUM_DEPOSIT const { hasValidInput, @@ -75,6 +73,7 @@ export const BridgeTransactionButton = ({ } = useBridgeValidations() const isButtonDisabled = + !isRecipientWarningAccepted || isBridgePaused || isTyping || isLoading || @@ -84,7 +83,7 @@ export const BridgeTransactionButton = ({ (isConnected && !hasValidQuote) || (isConnected && !hasSufficientBalance) || (isConnected && isQuoteStale) || - (destinationAddress && !isAddress(destinationAddress)) || + (!isUsdcDeposit && destinationAddress && !isAddress(destinationAddress)) || !hasHyperliquidMinDeposit let buttonProperties @@ -167,11 +166,19 @@ export const BridgeTransactionButton = ({ destinationTokenAddressForState: toToken.addresses[toChainId], bridgeQuote, }) - } else if (destinationAddress && !isAddress(destinationAddress)) { + } else if ( + !isUsdcDeposit && + destinationAddress && + !isAddress(destinationAddress) + ) { buttonProperties = { label: t('Invalid Destination address'), } - } else if (showDestinationWarning && !isDestinationWarningAccepted) { + } else if ( + !isUsdcDeposit && + showDestinationWarning && + !isDestinationWarningAccepted + ) { buttonProperties = { label: t('Confirm destination address'), onClick: () => dispatch(setIsDestinationWarningAccepted(true)), diff --git a/packages/synapse-interface/components/StateManagedBridge/BridgeWarnings.tsx b/packages/synapse-interface/components/StateManagedBridge/BridgeWarnings.tsx index 9484d61882..ac11c9ccb8 100644 --- a/packages/synapse-interface/components/StateManagedBridge/BridgeWarnings.tsx +++ b/packages/synapse-interface/components/StateManagedBridge/BridgeWarnings.tsx @@ -3,6 +3,38 @@ import { useTranslations } from 'next-intl' import { useAppDispatch } from '@/store/hooks' import { useBridgeDisplayState } from '@/slices/bridge/hooks' import { setIsDestinationWarningAccepted } from '@/slices/bridgeDisplaySlice' +import { WarningMessage } from '@/components/Warning' + +export const SynHyperCoreRecipientWarning = ({ + accepted, + onAccept, +}: { + accepted: boolean + onAccept: (accepted: boolean) => void +}) => { + const t = useTranslations('Bridge') + + return ( + +

{t('SynHyperCoreRecipientWarningMessage')}

+ + + } + /> + ) +} export const ConfirmDestinationAddressWarning = () => { const dispatch = useAppDispatch() diff --git a/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx b/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx index e033e3cfd3..7cca5ca61a 100644 --- a/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx +++ b/packages/synapse-interface/components/StateManagedBridge/OutputContainer.tsx @@ -16,7 +16,8 @@ import { useWalletState } from '@/slices/wallet/hooks' import { useBridgeQuoteState } from '@/slices/bridgeQuote/hooks' import { useBridgeValidations } from './hooks/useBridgeValidations' import { useTranslations } from 'next-intl' -import { ARBITRUM, HYPERLIQUID } from '@/constants/chains/master' +import { ARBITRUM } from '@/constants/chains/master' +import { isHyperliquidUsdcDeposit } from '@/utils/hyperliquid' import { useUsdDisplay } from '@hooks/useUsdDisplay' import { formatInlineUsdDifference } from '@utils/calculateUsdValue' import { usePortfolioBalances } from '@/slices/portfolio/hooks' @@ -37,6 +38,8 @@ export const OutputContainer = ({ isQuoteStale }: OutputContainerProps) => { const { hasValidInput, hasValidQuote } = useBridgeValidations() const { debouncedFromValue, fromChainId, toChainId, toToken } = useBridgeState() + const isUsdcDeposit = isHyperliquidUsdcDeposit(toChainId, toToken) + const isDirectUsdcDeposit = fromChainId === ARBITRUM.id && isUsdcDeposit const showValue = useMemo(() => { if (!hasValidInput) { @@ -51,10 +54,7 @@ export const OutputContainer = ({ isQuoteStale }: OutputContainerProps) => { const inputClassName = isQuoteStale ? 'opacity-50' : undefined // Fetch token price and calculate USD value - const outputValue = - fromChainId === ARBITRUM.id && toChainId === HYPERLIQUID.id - ? debouncedFromValue - : showValue + const outputValue = isDirectUsdcDeposit ? debouncedFromValue : showValue const usdValue = useUsdDisplay(toToken, outputValue) // Convert input amount to bigint for slippage calculation @@ -93,7 +93,7 @@ export const OutputContainer = ({ isQuoteStale }: OutputContainerProps) => {
- {showDestinationAddress && toChainId !== HYPERLIQUID.id ? ( + {showDestinationAddress && !isUsdcDeposit ? ( ) : null}
@@ -103,11 +103,7 @@ export const OutputContainer = ({ isQuoteStale }: OutputContainerProps) => {
diff --git a/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts b/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts index 8437b5c4fa..052d10e6c5 100644 --- a/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts +++ b/packages/synapse-interface/components/StateManagedBridge/hooks/useBridgeValidations.ts @@ -8,7 +8,7 @@ import { BridgeQuoteState } from '@/slices/bridgeQuote/reducer' import { EMPTY_BRIDGE_QUOTE } from '@/constants/bridge' import { hasOnlyZeroes } from '@/utils/hasOnlyZeroes' import { useBridgeSelections } from './useBridgeSelections' -import { ARBITRUM, HYPERLIQUID } from '@/constants/chains/master' +import { getBridgeDestinationChainId } from '@/utils/hyperliquid' export const useBridgeValidations = () => { const { chainId } = useAccount() @@ -67,7 +67,7 @@ export const useBridgeValidations = () => { debouncedFromValue, fromChainId, fromToken, - toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId, + getBridgeDestinationChainId(toChainId, toToken), toToken ) }, [debouncedFromValue, fromChainId, fromToken, toChainId, toToken]) diff --git a/packages/synapse-interface/components/StateManagedBridge/hooks/useFromChainListArray.ts b/packages/synapse-interface/components/StateManagedBridge/hooks/useFromChainListArray.ts index ce3ec1e8ed..5214cb13fc 100644 --- a/packages/synapse-interface/components/StateManagedBridge/hooks/useFromChainListArray.ts +++ b/packages/synapse-interface/components/StateManagedBridge/hooks/useFromChainListArray.ts @@ -3,6 +3,7 @@ import Fuse from 'fuse.js' import { useTranslations } from 'next-intl' import { ACTIVE_CHAINS_BY_ID, sortChains } from '@/constants/chains' +import { HYPERLIQUID } from '@/constants/chains/master' import { useBridgeState } from '@/slices/bridge/hooks' export const useFromChainListArray = (searchStr: string = '') => { @@ -10,7 +11,11 @@ export const useFromChainListArray = (searchStr: string = '') => { const t = useTranslations('Bridge') - let possibleChains = _(ACTIVE_CHAINS_BY_ID) + const originChains = _.pickBy( + ACTIVE_CHAINS_BY_ID, + (chain) => chain.id !== HYPERLIQUID.id + ) + let possibleChains = _(originChains) .pickBy((value) => _.includes(fromChainIds, value.id)) .values() .value() @@ -19,8 +24,8 @@ export const useFromChainListArray = (searchStr: string = '') => { let remainingChains = sortChains( _.difference( - Object.keys(ACTIVE_CHAINS_BY_ID).map((id) => ACTIVE_CHAINS_BY_ID[id]), - fromChainIds?.map((id) => ACTIVE_CHAINS_BY_ID[id]) + Object.values(originChains), + fromChainIds?.map((id) => originChains[id]) ) ) diff --git a/packages/synapse-interface/components/_Transaction/_Transaction.tsx b/packages/synapse-interface/components/_Transaction/_Transaction.tsx index aea06e61b6..a93683fc4e 100644 --- a/packages/synapse-interface/components/_Transaction/_Transaction.tsx +++ b/packages/synapse-interface/components/_Transaction/_Transaction.tsx @@ -20,7 +20,6 @@ import { RightArrow } from '@/components/icons/RightArrow' import { Address } from 'viem' import { useIsTxReverted } from './helpers/useIsTxReverted' import { useTxRefundStatus } from './helpers/useTxRefundStatus' -import { HYPERLIQUID } from '@/constants/chains/master' import { DISCORD_URL } from '@/constants/urls' interface _TransactionProps { @@ -91,7 +90,7 @@ export const _Transaction = ({ isCheckTxForRefund = isEstimatedTimeReached } - const [isTxCompleted, _kappa] = useBridgeTxStatus({ + const [isTxCompleted, _kappa, deliveryChainId] = useBridgeTxStatus({ originChainId: originChain?.id, destinationChainId: destinationChain?.id, originTxHash, @@ -116,15 +115,16 @@ export const _Transaction = ({ ['SynapseRFQ', 'Gas.zip'].includes(bridgeModuleName) ) - useBridgeTxUpdater( + useBridgeTxUpdater({ connectedAddress, destinationChain, - _kappa, + kappa: _kappa, originTxHash, - isTxCompleted, + isTxComplete: isTxCompleted, isTxReverted, - isTxRefunded - ) + isTxRefunded, + deliveryChainId, + }) // Show transaction support if the transaction is delayed by more than 5 minutes and not finalized or reverted const showTransactionSupport = @@ -190,15 +190,13 @@ export const _Transaction = ({ iconUrl={originChain?.explorerImg} /> )} - {destinationChain.id !== HYPERLIQUID.id && - !isNull(destExplorerAddressLink) && - !isTxReverted && ( - - )} + {!isNull(destExplorerAddressLink) && !isTxReverted && ( + + )} token.routeSymbol === tx.originToken?.routeSymbol ) - const destinationChain = CHAINS_BY_ID[tx.destinationChain?.id] + const destinationChain = + tx.destinationChain?.chainSymbol === HYPERLIQUID.chainSymbol + ? HYPERLIQUID + : CHAINS_BY_ID[tx.destinationChain?.id] const destinationToken = _(ALL_TOKENS).find( (token) => token.routeSymbol === tx.destinationToken?.routeSymbol ) @@ -63,7 +67,11 @@ export const _Transactions = ({ timestamp={tx.timestamp} currentTime={currentTime} status={ - tx.destinationChain.id === HYPERLIQUID.id + !tx.bridgeModuleName && + isHyperliquidUsdcDeposit( + destinationChain?.id, + destinationToken + ) ? 'completed' : tx.status } diff --git a/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxStatus.ts b/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxStatus.ts index faea36bb3a..ff6d37cadf 100644 --- a/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxStatus.ts +++ b/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxStatus.ts @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { useSynapseContext } from '@/utils/providers/SynapseProvider' +import { HYPERLIQUID } from '@/constants/chains/master' interface UseBridgeTxStatusProps { originChainId: number @@ -23,10 +24,11 @@ export const useBridgeTxStatus = ({ kappa, checkStatus = false, currentTime, -}: UseBridgeTxStatusProps): [boolean, string] => { +}: UseBridgeTxStatusProps): [boolean, string, number?] => { const { synapseSDK } = useSynapseContext() const [isComplete, setIsComplete] = useState(false) const [fetchedKappa, setFetchedKappa] = useState(kappa ?? null) + const [deliveryChainId, setDeliveryChainId] = useState() useEffect(() => { if (!checkStatus) return @@ -43,6 +45,19 @@ export const useBridgeTxStatus = ({ } if (fetchedKappa) { + if ( + bridgeModuleName === 'SYN' && + destinationChainId === HYPERLIQUID.id + ) { + const deliveredToChainId = await getSynDeliveryChainId( + synapseSDK, + destinationChainId, + fetchedKappa + ) + setDeliveryChainId(deliveredToChainId) + setIsComplete(deliveredToChainId !== undefined) + return + } const txStatus = await getBridgeTxStatus( synapseSDK, destinationChainId, @@ -59,7 +74,23 @@ export const useBridgeTxStatus = ({ })() }, [currentTime, checkStatus, fetchedKappa]) - return [isComplete, fetchedKappa] + return [isComplete, fetchedKappa, deliveryChainId] +} + +const getSynDeliveryChainId = async ( + synapseSDK: any, + destinationChainId: number, + txHash: string +): Promise => { + try { + return await synapseSDK?.synModuleSet?.getBridgeDeliveryChainId( + destinationChainId, + txHash + ) + } catch (error) { + console.error('Error in getBridgeDeliveryChainId:', error) + return undefined + } } const getKappa = async ( diff --git a/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxUpdater.ts b/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxUpdater.ts index 9466b9f670..1d611e81b8 100644 --- a/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxUpdater.ts +++ b/packages/synapse-interface/components/_Transaction/helpers/useBridgeTxUpdater.ts @@ -11,26 +11,30 @@ import { import { fetchAndStoreSingleNetworkPortfolioBalances } from '@/slices/portfolio/hooks' import { use_TransactionsState } from '@/slices/_transactions/hooks' import { type Chain } from '@/utils/types' +import { CHAINS_BY_ID } from '@/constants/chains' -/** - * Hook that updates bridge transaction in state. - * - * @param {string} connectedAddress - The signer executing bridge transactions. - * @param {Chain} destinationChain - The destination chain of bridge transaction. - * @param {string} kappa - The Synapse transaction ID queried from SDK. - * @param {string} originTxHash - The transaction hash returned when initiating the bridge transaction. - * @param {boolean} isTxComplete - Whether bridge transaction has completed, queried from SDK. - * @param {boolean} isTxReverted - Whether bridge transaction was reverted, queried on-chain. - */ -export const useBridgeTxUpdater = ( - connectedAddress: string, - destinationChain: Chain, - kappa: string, - originTxHash: string, - isTxComplete: boolean, - isTxReverted: boolean, +interface UseBridgeTxUpdaterProps { + connectedAddress: string + destinationChain: Chain + kappa: string + originTxHash: string + isTxComplete: boolean + isTxReverted: boolean isTxRefunded: boolean -) => { + deliveryChainId?: number +} + +/** Updates the stored bridge transaction and destination balances. */ +export const useBridgeTxUpdater = ({ + connectedAddress, + destinationChain, + kappa, + originTxHash, + isTxComplete, + isTxReverted, + isTxRefunded, + deliveryChainId, +}: UseBridgeTxUpdaterProps) => { const dispatch = useAppDispatch() const { transactions } = use_TransactionsState() const storedTx: _TransactionDetails = transactions.find( @@ -63,16 +67,33 @@ export const useBridgeTxUpdater = ( if (isTxComplete && originTxHash && kappa) { /** Check that we have not already marked tx as complete */ if (storedTx.status !== 'completed') { - dispatch(completeTransaction({ originTxHash, kappa })) + const deliveredToChain = deliveryChainId + ? CHAINS_BY_ID[deliveryChainId] + : destinationChain + dispatch( + completeTransaction({ + originTxHash, + kappa, + destinationChain: deliveredToChain, + }) + ) /** Update Destination Chain token balances after tx is marked complete */ dispatch( fetchAndStoreSingleNetworkPortfolioBalances({ address: connectedAddress, - chainId: destinationChain.id, + chainId: deliveredToChain.id, }) ) } } - }, [isTxComplete, dispatch, transactions, originTxHash, kappa]) + }, [ + isTxComplete, + dispatch, + transactions, + originTxHash, + kappa, + destinationChain, + deliveryChainId, + ]) } diff --git a/packages/synapse-interface/constants/chains/extraWagmiChains.ts b/packages/synapse-interface/constants/chains/extraWagmiChains.ts index c8f4740b00..8fed0ef693 100644 --- a/packages/synapse-interface/constants/chains/extraWagmiChains.ts +++ b/packages/synapse-interface/constants/chains/extraWagmiChains.ts @@ -125,8 +125,8 @@ export const hyperEVM = { }, blockExplorers: { default: { - name: 'Purrsec', - url: 'https://purrsec.com', + name: 'Hyperscan', + url: 'https://hyperscan.com', }, }, contracts: { diff --git a/packages/synapse-interface/constants/chains/master.tsx b/packages/synapse-interface/constants/chains/master.tsx index 853a463ac4..78a9161f01 100644 --- a/packages/synapse-interface/constants/chains/master.tsx +++ b/packages/synapse-interface/constants/chains/master.tsx @@ -24,16 +24,13 @@ import lineaImg from '@assets/chains/linea.svg' import worldchainImg from '@assets/chains/worldchain.svg' import unichainImg from '@assets/chains/unichain.svg' import berachainImg from '@assets/chains/berachain.svg' - import ethExplorerImg from '@assets/explorer/etherscan.svg' import arbitrumExplorerImg from '@assets/explorer/arbiscan.svg' import bnbExplorerImg from '@assets/explorer/bscscan.svg' import baseExplorerImg from '@assets/explorer/basescan.svg' import avalancheExplorerImg from '@assets/explorer/snowscan.svg' import fantomExplorerImg from '@assets/explorer/ftmscan.svg' - import { zeroAddress } from 'viem' - import { Chain } from '@types' export const ETH: Chain = { @@ -44,9 +41,8 @@ export const ETH: Chain = { chainImg: ethImg, layer: 1, rpcUrls: { - primary: - 'https://eth-mainnet.g.alchemy.com/v2/rJ3f0IWjZbpgEwnzrRS6yYO3WNH0jGle', - fallback: 'https://eth.llamarpc.com', + primary: 'https://ethereum-rpc.publicnode.com', + fallback: 'https://eth.drpc.org', }, explorerUrl: 'https://etherscan.io', explorerName: 'Etherscan', @@ -71,8 +67,7 @@ export const ARBITRUM: Chain = { layer: 2, blockTime: 300, rpcUrls: { - primary: - 'https://arb-mainnet.g.alchemy.com/v2/7kjdkqKTh1zQ1mRYGi4nJJbxbyJXHkef', + primary: 'https://arbitrum-one-rpc.publicnode.com', fallback: 'https://arb1.arbitrum.io/rpc', }, nativeCurrency: { @@ -173,8 +168,7 @@ export const OPTIMISM: Chain = { layer: 2, blockTime: 2000, rpcUrls: { - primary: - 'https://opt-mainnet.g.alchemy.com/v2/x1--EvECmiLCc4IRpo1granp1S7xxbxQ', + primary: 'https://optimism-rpc.publicnode.com', fallback: 'https://gateway.tenderly.co/public/optimism', }, nativeCurrency: { @@ -199,8 +193,7 @@ export const POLYGON: Chain = { layer: 2, blockTime: 2000, rpcUrls: { - primary: - 'https://polygon-mainnet.g.alchemy.com/v2/mN1t8Oc6E912QF28iPHaRvVEmv6EpYSs', + primary: 'https://polygon-bor-rpc.publicnode.com', fallback: 'https://polygon.drpc.org', }, nativeCurrency: { @@ -250,7 +243,7 @@ export const KLAYTN: Chain = { layer: 1, blockTime: 1000, rpcUrls: { - primary: 'https://kaia.blockpi.network/v1/rpc/public', + primary: 'https://public-en.node.kaia.io', fallback: 'https://internal.klaytn.rpc.defikingdoms.com/api=654302102', }, nativeCurrency: { @@ -499,8 +492,7 @@ export const BASE: Chain = { chainImg: baseImg, layer: 2, rpcUrls: { - primary: - 'https://base-mainnet.g.alchemy.com/v2/_YKy-Vm3LsknT8JKSa2ZTSmKu9Qp01Vd', + primary: 'https://base-rpc.publicnode.com', fallback: 'https://mainnet.base.org', }, explorerUrl: 'https://basescan.org', @@ -621,7 +613,7 @@ export const WORLDCHAIN: Chain = { export const HYPERLIQUID: Chain = { priorityRank: 99, - id: 998, // this is Hyperliquid Testnet from their docs + id: 1337, // HyperCore routing identifier, not an EVM RPC chain ID. chainSymbol: 'HYPERLIQUID', name: 'Hyperliquid', chainImg: hyperliquidImg, @@ -639,9 +631,9 @@ export const HYPERLIQUID: Chain = { address: zeroAddress, icon: ethImg, }, - explorerUrl: 'https://arbiscan.io', - explorerName: 'Arbiscan', - explorerImg: arbitrumExplorerImg, + explorerUrl: 'https://app.hyperliquid.xyz/explorer', + explorerName: 'Hyperliquid', + explorerImg: hyperliquidImg, color: 'gray', } @@ -709,8 +701,8 @@ export const HYPEREVM: Chain = { primary: 'https://rpc.hyperliquid.xyz/evm', fallback: 'https://hyperliquid.drpc.org', }, - explorerUrl: 'https://purrsec.com', - explorerName: 'Purrsec', + explorerUrl: 'https://hyperscan.com', + explorerName: 'Hyperscan', explorerImg: hyperliquidImg, blockTime: 2000, nativeCurrency: { diff --git a/packages/synapse-interface/constants/existingBridgeRoutes.ts b/packages/synapse-interface/constants/existingBridgeRoutes.ts index 3f1213399a..74bd523758 100644 --- a/packages/synapse-interface/constants/existingBridgeRoutes.ts +++ b/packages/synapse-interface/constants/existingBridgeRoutes.ts @@ -2,7 +2,7 @@ import _ from 'lodash' import { BRIDGE_MAP } from '@/constants/bridgeMap' import { flattenPausedTokens } from '@/utils/flattenPausedTokens' -import { HYPERLIQUID } from './chains/master' +import { ETH, HYPEREVM, HYPERLIQUID } from './chains/master' export type BridgeRoutes = Record @@ -65,3 +65,20 @@ const PAUSED_TOKENS = flattenPausedTokens() export const EXISTING_BRIDGE_ROUTES: BridgeRoutes = addUSDCHyperLiquid( constructJSON(BRIDGE_MAP, PAUSED_TOKENS) ) + +// Separate from the legacy SYN graph, which would advertise every SYN chain. +const synRoutes: BridgeRoutes = { + [`SYN-${ETH.id}`]: [`SYN-${HYPEREVM.id}`, `SYN-${HYPERLIQUID.id}`], + [`SYN-${HYPEREVM.id}`]: [`SYN-${ETH.id}`], +} +Object.entries(synRoutes).forEach(([origin, destinations]) => { + if (PAUSED_TOKENS.includes(origin)) return + EXISTING_BRIDGE_ROUTES[origin] = [ + ...new Set([ + ...(EXISTING_BRIDGE_ROUTES[origin] ?? []), + ...destinations.filter( + (destination) => !PAUSED_TOKENS.includes(destination) + ), + ]), + ] +}) diff --git a/packages/synapse-interface/constants/tokens/bridgeable.ts b/packages/synapse-interface/constants/tokens/bridgeable.ts index b475602d81..9dbe4f3755 100644 --- a/packages/synapse-interface/constants/tokens/bridgeable.ts +++ b/packages/synapse-interface/constants/tokens/bridgeable.ts @@ -595,6 +595,8 @@ export const ONEETH = new Token({ export const SYN = new Token({ addresses: { [CHAINS.ETH.id]: '0x0f2d719407fdbeff09d87557abb7232601fd9f29', + [CHAINS.HYPEREVM.id]: '0x6Cd6C5fDE1621053eD4BC7C09ADc76612D8C663f', + [CHAINS.HYPERLIQUID.id]: '0xf5f05eb8b9aa92365465f06daf5889c9', [CHAINS.BNB.id]: '0xa4080f1778e69467e905b8d6f72f6e441f9e9484', [CHAINS.POLYGON.id]: '0xf8f9efc0db77d8881500bb06ff5d6abc3070e695', [CHAINS.FANTOM.id]: '0xE55e19Fb4F2D85af758950957714292DAC1e25B2', @@ -613,7 +615,12 @@ export const SYN = new Token({ [CHAINS.BASE.id]: '0x432036208d2717394d2614d6697c46DF3Ed69540', [CHAINS.BLAST.id]: '0x9592f08387134e218327E6E8423400eb845EdE0E', }, - decimals: 18, + decimals: Object.fromEntries( + Object.values(CHAINS).map(({ id }) => [ + id, + id === CHAINS.HYPERLIQUID.id ? 8 : 18, + ]) + ), symbol: 'SYN', name: 'Synapse', logo: synapseLogo, diff --git a/packages/synapse-interface/messages/en-US.json b/packages/synapse-interface/messages/en-US.json index 78f7d7fa5c..46ff8d8872 100644 --- a/packages/synapse-interface/messages/en-US.json +++ b/packages/synapse-interface/messages/en-US.json @@ -15,6 +15,9 @@ "Send your assets across chains": "Send your assets across chains.", "Bridge paused": "Bridge paused", "BridgeModulePauseWarningMessage": "Bridge is paused for this route. Quotes may be less favorable.", + "SynHyperCoreRecipientWarningTitle": "SYN will arrive on HyperEVM", + "SynHyperCoreRecipientWarningMessage": "This wallet isn’t active on Hyperliquid yet. SYN will arrive at the same address on HyperEVM, a separate network.", + "SynHyperCoreRecipientWarningAccept": "I understand SYN will arrive on HyperEVM.", "Please select Origin Network": "Please select Origin Network", "Please select Destination network": "Please select Destination network", "Please select an Origin token": "Please select an Origin token", diff --git a/packages/synapse-interface/package.json b/packages/synapse-interface/package.json index e1d7f0a0c1..0033dda4a3 100644 --- a/packages/synapse-interface/package.json +++ b/packages/synapse-interface/package.json @@ -1,6 +1,6 @@ { "name": "@synapsecns/synapse-interface", - "version": "0.44.5", + "version": "0.45.1", "private": true, "engines": { "node": ">=18" @@ -13,6 +13,7 @@ "lint": "next lint", "cypress:run": "echo 'No cypress tests defined.'", "test:coverage": "echo 'No tests defined.'", + "test:rpc": "node --test utils/providers/TransportAwareJsonRpcProvider.test.cjs", "lint:fix": "npm run lint -- --fix", "lint:check": "eslint . --max-warnings=0", "lint:find-string-literals": "npx eslint -c .eslintrc-i18n.js './**/*.{js,jsx,ts,tsx}'", @@ -42,7 +43,7 @@ "@reduxjs/toolkit": "^1.9.5", "@rtk-query/graphql-request-base-query": "^2.2.0", "@segment/analytics-next": "^1.53.0", - "@synapsecns/sdk-router": "^0.23.3", + "@synapsecns/sdk-router": "^0.23.4", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.9", diff --git a/packages/synapse-interface/pages/state-managed-bridge/index.tsx b/packages/synapse-interface/pages/state-managed-bridge/index.tsx index 4ebc04b817..1176137d39 100644 --- a/packages/synapse-interface/pages/state-managed-bridge/index.tsx +++ b/packages/synapse-interface/pages/state-managed-bridge/index.tsx @@ -25,7 +25,10 @@ import SettingsSlideOver from '@/components/StateManagedBridge/SettingsSlideOver import Button from '@/components/ui/tailwind/Button' import { SettingsToggle } from '@/components/StateManagedBridge/SettingsToggle' import { BridgeCard } from '@/components/ui/BridgeCard' -import { ConfirmDestinationAddressWarning } from '@/components/StateManagedBridge/BridgeWarnings' +import { + ConfirmDestinationAddressWarning, + SynHyperCoreRecipientWarning, +} from '@/components/StateManagedBridge/BridgeWarnings' import { CHAINS_BY_ID } from '@/constants/chains' import { segmentAnalyticsEvent } from '@/contexts/SegmentAnalyticsProvider' import { useBridgeState } from '@/slices/bridge/hooks' @@ -79,6 +82,10 @@ import { USDC } from '@/constants/tokens/bridgeable' import { CheckCircleIcon } from '@heroicons/react/outline' import Image from 'next/image' import { HyperliquidDepositInfo } from '@/components/HyperliquidDepositInfo' +import { + getBridgeDestinationChainId, + isHyperliquidUsdcDeposit, +} from '@/utils/hyperliquid' const StateManagedBridge = () => { const dispatch = useAppDispatch() @@ -109,7 +116,41 @@ const StateManagedBridge = () => { destinationAddress, }: BridgeState = useBridgeState() + const isUsdcDeposit = isHyperliquidUsdcDeposit(toChainId, toToken) + const isDirectUsdcDeposit = fromChainId === ARBITRUM.id && isUsdcDeposit + const bridgeDestinationAddress = isUsdcDeposit + ? undefined + : destinationAddress + const bridgeDestinationChainId = getBridgeDestinationChainId( + toChainId, + toToken + ) + const { bridgeQuote, isLoading } = useBridgeQuoteState() + const [acceptedSynRecipient, setAcceptedSynRecipient] = useState(null) + const synRecipient = ( + destinationAddress && isAddress(destinationAddress) + ? destinationAddress + : address + )?.toLowerCase() + const isSynHyperCore = + toChainId === HYPERLIQUID.id && + fromToken?.routeSymbol === 'SYN' && + toToken?.routeSymbol === 'SYN' + const checkedRecipient = bridgeQuote.hyperCoreRecipient + const hasCheckedSynRecipient = + !!synRecipient && checkedRecipient?.address.toLowerCase() === synRecipient + const showSynRecipientWarning = + isSynHyperCore && hasCheckedSynRecipient && !checkedRecipient.isActive + const isRecipientWarningAccepted = + !isSynHyperCore || + !synRecipient || + (hasCheckedSynRecipient && + (checkedRecipient.isActive || acceptedSynRecipient === synRecipient)) + + useEffect(() => { + setAcceptedSynRecipient(null) + }, [synRecipient, fromChainId, toChainId, fromToken, toToken]) const isApproved = useIsBridgeApproved() @@ -154,6 +195,7 @@ const StateManagedBridge = () => { console.log('trying to set bridge quote') getAndSetBridgeQuote() } else { + currentSDKRequestID.current += 1 dispatch(resetBridgeQuote()) } }, [ @@ -181,18 +223,19 @@ const StateManagedBridge = () => { fetchBridgeQuote({ synapseSDK, fromChainId, - toChainId: toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId, + toChainId: bridgeDestinationChainId, fromToken, toToken, debouncedFromValue, requestId: thisRequestId, currentTimestamp, address, - destinationAddress, + destinationAddress: bridgeDestinationAddress, pausedModulesList, }) ) + if (thisRequestId !== currentSDKRequestID.current) return toast.dismiss(quoteToastRef.current.id) if (fetchBridgeQuote.fulfilled.match(result)) { @@ -210,10 +253,7 @@ const StateManagedBridge = () => { quoteToastRef.current.id = toast(message, { duration: 3000 }) } - if ( - fetchBridgeQuote.rejected.match(result) && - !(fromChainId === ARBITRUM.id && toChainId === HYPERLIQUID.id) - ) { + if (fetchBridgeQuote.rejected.match(result) && !isDirectUsdcDeposit) { const message = t( 'No route found for bridging {debouncedFromValue} {fromToken} on {fromChainId} to {toToken} on {toChainId}', { @@ -290,12 +330,13 @@ const StateManagedBridge = () => { } const executeBridge = async () => { + if (!isRecipientWarningAccepted) return let pendingPopup: any const pendingTransactionId: number = getUnixTimeSecondsNow() - if (destinationAddress) { - const isRisky = await screenAddress(destinationAddress) + if (bridgeDestinationAddress) { + const isRisky = await screenAddress(bridgeDestinationAddress) if (isRisky) { return } @@ -306,8 +347,7 @@ const StateManagedBridge = () => { { id: bridgeQuote.id, originChainId: fromChainId, - destinationChainId: - toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId, + destinationChainId: bridgeDestinationChainId, inputAmount: debouncedFromValue, expectedReceivedAmount: bridgeQuote.outputAmountString, slippage: bridgeQuote.exchangeRate, @@ -326,15 +366,14 @@ const StateManagedBridge = () => { originChain: CHAINS_BY_ID[fromChainId], originToken: fromToken, originValue: debouncedFromValue, - destinationChain: - CHAINS_BY_ID[toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId], + destinationChain: CHAINS_BY_ID[bridgeDestinationChainId], destinationToken: toToken, transactionHash: undefined, timestamp: undefined, isSubmitted: false, estimatedTime: bridgeQuote.estimatedTime, bridgeModuleName: bridgeQuote.bridgeModuleName, - destinationAddress: destinationAddress, + destinationAddress: bridgeDestinationAddress, routerAddress: bridgeQuote.routerAddress, }) ) @@ -378,8 +417,7 @@ const StateManagedBridge = () => { segmentAnalyticsEvent(`[Bridge] bridges successfully`, { id: bridgeQuote.id, originChainId: fromChainId, - destinationChainId: - toChainId === HYPERLIQUID.id ? ARBITRUM.id : toChainId, + destinationChainId: bridgeDestinationChainId, inputAmount: debouncedFromValue, expectedReceivedAmount: bridgeQuote.outputAmountString, slippage: bridgeQuote.exchangeRate, @@ -434,7 +472,7 @@ const StateManagedBridge = () => { timeout: 60_000, }) - if (toChainId === HYPERLIQUID.id) { + if (isUsdcDeposit) { dispatch(setFromChainId(ARBITRUM.id)) dispatch(setFromToken(USDC)) dispatch(setToChainId(HYPERLIQUID.id)) @@ -514,30 +552,34 @@ const StateManagedBridge = () => { dispatch(setToChainId(fromChainId)) dispatch(setToToken(fromToken)) }} - disabled={isWalletPending} + disabled={isWalletPending || toChainId === HYPERLIQUID.id} /> - {!( - fromChainId === ARBITRUM.id && toChainId === HYPERLIQUID.id - ) && } + {!isDirectUsdcDeposit && } - {toChainId === HYPERLIQUID.id && ( + {isUsdcDeposit && ( )} - + {!isUsdcDeposit && } + {showSynRecipientWarning && ( + + setAcceptedSynRecipient(accepted ? synRecipient : null) + } + /> + )}
- {fromChainId === ARBITRUM.id && toChainId === HYPERLIQUID.id ? ( + {isDirectUsdcDeposit ? ( { executeBridge={executeBridge} isBridgePaused={isBridgePaused} isQuoteStale={isQuoteStale} + isRecipientWarningAccepted={isRecipientWarningAccepted} /> )}
diff --git a/packages/synapse-interface/scripts/checkRpcHealth.js b/packages/synapse-interface/scripts/checkRpcHealth.js index be2ed80aca..57dfc0f8bd 100644 --- a/packages/synapse-interface/scripts/checkRpcHealth.js +++ b/packages/synapse-interface/scripts/checkRpcHealth.js @@ -19,7 +19,7 @@ const TIMEOUT_MS = 10000 const CONCURRENCY = 10 // Chains where chain ID mismatch is expected (deliberate choice) -const IGNORE_CHAIN_ID_MISMATCH = [998] // Hyperliquid +const IGNORE_CHAIN_ID_MISMATCH = [1337] // Hyperliquid's USDC deposit uses Arbitrum const PACKAGE_PATHS = { 'synapse-interface': '../constants/chains/master.tsx', diff --git a/packages/synapse-interface/slices/_transactions/reducer.ts b/packages/synapse-interface/slices/_transactions/reducer.ts index cac413c616..27e78db4bc 100644 --- a/packages/synapse-interface/slices/_transactions/reducer.ts +++ b/packages/synapse-interface/slices/_transactions/reducer.ts @@ -64,15 +64,22 @@ export const transactionsSlice = createSlice({ }, completeTransaction: ( state, - action: PayloadAction<{ originTxHash: string; kappa: string }> + action: PayloadAction<{ + originTxHash: string + kappa: string + destinationChain?: Chain + }> ) => { - const { originTxHash } = action.payload + const { originTxHash, destinationChain } = action.payload const txIndex = state.transactions.findIndex( (tx) => tx.originTxHash === originTxHash ) if (txIndex !== -1) { state.transactions[txIndex].status = 'completed' + if (destinationChain) { + state.transactions[txIndex].destinationChain = destinationChain + } } }, revertTransaction: ( diff --git a/packages/synapse-interface/slices/bridgeQuote/reducer.ts b/packages/synapse-interface/slices/bridgeQuote/reducer.ts index 898769f622..94d18e79bb 100644 --- a/packages/synapse-interface/slices/bridgeQuote/reducer.ts +++ b/packages/synapse-interface/slices/bridgeQuote/reducer.ts @@ -8,6 +8,7 @@ export interface BridgeQuoteState { bridgeQuote: BridgeQuote previousBridgeQuote: BridgeQuote | null isLoading: boolean + currentRequestId?: string } export const initialState: BridgeQuoteState = { @@ -25,6 +26,8 @@ export const bridgeQuoteSlice = createSlice({ }, resetBridgeQuote: (state) => { state.bridgeQuote = initialState.bridgeQuote + state.currentRequestId = undefined + state.isLoading = false }, setPreviousBridgeQuote: (state, action: PayloadAction) => { state.previousBridgeQuote = action.payload @@ -32,17 +35,17 @@ export const bridgeQuoteSlice = createSlice({ }, extraReducers: (builder) => { builder - .addCase(fetchBridgeQuote.pending, (state) => { + .addCase(fetchBridgeQuote.pending, (state, action) => { state.isLoading = true + state.currentRequestId = action.meta.requestId }) - .addCase( - fetchBridgeQuote.fulfilled, - (state, action: PayloadAction) => { - state.bridgeQuote = action.payload - state.isLoading = false - } - ) - .addCase(fetchBridgeQuote.rejected, (state) => { + .addCase(fetchBridgeQuote.fulfilled, (state, action) => { + if (state.currentRequestId !== action.meta.requestId) return + state.bridgeQuote = action.payload + state.isLoading = false + }) + .addCase(fetchBridgeQuote.rejected, (state, action) => { + if (state.currentRequestId !== action.meta.requestId) return state.bridgeQuote = EMPTY_BRIDGE_QUOTE state.isLoading = false }) diff --git a/packages/synapse-interface/slices/bridgeQuote/thunks.ts b/packages/synapse-interface/slices/bridgeQuote/thunks.ts index 7f9cad5d64..45e7283201 100644 --- a/packages/synapse-interface/slices/bridgeQuote/thunks.ts +++ b/packages/synapse-interface/slices/bridgeQuote/thunks.ts @@ -4,13 +4,13 @@ import { Address, isAddress, zeroAddress } from 'viem' import { getErc20TokenAllowance } from '@/actions/getErc20TokenAllowance' import { AcceptedChainId, CHAINS_BY_ID } from '@/constants/chains' +import { HYPERLIQUID } from '@/constants/chains/master' import { segmentAnalyticsEvent } from '@/contexts/SegmentAnalyticsProvider' import { stringToBigInt, formatBigIntToString } from '@/utils/bigint/format' import { calculateExchangeRate } from '@/utils/calculateExchangeRate' import { getPausedBridgeModuleNamesForRoute } from '@/utils/getPausedBridgeModuleNamesForRoute' import { Token } from '@/utils/types' import { BridgeModulePause } from '@/components/Maintenance/Maintenance' -import { HYPERLIQUID } from '@/constants/chains/master' export const fetchBridgeQuote = createAsyncThunk( 'bridgeQuote/fetchBridgeQuote', @@ -131,13 +131,24 @@ export const fetchBridgeQuote = createAsyncThunk( tx, } = quote - if (!(expectedToAmount && minToAmount && toChainId !== HYPERLIQUID.id)) { + if (!(expectedToAmount && minToAmount)) { const msg = `No route found for bridging ${debouncedFromValue} ${fromToken?.symbol} on ${CHAINS_BY_ID[fromChainId]?.name} to ${toToken?.symbol} on ${CHAINS_BY_ID[toChainId]?.name}` return rejectWithValue(msg) } const toValueBigInt = BigInt(expectedToAmount) ?? 0n const bridgeModuleName = moduleNames[moduleNames.length - 1] + // Only a confirmed inactive SYN recipient needs the HyperEVM acknowledgement. + // Let a failed check reject the quote rather than treating it as active. + const hyperCoreRecipient = + bridgeModuleName === 'SYN' && toChainId === HYPERLIQUID.id && toRecipient + ? { + address: toRecipient, + isActive: await synapseSDK.synModuleSet.isHyperCoreAccountActive( + toRecipient + ), + } + : undefined const isUnsupported = AcceptedChainId[fromChainId] ? false : true @@ -172,6 +183,7 @@ export const fetchBridgeQuote = createAsyncThunk( delta: toValueBigInt, estimatedTime, bridgeModuleName, + hyperCoreRecipient, gasDropAmount: BigInt(gasDropAmount), timestamp: currentTimestamp, originChainId, diff --git a/packages/synapse-interface/utils/actions/fetchPortfolioBalances.tsx b/packages/synapse-interface/utils/actions/fetchPortfolioBalances.tsx index 3081f4b95c..eff76cbb9b 100644 --- a/packages/synapse-interface/utils/actions/fetchPortfolioBalances.tsx +++ b/packages/synapse-interface/utils/actions/fetchPortfolioBalances.tsx @@ -7,6 +7,7 @@ import { } from '@/constants/tokens' import { FetchState } from '@/slices/portfolio/actions' import { isActiveChainId } from '@/constants/chains' +import { HYPERLIQUID } from '@/constants/chains/master' export interface TokenAndBalance { token: Token @@ -48,7 +49,9 @@ export const fetchPortfolioBalances = async ( const poolTokenBalances = {} const availableChains: string[] = Object.keys(BRIDGABLE_TOKENS).filter( - (id) => id !== '2000' && isActiveChainId(id) + // HyperCore is a virtual destination and has no EVM balance provider. + (id) => + id !== '2000' && isActiveChainId(id) && Number(id) !== HYPERLIQUID.id ) const isSingleNetworkCall: boolean = typeof chainId === 'number' diff --git a/packages/synapse-interface/utils/getBridgeModuleNames.ts b/packages/synapse-interface/utils/getBridgeModuleNames.ts index a378b38138..d1f1a38bf5 100644 --- a/packages/synapse-interface/utils/getBridgeModuleNames.ts +++ b/packages/synapse-interface/utils/getBridgeModuleNames.ts @@ -1,6 +1,8 @@ +import { BRIDGE_MODULE_NAMES } from '@/components/Maintenance/functions/isValidBridgeModule' + export const getBridgeModuleNames = (module) => { if (module.bridgeModuleName === 'ALL') { - return ['SynapseRFQ', 'SynapseCCTP', 'SynapseBridge'] + return [...BRIDGE_MODULE_NAMES] } return [module.bridgeModuleName] } diff --git a/packages/synapse-interface/utils/getPendingBridgeTransactionTrackingData.ts b/packages/synapse-interface/utils/getPendingBridgeTransactionTrackingData.ts index cab7776c9a..c85126a3e6 100644 --- a/packages/synapse-interface/utils/getPendingBridgeTransactionTrackingData.ts +++ b/packages/synapse-interface/utils/getPendingBridgeTransactionTrackingData.ts @@ -1,6 +1,7 @@ import { zeroAddress } from 'viem' import { HYPERLIQUID } from '@/constants/chains/master' +import { isHyperliquidUsdcDeposit } from '@/utils/hyperliquid' import { PendingBridgeTransaction } from '@/slices/transactions/actions' export const getPendingBridgeTransactionTrackingData = ( @@ -11,7 +12,17 @@ export const getPendingBridgeTransactionTrackingData = ( return null } - const isHyperliquidDeposit = tx.destinationChain?.id === HYPERLIQUID.id + // Resolve saved Hyperliquid activity by its stable symbol after an ID change. + const destinationChain = + tx.destinationChain?.chainSymbol === HYPERLIQUID.chainSymbol + ? HYPERLIQUID + : tx.destinationChain + const isHyperliquidDeposit = + !tx.bridgeModuleName && + isHyperliquidUsdcDeposit( + destinationChain?.id, + tx.destinationToken ?? tx.originToken + ) const hasTrackedTimestamp = typeof tx.timestamp === 'number' if (!hasTrackedTimestamp && !isHyperliquidDeposit) { @@ -25,7 +36,7 @@ export const getPendingBridgeTransactionTrackingData = ( originValue: tx.originValue, originChain: tx.originChain, originToken: tx.originToken, - destinationChain: tx.destinationChain, + destinationChain, destinationToken: tx.destinationToken ?? tx.originToken, bridgeModuleName: tx.bridgeModuleName ?? '', routerAddress: tx.routerAddress ?? zeroAddress, diff --git a/packages/synapse-interface/utils/hyperliquid.ts b/packages/synapse-interface/utils/hyperliquid.ts new file mode 100644 index 0000000000..c587fd1687 --- /dev/null +++ b/packages/synapse-interface/utils/hyperliquid.ts @@ -0,0 +1,13 @@ +import { ARBITRUM, HYPERLIQUID } from '@/constants/chains/master' + +// The existing Hyperliquid destination uses an Arbitrum deposit only for USDC. +// SYN reaches HyperCore directly through the OFT composer. +export const isHyperliquidUsdcDeposit = ( + toChainId: number, + toToken?: { routeSymbol?: string } +) => toChainId === HYPERLIQUID.id && toToken?.routeSymbol === 'USDC' + +export const getBridgeDestinationChainId = ( + toChainId: number, + toToken?: { routeSymbol?: string } +) => (isHyperliquidUsdcDeposit(toChainId, toToken) ? ARBITRUM.id : toChainId) diff --git a/packages/synapse-interface/utils/providers/SynapseProvider.tsx b/packages/synapse-interface/utils/providers/SynapseProvider.tsx index efdcb38806..e593f4cd52 100644 --- a/packages/synapse-interface/utils/providers/SynapseProvider.tsx +++ b/packages/synapse-interface/utils/providers/SynapseProvider.tsx @@ -2,11 +2,12 @@ import { SynapseSDK } from '@synapsecns/sdk-router' import { createContext, useContext, memo, useMemo } from 'react' import { - StaticJsonRpcProvider, FallbackProvider, FallbackProviderConfig, } from '@ethersproject/providers' +import { TransportAwareJsonRpcProvider } from './TransportAwareJsonRpcProvider' + export const SynapseContext = createContext(null) export const SynapseProvider = memo( @@ -21,7 +22,7 @@ export const SynapseProvider = memo( // Set priority based on list order const providerConfigs: FallbackProviderConfig[] = providerUrls.map( (url, index) => ({ - provider: new StaticJsonRpcProvider(url, chain.id), + provider: new TransportAwareJsonRpcProvider(url, chain.id), priority: index, stallTimeout: 750, }) diff --git a/packages/synapse-interface/utils/providers/TransportAwareJsonRpcProvider.test.cjs b/packages/synapse-interface/utils/providers/TransportAwareJsonRpcProvider.test.cjs new file mode 100644 index 0000000000..a8ede56d3c --- /dev/null +++ b/packages/synapse-interface/utils/providers/TransportAwareJsonRpcProvider.test.cjs @@ -0,0 +1,415 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const http = require('node:http') +const Module = require('node:module') +const path = require('node:path') +const test = require('node:test') + +const ts = require('typescript') +const { + FallbackProvider, + StaticJsonRpcProvider, +} = require('@ethersproject/providers') + +const sourcePath = path.join(__dirname, 'TransportAwareJsonRpcProvider.ts') +const source = fs.readFileSync(sourcePath, 'utf8') +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, +}).outputText +const compiledModule = new Module(sourcePath, module) +compiledModule.filename = sourcePath +compiledModule.paths = Module._nodeModulePaths(__dirname) +compiledModule._compile(compiled, sourcePath) +const { TransportAwareJsonRpcProvider } = compiledModule.exports + +const transaction = { + to: '0x0000000000000000000000000000000000000001', + data: '0x1234', +} +const callParams = { transaction, blockTag: 'latest' } +const testOptions = { timeout: 4000 } + +const createFallback = (primary, secondary) => { + return new FallbackProvider( + [ + { provider: primary, priority: 1, stallTimeout: 250 }, + { provider: secondary, priority: 2, stallTimeout: 250 }, + ], + 1 + ) +} + +// Exercise ethers' real HTTP parsing and error wrapping. Every fixture binds an +// ephemeral loopback port and tears down even sockets that never send a reply. +const endpoint = async (t, handler) => { + const requests = [] + const sockets = new Set() + const server = http.createServer((req, res) => { + let body = '' + req.on('data', (chunk) => (body += chunk)) + req.on('end', () => { + const request = JSON.parse(body) + requests.push(request) + if (request.method === 'eth_blockNumber') { + res.end( + JSON.stringify({ jsonrpc: '2.0', id: request.id, result: '0x1' }) + ) + } else { + handler(request, res) + } + }) + }) + server.on('connection', (socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + }) + t.after(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(resolve)) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + return { + url: `http://127.0.0.1:${server.address().port}`, + requests, + get calls() { + return requests.filter((request) => request.method === 'eth_call') + }, + } +} + +const reply = (status, body) => { + return (_request, response) => { + response.writeHead(status, { 'Content-Type': 'application/json' }) + response.end(typeof body === 'string' ? body : JSON.stringify(body)) + } +} + +const rpcError = (code, message, extra = {}, envelope = true) => { + return { + ...(envelope ? { jsonrpc: '2.0' } : {}), + id: 1, + error: { code, message, ...extra }, + } +} + +const healthyEndpoint = async (t) => { + return endpoint(t, (request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end( + JSON.stringify({ jsonrpc: '2.0', id: request.id, result: '0x1234' }) + ) + }) +} + +const outcome = async (action) => { + try { + return { result: await action() } + } catch (error) { + return { error } + } +} + +// Request IDs and stack traces differ across calls, but the RPC error, revert +// data, HTTP response, and nested transport cause must keep ethers' semantics. +const diagnostics = (error) => { + if (!error) return undefined + return Object.fromEntries( + [ + 'code', + 'reason', + 'status', + 'body', + 'data', + 'transaction', + 'error', + 'serverError', + ] + .filter((key) => error[key] !== undefined) + .map((key) => [ + key, + key === 'error' || key === 'serverError' + ? diagnostics(error[key]) + : error[key], + ]) + ) +} + +const endpointFailures = [ + { + name: 'HTTP 500 with a generic JSON error', + handler: reply(500, { error: { code: 500 } }), + reason: 'bad response', + status: 500, + }, + { + name: 'HTTP 403 with an inactive-team JSON-RPC error', + handler: reply(403, rpcError(-32600, 'This team has been deactivated')), + reason: 'bad response', + status: 403, + }, + { + name: 'HTTP 500 with a plain-text body', + handler: reply(500, 'upstream unavailable'), + reason: 'bad response', + status: 500, + }, + ...[ + [-32603, 'Internal error'], + [-32601, 'Method not supported'], + [-32002, 'Resource unavailable'], + [-32005, 'Limit exceeded'], + ].map(([code, message]) => ({ + name: `HTTP 200 JSON-RPC ${code} (${message})`, + handler: reply(200, rpcError(code, message)), + reason: 'processing response error', + rpcCode: code, + })), + { + name: 'a connection that closes without a response', + handler: (_request, response) => response.socket.destroy(), + reason: 'missing response', + serverCode: 'ECONNRESET', + }, + { + name: 'a connection that never responds before its timeout', + handler: () => {}, + connection: { timeout: 100 }, + reason: 'timeout', + code: 'TIMEOUT', + }, + { + name: 'HTTP 429 after exhausting the retry limit', + handler: reply(429, 'Too many requests'), + // ethers leaves its timeout timer alive on exhausted retries. Bound that + // timer too, without waiting through its production retry schedule. + connection: { throttleLimit: 1, throttleSlotInterval: 1, timeout: 200 }, + reason: 'failed response', + }, + { + name: 'malformed JSON in an HTTP 200 response', + handler: reply(200, '{not-json'), + reason: 'processing response error', + nestedReason: 'invalid JSON', + }, +] + +for (const failure of endpointFailures) { + test( + `${failure.name} reaches the healthy fallback`, + testOptions, + async (t) => { + const failed = await endpoint(t, failure.handler) + const healthy = await healthyEndpoint(t) + const primary = new TransportAwareJsonRpcProvider( + { url: failed.url, ...failure.connection }, + 1 + ) + const secondary = new TransportAwareJsonRpcProvider(healthy.url, 1) + + // Check the surfaced cause directly as well: stall-timeout failover alone + // must not make a still-wrapped CALL_EXCEPTION look like a passing test. + const direct = await outcome(() => primary.perform('call', callParams)) + assert.ok(direct.error) + assert.equal(direct.error.code, failure.code || 'SERVER_ERROR') + assert.equal(direct.error.reason, failure.reason) + if (failure.status) assert.equal(direct.error.status, failure.status) + if (failure.rpcCode) + assert.equal(direct.error.error.code, failure.rpcCode) + if (failure.serverCode) { + assert.equal(direct.error.serverError.code, failure.serverCode) + } + if (failure.nestedReason) { + assert.equal(direct.error.error.reason, failure.nestedReason) + } + assert.equal(direct.error.url, failed.url) + assert.equal(JSON.parse(direct.error.requestBody).method, 'eth_call') + + assert.equal( + await createFallback(primary, secondary).perform('call', callParams), + '0x1234' + ) + assert.equal(healthy.calls.length, 1) + assert.equal(healthy.calls[0].method, 'eth_call') + assert.equal(failed.calls.length, 2) + } + ) +} + +const preservedErrors = [ + { + statuses: [200], + name: 'an invalid request', + body: rpcError(-32600, 'Invalid request'), + }, + { + name: 'a nested data-less execution revert', + body: rpcError(-32603, 'Internal error', { + data: { originalError: { code: 3, message: 'execution reverted' } }, + }), + }, + { + name: 'a code 3 revert without data', + body: rpcError(3, 'execution reverted'), + }, + { + name: 'a revert without a jsonrpc marker', + body: rpcError(3, 'execution reverted', {}, false), + }, + { + name: 'a generic -32000 execution revert', + body: rpcError(-32000, 'execution reverted'), + }, + ...[ + ['custom error', '0xdeadbeef' + '0'.repeat(64)], + ['panic', '0x4e487b71' + '0'.repeat(62) + '11'], + ['empty revert', '0x'], + ].map(([name, data]) => ({ + name: `${name} data`, + body: rpcError(3, 'execution reverted', { data }), + result: data, + })), + { + name: 'invalid params', + body: rpcError(-32602, 'Invalid params'), + }, + { + statuses: [200, 400], + name: 'an ambiguous -32000 server error', + body: rpcError(-32000, 'Something went wrong'), + }, + { + statuses: [200, 400], + name: 'an unknown JSON-RPC error code', + body: rpcError(-32099, 'Unknown request error'), + }, +] + +for (const preserved of preservedErrors) { + for (const status of preserved.statuses || [200, 500]) { + test( + `HTTP ${status} ${preserved.name} preserves ethers behavior`, + testOptions, + async (t) => { + const failed = await endpoint(t, reply(status, preserved.body)) + const healthy = await healthyEndpoint(t) + const baseline = new StaticJsonRpcProvider(failed.url, 1) + const primary = new TransportAwareJsonRpcProvider(failed.url, 1) + const secondary = new TransportAwareJsonRpcProvider(healthy.url, 1) + const expected = await outcome(() => + baseline.perform('call', callParams) + ) + const direct = await outcome(() => primary.perform('call', callParams)) + const actual = await outcome(() => + createFallback(primary, secondary).perform('call', callParams) + ) + + if (preserved.result !== undefined) { + assert.equal(expected.result, preserved.result) + assert.deepEqual(direct, expected) + assert.deepEqual(actual, expected) + } else { + assert.equal(expected.error?.code, 'CALL_EXCEPTION') + assert.equal(actual.error?.code, 'CALL_EXCEPTION') + assert.deepEqual( + diagnostics(direct.error), + diagnostics(expected.error) + ) + assert.equal(actual.error.reason, expected.error.reason) + } + assert.equal(healthy.calls.length, 0) + assert.equal(failed.calls.length, 3) + } + ) + } +} + +test( + 'HTTP 400 invalid params remains a call exception', + testOptions, + async (t) => { + const failed = await endpoint( + t, + reply(400, rpcError(-32602, 'Invalid params')) + ) + const healthy = await healthyEndpoint(t) + const primary = new TransportAwareJsonRpcProvider(failed.url, 1) + const secondary = new TransportAwareJsonRpcProvider(healthy.url, 1) + await assert.rejects( + createFallback(primary, secondary).perform('call', callParams), + { code: 'CALL_EXCEPTION' } + ) + assert.equal(healthy.calls.length, 0) + } +) + +for (const method of ['getBalance', 'estimateGas', 'sendTransaction']) { + test( + `${method} retains normal ethers error behavior`, + testOptions, + async (t) => { + const failed = await endpoint(t, reply(500, 'upstream unavailable')) + const baseline = new StaticJsonRpcProvider(failed.url, 1) + const primary = new TransportAwareJsonRpcProvider(failed.url, 1) + const params = { + signedTransaction: '0x1234', + address: transaction.to, + transaction, + blockTag: 'latest', + } + const expected = await outcome(() => baseline.perform(method, params)) + const actual = await outcome(() => primary.perform(method, params)) + assert.ok(expected.error) + assert.deepEqual(diagnostics(actual.error), diagnostics(expected.error)) + } + ) +} + +test( + 'all endpoints failing retains nested HTTP and RPC diagnostics', + testOptions, + async (t) => { + const firstBody = { error: 'upstream unavailable' } + const secondBody = rpcError(-32603, 'Internal error') + const first = await endpoint(t, reply(500, firstBody)) + const second = await endpoint(t, reply(200, secondBody)) + const provider = createFallback( + new TransportAwareJsonRpcProvider(first.url, 1), + new TransportAwareJsonRpcProvider(second.url, 1) + ) + const { error } = await outcome(() => provider.perform('call', callParams)) + assert.equal(error?.code, 'SERVER_ERROR') + assert.equal(error.reason, 'failed to meet quorum') + assert.equal(error.results.length, 2) + const failures = error.results.map((result) => result.error) + const httpFailure = failures.find((failure) => failure.url === first.url) + const rpcFailure = failures.find((failure) => failure.url === second.url) + assert.equal(httpFailure.code, 'SERVER_ERROR') + assert.equal(httpFailure.status, 500) + assert.deepEqual(JSON.parse(httpFailure.body), firstBody) + assert.equal(rpcFailure.code, 'SERVER_ERROR') + assert.equal(rpcFailure.error.code, -32603) + assert.equal(rpcFailure.error.message, 'Internal error') + assert.deepEqual(JSON.parse(rpcFailure.body), secondBody) + assert.equal(first.calls.length, 1) + assert.equal(second.calls.length, 1) + } +) + +test( + 'a healthy primary answers without a fallback call', + testOptions, + async (t) => { + const first = await healthyEndpoint(t) + const second = await healthyEndpoint(t) + const provider = createFallback( + new TransportAwareJsonRpcProvider(first.url, 1), + new TransportAwareJsonRpcProvider(second.url, 1) + ) + assert.equal(await provider.perform('call', callParams), '0x1234') + assert.equal(first.calls.length, 1) + assert.equal(second.calls.length, 0) + } +) diff --git a/packages/synapse-interface/utils/providers/TransportAwareJsonRpcProvider.ts b/packages/synapse-interface/utils/providers/TransportAwareJsonRpcProvider.ts new file mode 100644 index 0000000000..dcce602ae7 --- /dev/null +++ b/packages/synapse-interface/utils/providers/TransportAwareJsonRpcProvider.ts @@ -0,0 +1,123 @@ +import { StaticJsonRpcProvider } from '@ethersproject/providers' + +type RpcError = { + code?: string | number + message?: string + reason?: string + status?: number + body?: unknown + error?: unknown + data?: unknown + originalError?: unknown +} + +const asError = (value: unknown): RpcError | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as RpcError) + : undefined + +const getResponseError = (body: unknown): RpcError | undefined => { + if (typeof body !== 'string') return undefined + try { + return asError(asError(JSON.parse(body))?.error) + } catch { + return undefined + } +} + +const isExecutionError = (error: RpcError, depth = 0): boolean => { + if ( + error.code === 3 || + (typeof error.message === 'string' && + /^(?:VM Exception while processing transaction: )?(?:(?:execution )?revert(?:ed)?|VM execution error|invalid opcode|out of gas)(?:$|[:.\s])/i.test( + error.message + )) + ) { + return true + } + if (depth >= 4) return false + + // Some RPCs wrap a data-less execution failure in an internal-error envelope. + // Only inspect known error paths; never search request payloads or arbitrary + // strings, and bound traversal for malformed/circular error objects. + return [error.error, error.data, error.originalError].some((value) => { + const nested = asError(value) + return nested !== undefined && isExecutionError(nested, depth + 1) + }) +} + +const isRpcError = (error: RpcError): boolean => + typeof error.code === 'number' && + Number.isInteger(error.code) && + typeof error.message === 'string' + +// These codes describe endpoint capabilities/availability, not EVM outcomes. +const RETRYABLE_RPC_CODES = new Set([-32601, -32603, -32002, -32005]) +const RETRYABLE_HTTP_STATUSES = new Set([401, 403, 404, 408, 429]) + +const isEndpointFailure = (error: RpcError): boolean => { + if (error.code === 'TIMEOUT') return true + if (error.code !== 'SERVER_ERROR') return false + + // ethers puts HTTP-200 RPC errors in error.error; non-2xx bodies remain text. + // A protocol marker alone cannot distinguish service errors from reverts. + const errors = [asError(error.error), getResponseError(error.body)].filter( + (item): item is RpcError => item !== undefined + ) + if (errors.some((item) => isExecutionError(item))) return false + + // Preserve malformed requests/parameters rather than masking caller bugs. + if (errors.some(({ code }) => code === -32700 || code === -32602)) + return false + + const status = error.status + if ( + typeof status === 'number' && + (RETRYABLE_HTTP_STATUSES.has(status) || (status >= 500 && status <= 599)) + ) { + return true + } + if ( + errors.some( + ({ code }) => typeof code === 'number' && RETRYABLE_RPC_CODES.has(code) + ) + ) { + return true + } + + // Without an explicit endpoint failure, preserve unknown RPC errors + // (including -32000 and invalid-request -32600). Hex data alone is not a revert. + if (errors.some(isRpcError)) return false + + // Known ethers transport failures can lack both an HTTP status and a body: + // disconnected sockets, exhausted HTTP-429 retries, and malformed responses. + return ( + (typeof status === 'number' && status >= 300 && status <= 599) || + error.reason === 'missing response' || + error.reason === 'failed response' || + error.reason === 'processing response error' + ) +} + +// ethers v5 wraps rejected eth_call requests in CALL_EXCEPTION, which is +// terminal at quorum one. Restore endpoint errors so FallbackProvider can try +// another RPC, while leaving execution results and all other methods alone. +export class TransportAwareJsonRpcProvider extends StaticJsonRpcProvider { + async perform(method: string, params: any): Promise { + try { + return await super.perform(method, params) + } catch (error) { + const rpcError = asError(error) + const cause = asError(rpcError?.error) + if ( + method === 'call' && + rpcError?.code === 'CALL_EXCEPTION' && + cause && + isEndpointFailure(cause) + ) { + throw cause + } + throw error + } + } +} diff --git a/packages/synapse-interface/utils/routeMaker/getToChainIds.ts b/packages/synapse-interface/utils/routeMaker/getToChainIds.ts index 50b942489f..b4eb58acea 100644 --- a/packages/synapse-interface/utils/routeMaker/getToChainIds.ts +++ b/packages/synapse-interface/utils/routeMaker/getToChainIds.ts @@ -261,13 +261,12 @@ export const getToChainIds = ({ } if (fromChainId && fromTokenRouteSymbol && toChainId && toTokenRouteSymbol) { - return _(EXISTING_BRIDGE_ROUTES) - .pickBy((values, _key) => - values.some((token) => token.startsWith(`${toTokenRouteSymbol}-`)) - ) - .keys() - .map((t) => getTokenAndChainId(t).chainId) - .uniq() - .value() + return [ + ...new Set( + EXISTING_BRIDGE_ROUTES[`${fromTokenRouteSymbol}-${fromChainId}`] + ?.filter((token) => token.startsWith(`${toTokenRouteSymbol}-`)) + .map((token) => getTokenAndChainId(token).chainId) + ), + ] } } diff --git a/packages/synapse-interface/utils/types/index.tsx b/packages/synapse-interface/utils/types/index.tsx index d76066344f..877be455d3 100644 --- a/packages/synapse-interface/utils/types/index.tsx +++ b/packages/synapse-interface/utils/types/index.tsx @@ -79,6 +79,7 @@ export type BridgeQuote = { originChainId: number destChainId: number requestId: number + hyperCoreRecipient?: { address: string; isActive: boolean } tx?: any } @@ -356,6 +357,14 @@ const validateAddresses = (addresses: { }): { [x: number]: string } => { const reformatted: { [x: number]: string } = {} for (const chainId in addresses) { + // HyperCore's native 16-byte token IDs do not use EVM address checksums. + if ( + Number(chainId) === CHAINS.HYPERLIQUID.id && + /^0x[0-9a-fA-F]{32}$/.test(addresses[chainId]) + ) { + reformatted[chainId] = addresses[chainId].toLowerCase() + continue + } reformatted[chainId] = addresses[chainId] ? getAddress(addresses[chainId]) : '' diff --git a/packages/widget-example/CHANGELOG.md b/packages/widget-example/CHANGELOG.md index d89e47e99f..5caa9409eb 100644 --- a/packages/widget-example/CHANGELOG.md +++ b/packages/widget-example/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.2.6](https://github.com/synapsecns/sanguine/compare/@synapsecns/widget-example@0.2.5...@synapsecns/widget-example@0.2.6) (2026-09-21) + +**Note:** Version bump only for package @synapsecns/widget-example + + + + + ## [0.2.5](https://github.com/synapsecns/sanguine/compare/@synapsecns/widget-example@0.2.4...@synapsecns/widget-example@0.2.5) (2026-05-22) **Note:** Version bump only for package @synapsecns/widget-example diff --git a/packages/widget-example/package.json b/packages/widget-example/package.json index 285e40df18..cae59becbf 100644 --- a/packages/widget-example/package.json +++ b/packages/widget-example/package.json @@ -1,6 +1,6 @@ { "name": "@synapsecns/widget-example", - "version": "0.2.5", + "version": "0.2.6", "private": true, "description": "Minimal Next.js consumer app for the Synapse widget", "engines": { @@ -18,7 +18,7 @@ "build:slither": " " }, "dependencies": { - "@synapsecns/widget": "^0.11.5", + "@synapsecns/widget": "^0.11.6", "ethers": "^6.9.1", "next": "^14.2.35", "react": "^18.2.0", diff --git a/packages/widget/CHANGELOG.md b/packages/widget/CHANGELOG.md index fcec244615..bf2f9a2849 100644 --- a/packages/widget/CHANGELOG.md +++ b/packages/widget/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.11.6](https://github.com/synapsecns/sanguine/compare/@synapsecns/widget@0.11.5...@synapsecns/widget@0.11.6) (2026-09-21) + +**Note:** Version bump only for package @synapsecns/widget + + + + + ## [0.11.5](https://github.com/synapsecns/sanguine/compare/@synapsecns/widget@0.11.4...@synapsecns/widget@0.11.5) (2026-05-22) **Note:** Version bump only for package @synapsecns/widget diff --git a/packages/widget/package.json b/packages/widget/package.json index 27c4025fcb..1a7fb15238 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -1,7 +1,7 @@ { "name": "@synapsecns/widget", "description": "Widget library for interacting with the Synapse Protocol", - "version": "0.11.5", + "version": "0.11.6", "license": "MIT", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -78,7 +78,7 @@ "@ethersproject/providers": "^5.7.2", "@ethersproject/units": "^5.7.0", "@reduxjs/toolkit": "^2.0.1", - "@synapsecns/sdk-router": "^0.23.3", + "@synapsecns/sdk-router": "^0.23.4", "ethers": "^6.9.1", "lodash": "^4.17.21", "react-redux": "^9.0.2"