Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,43 @@ gh pr merge <pr#> --squash --delete-branch
- **严禁主目录开发**: 必须使用 `task start` 创建的 Worktree。
- **Schema-first**: 服务开发必须先定义 `types.ts`。

---

## Agent 工作方式(约定)

> 目标:避免“围绕 DOM 修修补补”导致的路径爆炸;用可验证的状态机 + 纯函数绑定 DOM。

### 1) 状态机优先(State-first)

- 先把“控制层状态”定义清楚(例如:miniapp 是 `active/backgrounded`,sheet 是否 `pending/visible/resolved`)。
- 先完成状态迁移/队列/FIFO 等控制逻辑,再把状态映射到 DOM(DOM 只是最后一步渲染)。
- 使用函数式工具函数做绑定:
- `derive*`:从状态派生视图状态(纯函数)
- `apply*ToDom`:把派生后的视图状态写入 DOM(集中处理,避免到处散落 `style/classList`)

### 2) KISS:样式控制收敛到单一入口

- 不在多个层级叠加 `opacity/visibility/display/pointer-events` 进行互相对冲。
- 约定由“单一视图状态”驱动:
- `interactive`(是否可交互)→ `pointer-events`
- `visible`(是否可见)→ `opacity` 或 `visibility`
- **禁止**通过移除 DOM 节点/父节点来实现后台化(iframe 被移除会被释放)。

### 3) Miniapp Sheet 统一 FIFO(按 appId)

- 所有需要 UI Sheet 的 handler 必须通过 `enqueueMiniappSheet(appId, task)` 串行化。
- 目标:同一 app 的敏感动作“先入先出”,且不会被新弹窗打断。

### 4) Worktree 与 PR 管理

- 一律从 repo 根目录执行 `pnpm agent task start`,避免在 worktree 内再次创建 worktree(会出现嵌套 worktree)。
- PR 合并后清理无用 worktree/分支,保持本地环境干净可控。

### 5) 验证要求

- 修改完成后至少跑一次:`pnpm agent review verify`。
- 对“协议/URL 拼接/缓存”这类易回归问题优先补单测,确保在 CI 中可复现。

<!-- OPENSPEC:START -->
# OpenSpec Instructions

Expand Down
24 changes: 24 additions & 0 deletions packages/ecosystem-native/src/components/home-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export class HomeButton extends LitElement {
velocityThreshold = 0.3;

private swipeDetector = createUpSwipeDetector();
private ignoreNextClick = false;
private ignoreClickTimeoutId: ReturnType<typeof globalThis.setTimeout> | null = null;

override connectedCallback(): void {
super.connectedCallback();
Expand All @@ -74,6 +76,11 @@ export class HomeButton extends LitElement {
}

override disconnectedCallback(): void {
if (this.ignoreClickTimeoutId !== null) {
globalThis.clearTimeout(this.ignoreClickTimeoutId);
this.ignoreClickTimeoutId = null;
}

this.removeEventListener('touchstart', this.handleTouchStart, { capture: true });
this.removeEventListener('touchend', this.handleTouchEnd, { capture: true });
this.removeEventListener('touchcancel', this.handleTouchCancel, { capture: true });
Expand Down Expand Up @@ -114,6 +121,18 @@ export class HomeButton extends LitElement {

if (result.detected && result.direction === 'up') {
e.preventDefault();

// Some browsers still fire a click after touchend.
// Suppress that tap so swipe-up doesn't immediately trigger the tap path.
this.ignoreNextClick = true;
if (this.ignoreClickTimeoutId !== null) {
globalThis.clearTimeout(this.ignoreClickTimeoutId);
}
this.ignoreClickTimeoutId = globalThis.setTimeout(() => {
this.ignoreNextClick = false;
this.ignoreClickTimeoutId = null;
}, 400);

ecosystemEvents.emit('home:swipe-up', undefined);

// Dispatch custom event for React integration
Expand All @@ -128,6 +147,11 @@ export class HomeButton extends LitElement {
};

private handleClick = (): void => {
if (this.ignoreNextClick) {
this.ignoreNextClick = false;
return;
}

ecosystemEvents.emit('home:tap', undefined);

this.dispatchEvent(
Expand Down
36 changes: 31 additions & 5 deletions src/services/chain-adapter/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { MoralisProviderEffect, createMoralisProviderEffect } from './moralis-pr
import type { ApiProvider, ApiProviderFactory } from './types';
import type { ParsedApiEntry } from '@/services/chain-config';
import { chainConfigService } from '@/services/chain-config/service';
import { chainConfigStore } from '@/stores/chain-config';
import { ChainProvider } from './chain-provider';

import { createEtherscanV1ProviderEffect } from './etherscan-v1-provider.effect';
Expand Down Expand Up @@ -97,19 +98,44 @@ export function createChainProvider(chainId: string): ChainProvider {
}

/** ChainProvider 缓存 */
const providerCache = new Map<string, ChainProvider>();
type ProviderCacheEntry = {
provider: ChainProvider;
apiKey: string;
};

const providerCache = new Map<string, ProviderCacheEntry>();

function createApiKey(entries: ParsedApiEntry[]): string {
return entries
.map((entry) => {
const configKey = entry.config ? JSON.stringify(entry.config) : "";
return `${entry.type}|${entry.endpoint}|${configKey}`;
})
.join(",");
}

/**
* 获取或创建 ChainProvider(带缓存)
*/
export function getChainProvider(chainId: string): ChainProvider {
const resolvedChainId = resolveChainId(chainId);

let provider = providerCache.get(resolvedChainId);
if (!provider) {
provider = createChainProvider(resolvedChainId);
providerCache.set(resolvedChainId, provider);
// Avoid caching providers before chain configs are initialized.
// Otherwise `entries=[]` would create an empty provider and poison the cache.
if (!chainConfigStore.state.snapshot) {
return createChainProvider(resolvedChainId);
}

const entries = chainConfigService.getApi(resolvedChainId);
const apiKey = createApiKey(entries);

const cached = providerCache.get(resolvedChainId);
if (cached && cached.apiKey === apiKey) {
return cached.provider;
}

const provider = createChainProvider(resolvedChainId);
providerCache.set(resolvedChainId, { provider, apiKey });
return provider;
}

Expand Down
94 changes: 94 additions & 0 deletions src/services/chain-adapter/providers/provider-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it } from 'vitest'
import 'fake-indexeddb/auto'

import { resetChainConfigStorageForTests } from '@/services/chain-config/storage'
import { chainConfigActions, chainConfigStore } from '@/stores/chain-config'

import { clearProviderCache, getChainProvider } from './index'

describe('getChainProvider cache safety', () => {
beforeEach(async () => {
await resetChainConfigStorageForTests()
clearProviderCache()
chainConfigStore.setState(() => ({
snapshot: null,
isLoading: false,
error: null,
migrationRequired: false,
}))
})

it('does not cache an empty provider before chain configs initialize', async () => {
const provider1 = getChainProvider('bfmetav2')
const provider2 = getChainProvider('bfmetav2')

expect(provider1).not.toBe(provider2)
expect(provider1.supportsFullTransaction).toBe(false)

await chainConfigActions.initialize()

const provider3 = getChainProvider('bfmetav2')
const provider4 = getChainProvider('bfmetav2')

expect(provider3).toBe(provider4)
expect(provider3).not.toBe(provider1)
expect(provider3.supportsFullTransaction).toBe(true)
})

it('rebuilds the cached provider when api entries change', async () => {
await chainConfigActions.initialize()

const originalSnapshot = chainConfigStore.state.snapshot
expect(originalSnapshot).not.toBeNull()
if (!originalSnapshot) return

const originalConfig = originalSnapshot.configs.find((config) => config.id === 'bfmetav2')
expect(originalConfig).toBeTruthy()
if (!originalConfig) return

chainConfigStore.setState((state) => {
const snapshot = state.snapshot
if (!snapshot) return state

return {
...state,
snapshot: {
...snapshot,
configs: snapshot.configs.map((config) =>
config.id === originalConfig.id
? {
...config,
apis: [],
}
: config,
),
},
}
})

clearProviderCache()

const provider1 = getChainProvider('bfmetav2')
expect(provider1.supportsFullTransaction).toBe(false)

chainConfigStore.setState((state) => {
const snapshot = state.snapshot
if (!snapshot) return state

return {
...state,
snapshot: {
...snapshot,
configs: snapshot.configs.map((config) =>
config.id === originalConfig.id ? originalConfig : config,
),
},
}
})

const provider2 = getChainProvider('bfmetav2')
expect(provider2).not.toBe(provider1)
expect(provider2.supportsFullTransaction).toBe(true)
})
})

3 changes: 2 additions & 1 deletion src/services/ecosystem/handlers/destroy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type { MethodHandler, EcosystemDestroyParams } from '../types'
import { BioErrorCodes } from '../types'
import { HandlerContext, type MiniappInfo, toMiniappInfo } from './context'
import { enqueueMiniappSheet } from '../sheet-queue'

// 兼容旧 API
let _showDestroyDialog: ((params: EcosystemDestroyParams & { app: MiniappInfo }) => Promise<{ txHash: string } | null>) | null = null
Expand Down Expand Up @@ -43,7 +44,7 @@ export const handleDestroyAsset: MethodHandler = async (params, context) => {
app: toMiniappInfo(context),
}

const result = await showDestroyDialog(destroyParams)
const result = await enqueueMiniappSheet(context.appId, () => showDestroyDialog(destroyParams))

if (!result) {
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
Expand Down
38 changes: 24 additions & 14 deletions src/services/ecosystem/handlers/evm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { MethodHandler, BioAccount } from '../types'
import { BioErrorCodes } from '../types'
import { HandlerContext } from './context'
import { enqueueMiniappSheet } from '../sheet-queue'
import {
toHexChainId,
parseHexChainId,
Expand Down Expand Up @@ -132,7 +133,10 @@ export const handleEthRequestAccounts: MethodHandler = async (_params, context)
}

const chainId = getCurrentChainId(context.appId)
const wallet = await showWalletPicker({ chainId, app: { name: context.appName, icon: context.appIcon } })

const wallet = await enqueueMiniappSheet(context.appId, () =>
showWalletPicker({ chainId, app: { name: context.appName, icon: context.appIcon } }),
)
if (!wallet) {
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
}
Expand Down Expand Up @@ -173,12 +177,14 @@ export const handleSwitchChain: MethodHandler = async (params, context) => {

// Show confirmation dialog
if (_showChainSwitchConfirm) {
const approved = await _showChainSwitchConfirm({
fromChainId: currentChainId,
toChainId: targetChainId,
appName: context.appName,
appIcon: context.appIcon,
})
const approved = await enqueueMiniappSheet(context.appId, () =>
_showChainSwitchConfirm({
fromChainId: currentChainId,
toChainId: targetChainId,
appName: context.appName,
appIcon: context.appIcon,
}),
)
if (!approved) {
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
}
Expand Down Expand Up @@ -211,7 +217,9 @@ export const handlePersonalSign: MethodHandler = async (params, context) => {
throw Object.assign(new Error('Signing dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR })
}

const result = await showSigningDialog({ message, address, appName: context.appName })
const result = await enqueueMiniappSheet(context.appId, () =>
showSigningDialog({ message, address, appName: context.appName }),
)
if (!result) {
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
}
Expand Down Expand Up @@ -243,11 +251,13 @@ export const handleSignTypedDataV4: MethodHandler = async (params, context) => {
// Format typed data for display
const displayMessage = JSON.stringify(data, null, 2)

const result = await showSigningDialog({
message: displayMessage,
address,
appName: context.appName,
})
const result = await enqueueMiniappSheet(context.appId, () =>
showSigningDialog({
message: displayMessage,
address,
appName: context.appName,
}),
)
if (!result) {
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
}
Expand All @@ -272,7 +282,7 @@ export const handleEthSendTransaction: MethodHandler = async (params, context) =
tx.chainId = getCurrentChainId(context.appId)
}

const result = await showTransactionDialog({ tx, appName: context.appName })
const result = await enqueueMiniappSheet(context.appId, () => showTransactionDialog({ tx, appName: context.appName }))
if (!result) {
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
}
Expand Down
13 changes: 9 additions & 4 deletions src/services/ecosystem/handlers/signing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import type { MethodHandler } from '../types'
import { BioErrorCodes } from '../types'
import { HandlerContext, type SigningParams, type SigningResult, toMiniappInfo } from './context'
import { enqueueMiniappSheet } from '../sheet-queue'

// 兼容旧 API(现在返回 SigningResult)
let _showSigningDialog: ((params: SigningParams) => Promise<SigningResult | null>) | null = null
Expand Down Expand Up @@ -32,12 +33,14 @@ export const handleSignMessage: MethodHandler = async (params, context) => {
throw Object.assign(new Error('Signing dialog not available'), { code: BioErrorCodes.INTERNAL_ERROR })
}

const result = await showSigningDialog({
const dialogParams = {
message: opts.message,
address: opts.address,
chainName: opts.chainName,
app: toMiniappInfo(context),
})
}

const result = await enqueueMiniappSheet(context.appId, () => showSigningDialog(dialogParams))

if (!result) {
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
Expand All @@ -62,12 +65,14 @@ export const handleSignTypedData: MethodHandler = async (params, context) => {
// Convert typed data to readable message
const message = JSON.stringify(opts.data, null, 2)

const result = await showSigningDialog({
const dialogParams = {
message,
address: opts.address,
chainName: opts.chainName,
app: toMiniappInfo(context),
})
}

const result = await enqueueMiniappSheet(context.appId, () => showSigningDialog(dialogParams))

if (!result) {
throw Object.assign(new Error('User rejected'), { code: BioErrorCodes.USER_REJECTED })
Expand Down
Loading