diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..068de0b --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,77 @@ +name: Backend CI + +on: + pull_request: + paths: + - '.github/workflows/backend-ci.yml' + - '.golangci.yml' + - 'Dockerfile' + - 'cmd/**' + - 'configs/**' + - 'docs/api/**' + - 'go.mod' + - 'go.sum' + - 'internal/**' + - 'model/**' + - 'pkg/**' + - 'plugins/**' + - 'share/**' + - 'util/**' + push: + branches: + - master + - crossagent + paths: + - '.github/workflows/backend-ci.yml' + - '.golangci.yml' + - 'Dockerfile' + - 'cmd/**' + - 'configs/**' + - 'docs/api/**' + - 'go.mod' + - 'go.sum' + - 'internal/**' + - 'model/**' + - 'pkg/**' + - 'plugins/**' + - 'share/**' + - 'util/**' + +concurrency: + group: backend-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + go: + name: Go build and stable tests + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build backend + run: go build -o /tmp/subscan-ci ./cmd + + - name: Build backend Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: subscan-essentials-ci:${{ github.sha }} + + - name: Run stable backend tests + shell: bash + run: | + set -euo pipefail + # The excluded packages currently require local config/services or have pre-existing test compile/vet issues. + go list ./... \ + | grep -Ev '/(internal/dao|internal/server/http|internal/service|pkg/go-web3/providers/util|plugins/evm/dao|share/redis|util/mq)$' \ + | xargs go test diff --git a/.github/workflows/deploy-test-explorer-api.yml b/.github/workflows/deploy-test-explorer-api.yml new file mode 100644 index 0000000..51d38be --- /dev/null +++ b/.github/workflows/deploy-test-explorer-api.yml @@ -0,0 +1,47 @@ +name: Deploy Test Explorer API + +on: + push: + branches: + - crossagent + workflow_dispatch: + +concurrency: + group: test-explorer-api-deploy + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure SSH + shell: bash + env: + DEPLOY_HOST: ${{ secrets.TEST_EXPLORER_DEPLOY_HOST }} + DEPLOY_SSH_KEY: ${{ secrets.TEST_EXPLORER_DEPLOY_SSH_KEY }} + run: | + set -euo pipefail + test -n "$DEPLOY_HOST" + test -n "$DEPLOY_SSH_KEY" + install -d -m 700 ~/.ssh + printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/test-explorer-deploy + chmod 600 ~/.ssh/test-explorer-deploy + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + + - name: Deploy + shell: bash + env: + DEPLOY_HOST: ${{ secrets.TEST_EXPLORER_DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.TEST_EXPLORER_DEPLOY_USER }} + run: | + set -euo pipefail + test -n "$DEPLOY_USER" + ssh -i ~/.ssh/test-explorer-deploy \ + -o BatchMode=yes \ + "$DEPLOY_USER@$DEPLOY_HOST" \ + 'bash -s' < scripts/deploy-test-explorer-api.sh diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml deleted file mode 100644 index ff4d123..0000000 --- a/.github/workflows/docker.yaml +++ /dev/null @@ -1,43 +0,0 @@ -name: Build Dev Docker Image -on: - push: - tags: - - 'v*' - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Docker login - uses: docker/login-action@v1 - with: - username: ${{ secrets.QUAY_IO_BOT_USERNAME }} - password: ${{ secrets.QUAY_IO_BOT_PASSWORD }} - registry: quay.io - - uses: olegtarasov/get-tag@v2.1 - id: tag-name - - uses: benjlevesque/short-sha@v3.0 - id: short-sha - with: - length: 7 - - name: Build and publish tag docker image - uses: docker/build-push-action@v3 - if: startsWith(github.ref, 'refs/tags/') - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: quay.io/subscan-explorer/subscan-essentials:${{ steps.tag-name.outputs.tag }} - - name: Build and publish SHA docker image - uses: docker/build-push-action@v3 - if: startsWith(github.ref, 'refs/tags/') == false - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: quay.io/subscan-explorer/subscan-essentials:sha-${{ steps.short-sha.outputs.sha }}-${{ github.run_number }} \ No newline at end of file diff --git a/.github/workflows/ui-react-deploy-docker.yml b/.github/workflows/ui-react-deploy-docker.yml deleted file mode 100644 index b440bf1..0000000 --- a/.github/workflows/ui-react-deploy-docker.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy Docker - -on: - push: - branches: [master] - paths: - - 'ui-react/**' - - '.github/workflows/ui-react-deploy-docker.yml' - -jobs: - deploy: - name: Deploy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Docker login - uses: docker/login-action@v1 - with: - username: ${{ secrets.QUAY_IO_BOT_USERNAME }} - password: ${{ secrets.QUAY_IO_BOT_PASSWORD }} - registry: quay.io - - uses: benjlevesque/short-sha@v3.0 - id: short-sha - with: - length: 7 - - uses: actions/setup-node@v3 - with: - node-version: 18.20.2 - cache: 'npm' - cache-dependency-path: ui-react/package-lock.json - - name: Install dependencies && build - working-directory: ui-react - run: npm install && npm run build - - name: Push Docker image - uses: docker/build-push-action@v6 - with: - context: ui-react - push: true - tags: quay.io/subscan-explorer/subscan-essentials-ui:sha-${{ steps.short-sha.outputs.sha }}-${{ github.run_number }} diff --git a/.github/workflows/ui-react-deploy-stg.yml b/.github/workflows/ui-react-deploy-stg.yml deleted file mode 100644 index 4008346..0000000 --- a/.github/workflows/ui-react-deploy-stg.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Deploy staging - -on: - push: - branches: [master] - paths: - - 'ui-react/**' - - '.github/workflows/ui-react-deploy-stg.yml' - -jobs: - deploy: - name: Deploy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: darwinia-network/devops/actions/smart-vercel@main - name: Deploy - with: - node_version: 22 - vercel_token: ${{ secrets.VERCEL_TOKEN }} - vercel_group: itering - preview_output: true - alias_domain: "essentials-stg" - project_name: "subscan-essentials-ui-react" - script_run: false - dist_path: ui-react - enable_notify_slack: true - slack_channel: subscan-github-notification - slack_webhook: ${{ secrets.SLACK_INCOMING_WEBHOOK_URL }} diff --git a/docs/heima_evm_interaction_records.md b/docs/heima_evm_interaction_records.md new file mode 100644 index 0000000..fa69d2b --- /dev/null +++ b/docs/heima_evm_interaction_records.md @@ -0,0 +1,94 @@ +# Heima EVM 智能合约交互记录调查 + +调查日期:2026-05-25 + +## 结论 + +当前状态:部分支持。 + +Heima 线上 API 已经索引 EVM 合约、普通 EVM 交易和交易 receipt logs。现有产品也已经有合约详情页、地址详情页、交易列表页和交易详情页,所以“某个合约有哪些直接 EVM 交易交互”可以查看。 + +但这还不是完整的智能合约交互记录能力。当前前端没有 raw event/log 页签、没有 internal transaction/trace 入口,也没有在交易详情里展示 ABI 解码后的 method call、event name 或参数。对于未验证合约,线上 API 返回的 `abi`、`method_identifiers`、`event_identifiers` 也为空,因此页面只能展示交易哈希、from/to、value、input data 等低层字段。 + +## 最佳线上证据 + +线上页面入口: + +- 合约页:`https://test-explorer.heima.network/contract/0xeb9c31afbe1bc3cfbb218f554148b456095def9b` +- 交易页:`https://test-explorer.heima.network/tx/0x2e2f768ca1ffc8a5d53fc6dcd7e4af36af02c29d762ecc0f1ecbf2825d70bb6d` + +线上 API 样本: + +- `POST https://explorer-api.heima.network/api/scan/metadata` 返回 `enable_evm: true`、`total_evm_contract: "22"`、`total_transaction: "2106"`。 +- `POST https://explorer-api.heima.network/api/plugin/evm/contracts` 返回合约 `0xeb9c31afbe1bc3cfbb218f554148b456095def9b`,`transaction_count: 46`。 +- `POST https://explorer-api.heima.network/api/plugin/evm/transactions` 加 `address: "0xeb9c31afbe1bc3cfbb218f554148b456095def9b"` 返回多条真实交互交易,例如 `0x2e2f768ca1ffc8a5d53fc6dcd7e4af36af02c29d762ecc0f1ecbf2825d70bb6d`。 +- `POST https://explorer-api.heima.network/api/plugin/evm/transaction` 查询该 hash 返回 `to_address: "0xeb9c31afbe1bc3cfbb218f554148b456095def9b"`、`input_data: "0x28d3a294..."`、`success: true`、`extrinsic_index: "9649159-2"`。 +- `GET https://explorer-api.heima.network/api/plugin/evm/etherscan?module=logs&action=getLogs&address=0xeb9c31afbe1bc3cfbb218f554148b456095def9b&page=1&offset=10` 返回多条 receipt log,说明数据库/API 层有 raw event/log 数据。 + +## 产品和代码层面 + +已存在入口: + +- `ui-react/src/pages/contract/[id].tsx`:合约详情页,包含 `Contract`、`Transactions`、`ERC-20 Transfers`、`ERC-721 Transfers` 页签。 +- `ui-react/src/pages/address/[id].tsx`:地址详情页,包含 token、交易和 transfer 页签。 +- `ui-react/src/pages/tx/[id].tsx`:EVM 交易详情页,展示 hash、from、to、value、result、nonce、input data、fee 和 signature。 +- `ui-react/src/components/tx/txTable.tsx`:EVM 交易表,按地址或区块过滤。 + +已存在 API/索引能力: + +- `plugins/evm/http/http.go` 注册了 `transactions`、`transaction`、`contracts`、`contract`、`token/transfer`、`etherscan` 等 EVM API。 +- `plugins/evm/dao/transaction.go` 在扫描 EVM block transaction 时调用 `eth_getTransactionReceipt`,写入 `evm_transactions` 和 `evm_transaction_receipts`。 +- `plugins/evm/dao/api.go` 的 `API_GetLogs` 从 `evm_transaction_receipts` 输出 Etherscan-compatible logs。 +- `plugins/evm/dao/event.go` 会对部分 receipt event 做 token/proxy 后处理,例如 ERC-20/ERC-721 Transfer 和 proxy Upgraded。 + +## 缺失层级和上游原因 + +前端层: + +- 缺少合约详情页的 raw `Logs / Events` 页签。 +- 缺少交易详情页的 receipt logs、decoded method call 和 decoded event 参数展示。 +- 缺少 internal transaction/trace 页签或入口。 + +API 层: + +- 已有 Etherscan-compatible `logs/getLogs` raw log API,但没有被当前 React 页面消费。 +- `txlistinternal` 请求当前返回 HTTP 200 且 body 为空,未形成可展示的 internal transaction 数据契约。 +- `API_Transactions` 的 `FunctionName` 仍是空字符串,当前 API 没有把 method selector 映射成可读函数名输出给页面。 + +数据库层: + +- 已有 `evm_transactions` 和 `evm_transaction_receipts`,能够支撑直接交易列表和 raw event/log。 +- 未看到 internal transaction/trace 专用表;当前 schema 不能支撑合约内部调用树展示。 +- 示例合约 `0xeb9c31afbe1bc3cfbb218f554148b456095def9b` 未验证,线上 `abi`、`method_identifiers`、`event_identifiers` 均为空,限制了 method/event 解码。 + +扫描解析层: + +- 扫描器已经解析 block transaction 和 receipt logs。 +- 扫描器没有显式采集 `debug_traceTransaction`、`trace_*` 或等价 internal call 数据。 +- receipt log 后处理目前聚焦 token/proxy 特定事件,不是通用 ABI event 解码流水线。 + +## 最小补齐路径 + +前端: + +1. 在合约详情页增加 `Logs / Events` 页签,消费现有 `etherscan?module=logs&action=getLogs&address=...`。 +2. 在交易详情页增加 receipt logs 区块,并展示 topic、data、log index、contract address。 +3. 对已验证合约增加 method/event 名称和参数展示;未验证合约保留 raw input/topic/data。 + +API: + +1. 提供 UI-native 的 `POST /api/plugin/evm/logs` 或在前端规范化消费现有 Etherscan GET logs。 +2. 在 `transactions` 和 `transaction` 返回值中补充 method selector、已知 method name 和 decode 状态。 +3. 明确定义 `internal transactions` API;在没有 trace 数据前返回结构化空结果,而不是空 body。 + +数据库: + +1. 复用 `evm_transaction_receipts` 支撑 raw logs。 +2. 增加 method/event ABI 映射缓存与按合约、topic、selector 的查询索引。 +3. 如需 internal tx,新增 trace/internal call 表,至少记录 tx hash、trace path、from、to、value、input、output、call type、status/error。 + +扫描解析: + +1. 继续保存 `eth_getTransactionReceipt` logs。 +2. 对已验证合约按 ABI 解码 input 和 logs。 +3. 如链节点支持 trace/debug RPC,新增 internal call 采集任务;如不支持,需要先确认 Heima 节点侧 RPC 能力。 diff --git a/go.mod b/go.mod index 3636ed1..8851e62 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( gorm.io/datatypes v1.2.5 gorm.io/driver/mysql v1.5.6 gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.4.3 gorm.io/gorm v1.30.0 ) diff --git a/internal/agentkeys/audit.go b/internal/agentkeys/audit.go new file mode 100644 index 0000000..794a006 --- /dev/null +++ b/internal/agentkeys/audit.go @@ -0,0 +1,809 @@ +package agentkeys + +import ( + "context" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + EnvelopeVersion = 1 + + HeimaChainID = 212013 + CredentialAuditContractAddress = "0x63c4545ac01c77cc74044f25b8edea3880224577" + + AuditAppendedV2Signature = "AuditAppendedV2(bytes32,bytes32,uint8,bytes32)" + AuditAppendedCurrentSignature = "AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)" + AuditRootAppendedV2Signature = "AuditRootAppendedV2(bytes32,bytes32,bytes32,uint64)" +) + +var ( + AuditAppendedV2Topic = crypto.Keccak256Hash([]byte(AuditAppendedV2Signature)).Hex() + AuditAppendedCurrentTopic = crypto.Keccak256Hash([]byte(AuditAppendedCurrentSignature)).Hex() + AuditRootAppendedV2Topic = crypto.Keccak256Hash([]byte(AuditRootAppendedV2Signature)).Hex() + ErrEnvelopeNotFound = errors.New("agentkeys audit envelope not found") +) + +type Envelope struct { + Version uint8 `json:"version"` + TsUnix uint64 `json:"ts_unix"` + ActorOmni string `json:"actor_omni"` + OperatorOmni string `json:"operator_omni"` + OpKind uint8 `json:"op_kind"` + OpKindName string `json:"op_kind_name"` + Result uint8 `json:"result"` + ResultName string `json:"result_name"` + IntentText *string `json:"intent_text"` + IntentCommitment *string `json:"intent_commitment"` + EnvelopeHash string `json:"envelope_hash"` + HashVerified bool `json:"hash_verified"` + Body interface{} `json:"body"` + OpaqueOpBodyCBOR string `json:"opaque_op_body_cbor,omitempty"` + canonicalCBORBody []byte +} + +type UnknownOpBody struct { + OpKindByte uint8 `json:"op_kind_byte"` + OpBodyB64 string `json:"op_body_b64"` +} + +type AuditAppendedV2Event struct { + EventName string `json:"event_name"` + EventTopic string `json:"event_topic"` + OperatorOmni string `json:"operator_omni"` + ActorOmni string `json:"actor_omni"` + OpKind uint8 `json:"op_kind"` + EnvelopeHash string `json:"envelope_hash"` + CurrentIndexedKey string `json:"current_indexed_key,omitempty"` + CurrentSequence string `json:"current_sequence,omitempty"` +} + +type AuditRootAppendedV2Event struct { + OperatorOmni string `json:"operator_omni"` + MerkleRoot string `json:"merkle_root"` + OpKindBitmapU256 string `json:"op_kind_bitmap_u256"` + EntryCount uint64 `json:"entry_count"` +} + +type EVMLogRecord struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + BlockHash string `json:"blockHash"` + Timestamp string `json:"timestamp"` + LogIndex string `json:"logIndex"` + TransactionHash string `json:"transactionHash"` + TransactionIndex string `json:"transactionIndex"` +} + +type TypedAuditRow struct { + Envelope + ChainID uint64 `json:"chain_id"` + ContractAddress string `json:"contract_address"` + EventName string `json:"event_name"` + EventTopic string `json:"event_topic"` + Block uint64 `json:"block"` + BlockHash string `json:"block_hash"` + Timestamp uint64 `json:"timestamp"` + Tx string `json:"tx"` + TransactionIndex uint64 `json:"transaction_index"` + LogIndex uint64 `json:"log_index"` + StreamPosition string `json:"stream_position"` + CurrentIndexedKey string `json:"current_indexed_key,omitempty"` + CurrentSequence string `json:"current_sequence,omitempty"` + EnvelopeAvailable bool `json:"envelope_available"` + EnvelopeFetchError *string `json:"envelope_fetch_error,omitempty"` +} + +type AuditRowsPage struct { + ChainID uint64 `json:"chain_id"` + ContractAddress string `json:"contract_address"` + Events []TypedAuditRow `json:"events"` + NextCursor *string `json:"next_cursor"` +} + +type AuditRootRows struct { + ChainID uint64 `json:"chain_id"` + ContractAddress string `json:"contract_address"` + MerkleRoot string `json:"merkle_root"` + OperatorOmni string `json:"operator_omni"` + OpKindBitmapU256 string `json:"op_kind_bitmap_u256"` + EntryCount uint64 `json:"entry_count"` + Block uint64 `json:"block"` + BlockHash string `json:"block_hash"` + Tx string `json:"tx"` + LogIndex uint64 `json:"log_index"` + Leaves []string `json:"leaves"` + Rows []TypedAuditRow `json:"rows"` +} + +type EnvelopeCache struct { + mu sync.RWMutex + bodies map[string][]byte +} + +type opFieldType string + +const ( + opText opFieldType = "text" + opUint opFieldType = "uint" +) + +type opKindSpec struct { + Name string + Family string + Fields []opFieldSpec +} + +type opFieldSpec struct { + Name string + Type opFieldType +} + +var opKindSpecs = map[uint8]opKindSpec{ + 0: {"CredStore", "creds", fields(text("service"), text("payload_hash"))}, + 1: {"CredFetch", "creds", fields(text("service"), text("cap_hash"))}, + 2: {"CredTeardown", "creds", fields(text("actor_target"))}, + 10: {"MemoryPut", "memory", fields(text("key"), text("payload_hash"))}, + 11: {"MemoryGet", "memory", fields(text("key"), text("cap_hash"))}, + 12: {"MemoryTeardown", "memory", fields(text("actor_target"))}, + 20: {"SignEip191", "signs", fields(text("message_digest"), text("wallet"))}, + 21: {"SignEip712", "signs", fields(uintf("chain_id"), text("verifying_contract"), text("primary_type"), text("type_hash"), text("domain_separator"), text("digest"))}, + 30: {"PaymentEscrowRedeem", "payments", fields(text("escrow_addr"), text("amount"), text("recipient"), uintf("chain_id"))}, + 31: {"PaymentDirect", "payments", fields(text("rail"), text("ref"), text("amount_minor"), text("currency"))}, + 40: {"ScopeGrant", "scope", fields(text("agent_omni"), text("service"), uintf("max_calls"), text("max_amount"))}, + 41: {"ScopeRevoke", "scope", fields(text("agent_omni"), text("service"))}, + 50: {"DeviceAdd", "device", fields(text("device_key_hash"), uintf("role_bits"), text("attestation_hash"))}, + 51: {"DeviceRevoke", "device", fields(text("device_key_hash"))}, + 52: {"K10Rotate", "device", fields(text("old_device_key_hash"), text("new_device_key_hash"))}, + 60: {"EmailSend", "email", fields(text("to_hash"), text("subject_hash"), text("message_id"))}, + 61: {"EmailReceive", "email", fields(text("from_hash"), text("message_id"), text("payload_hash"))}, + 70: {"K3EpochAdvance", "K3", fields(uintf("old_epoch"), uintf("new_epoch"), text("gov_tx"))}, +} + +func fields(f ...opFieldSpec) []opFieldSpec { return f } +func text(name string) opFieldSpec { return opFieldSpec{Name: name, Type: opText} } +func uintf(name string) opFieldSpec { return opFieldSpec{Name: name, Type: opUint} } + +func OpKindName(opKind uint8) string { + if spec, ok := opKindSpecs[opKind]; ok { + return spec.Name + } + return fmt.Sprintf("Unknown(%d)", opKind) +} + +func DecodeEnvelope(data []byte, expectedHash string) (*Envelope, error) { + v, err := decodeCBOR(data) + if err != nil { + return nil, err + } + top, err := v.textMap() + if err != nil { + return nil, err + } + for _, key := range []string{"version", "ts_unix", "actor_omni", "operator_omni", "op_kind", "op_body", "result", "intent_text", "intent_commitment"} { + if _, ok := top[key]; !ok { + return nil, fmt.Errorf("missing envelope field %q", key) + } + } + + version, err := requireUint8(top["version"], "version") + if err != nil { + return nil, err + } + if version != EnvelopeVersion { + return nil, fmt.Errorf("unsupported AuditEnvelope version %d", version) + } + opKind, err := requireUint8(top["op_kind"], "op_kind") + if err != nil { + return nil, err + } + result, err := requireUint8(top["result"], "result") + if err != nil { + return nil, err + } + tsUnix, err := requireUint64(top["ts_unix"], "ts_unix") + if err != nil { + return nil, err + } + actor, err := requireBytesHex(top["actor_omni"], "actor_omni", 32) + if err != nil { + return nil, err + } + operator, err := requireBytesHex(top["operator_omni"], "operator_omni", 32) + if err != nil { + return nil, err + } + intentCommitment, err := optionalBytesHex(top["intent_commitment"], "intent_commitment", 32) + if err != nil { + return nil, err + } + intentText, err := optionalText(top["intent_text"], "intent_text") + if err != nil { + return nil, err + } + body, err := RenderOpBody(opKind, top["op_body"]) + if err != nil { + return nil, err + } + + actualHash := crypto.Keccak256Hash(data).Hex() + expectedHash = normalizeHexHash(expectedHash) + hashVerified := expectedHash == "" || strings.EqualFold(actualHash, expectedHash) + if expectedHash != "" && !hashVerified { + return nil, fmt.Errorf("envelope hash mismatch: expected %s got %s", expectedHash, actualHash) + } + + return &Envelope{ + Version: version, + TsUnix: tsUnix, + ActorOmni: actor, + OperatorOmni: operator, + OpKind: opKind, + OpKindName: OpKindName(opKind), + Result: result, + ResultName: resultName(result), + IntentText: intentText, + IntentCommitment: intentCommitment, + EnvelopeHash: actualHash, + HashVerified: hashVerified, + Body: body, + OpaqueOpBodyCBOR: base64.StdEncoding.EncodeToString(top["op_body"].raw), + canonicalCBORBody: append([]byte(nil), data...), + }, nil +} + +func RenderOpBody(opKind uint8, opBody cborValue) (interface{}, error) { + spec, ok := opKindSpecs[opKind] + if !ok { + return UnknownOpBody{ + OpKindByte: opKind, + OpBodyB64: base64.StdEncoding.EncodeToString(opBody.raw), + }, nil + } + m, err := opBody.textMap() + if err != nil { + return nil, fmt.Errorf("%s op_body: %w", spec.Name, err) + } + if len(m) != len(spec.Fields) { + return nil, fmt.Errorf("%s op_body field count mismatch", spec.Name) + } + out := make(map[string]interface{}, len(spec.Fields)) + for _, field := range spec.Fields { + value, ok := m[field.Name] + if !ok { + return nil, fmt.Errorf("%s op_body missing field %q", spec.Name, field.Name) + } + switch field.Type { + case opText: + if value.kind != cborText { + return nil, fmt.Errorf("%s op_body field %q must be text", spec.Name, field.Name) + } + out[field.Name] = value.s + case opUint: + if value.kind != cborUint { + return nil, fmt.Errorf("%s op_body field %q must be uint", spec.Name, field.Name) + } + out[field.Name] = value.u + } + } + return out, nil +} + +func KnownOpKinds() map[uint8]string { + out := make(map[uint8]string, len(opKindSpecs)) + for opKind, spec := range opKindSpecs { + out[opKind] = spec.Name + } + return out +} + +func NewEnvelopeCache() *EnvelopeCache { + return &EnvelopeCache{bodies: make(map[string][]byte)} +} + +func (c *EnvelopeCache) FetchAndDecode(ctx context.Context, workerBaseURL string, hash string) ([]byte, *Envelope, error) { + if c == nil { + body, err := FetchEnvelope(ctx, workerBaseURL, hash) + if err != nil { + return nil, nil, err + } + envelope, err := DecodeEnvelope(body, hash) + return body, envelope, err + } + + key := normalizeHexHash(hash) + c.mu.RLock() + if cached, ok := c.bodies[key]; ok { + body := append([]byte(nil), cached...) + c.mu.RUnlock() + envelope, err := DecodeEnvelope(body, key) + return body, envelope, err + } + c.mu.RUnlock() + + body, err := FetchEnvelope(ctx, workerBaseURL, key) + if err != nil { + return nil, nil, err + } + envelope, err := DecodeEnvelope(body, key) + if err != nil { + return nil, nil, err + } + + c.mu.Lock() + if cached, ok := c.bodies[key]; ok { + body = append([]byte(nil), cached...) + c.mu.Unlock() + envelope, err = DecodeEnvelope(body, key) + return body, envelope, err + } + c.bodies[key] = append([]byte(nil), body...) + c.mu.Unlock() + + return append([]byte(nil), body...), envelope, nil +} + +func DecodeTypedAuditRow(ctx context.Context, log EVMLogRecord, workerBaseURL string, cache *EnvelopeCache) (*TypedAuditRow, error) { + return decodeTypedAuditRow(ctx, log, workerBaseURL, cache, false) +} + +func DecodeTypedAuditRowBestEffort(ctx context.Context, log EVMLogRecord, workerBaseURL string, cache *EnvelopeCache) (*TypedAuditRow, error) { + return decodeTypedAuditRow(ctx, log, workerBaseURL, cache, true) +} + +func decodeTypedAuditRow(ctx context.Context, log EVMLogRecord, workerBaseURL string, cache *EnvelopeCache, allowMissingEnvelope bool) (*TypedAuditRow, error) { + event, err := DecodeAuditAppendedLog(log.Topics, log.Data) + if err != nil { + return nil, err + } + row, err := auditRowSkeleton(log, event) + if err != nil { + return nil, err + } + if cache == nil { + cache = NewEnvelopeCache() + } + _, envelope, err := cache.FetchAndDecode(ctx, workerBaseURL, event.EnvelopeHash) + if err != nil { + if allowMissingEnvelope && errors.Is(err, ErrEnvelopeNotFound) { + errText := err.Error() + row.Envelope = Envelope{ + Version: EnvelopeVersion, + ActorOmni: event.ActorOmni, + OperatorOmni: event.OperatorOmni, + OpKind: event.OpKind, + OpKindName: OpKindName(event.OpKind), + EnvelopeHash: event.EnvelopeHash, + } + row.EnvelopeFetchError = &errText + return row, nil + } + return nil, err + } + if !strings.EqualFold(event.OperatorOmni, envelope.OperatorOmni) { + return nil, fmt.Errorf("operator_omni mismatch for envelope %s", event.EnvelopeHash) + } + if !strings.EqualFold(event.ActorOmni, envelope.ActorOmni) { + return nil, fmt.Errorf("actor_omni mismatch for envelope %s", event.EnvelopeHash) + } + if event.OpKind != envelope.OpKind { + return nil, fmt.Errorf("op_kind mismatch for envelope %s", event.EnvelopeHash) + } + + row.Envelope = *envelope + row.EnvelopeAvailable = true + return row, nil +} + +func auditRowSkeleton(log EVMLogRecord, event *AuditAppendedV2Event) (*TypedAuditRow, error) { + block, err := parseUintAuto(log.BlockNumber) + if err != nil { + return nil, fmt.Errorf("blockNumber: %w", err) + } + timestamp, err := parseUintAuto(log.Timestamp) + if err != nil { + return nil, fmt.Errorf("timestamp: %w", err) + } + logIndex, err := parseUintAuto(log.LogIndex) + if err != nil { + return nil, fmt.Errorf("logIndex: %w", err) + } + txIndex, err := parseUintAuto(log.TransactionIndex) + if err != nil { + return nil, fmt.Errorf("transactionIndex: %w", err) + } + + return &TypedAuditRow{ + ChainID: HeimaChainID, + ContractAddress: normalizeHexHash(log.Address), + EventName: event.EventName, + EventTopic: event.EventTopic, + Block: block, + BlockHash: normalizeHexHash(log.BlockHash), + Timestamp: timestamp, + Tx: normalizeHexHash(log.TransactionHash), + TransactionIndex: txIndex, + LogIndex: logIndex, + StreamPosition: fmt.Sprintf("%d:%d", block, logIndex), + CurrentIndexedKey: event.CurrentIndexedKey, + CurrentSequence: event.CurrentSequence, + }, nil +} + +func DecodeTypedAuditRows(ctx context.Context, logs []EVMLogRecord, workerBaseURL string, cache *EnvelopeCache) ([]TypedAuditRow, error) { + rows := make([]TypedAuditRow, 0, len(logs)) + for _, log := range logs { + row, err := DecodeTypedAuditRow(ctx, log, workerBaseURL, cache) + if err != nil { + return nil, err + } + rows = append(rows, *row) + } + return rows, nil +} + +func DecodeTypedAuditRowsBestEffort(ctx context.Context, logs []EVMLogRecord, workerBaseURL string, cache *EnvelopeCache) ([]TypedAuditRow, error) { + rows := make([]TypedAuditRow, 0, len(logs)) + for _, log := range logs { + row, err := DecodeTypedAuditRowBestEffort(ctx, log, workerBaseURL, cache) + if err != nil { + return nil, err + } + rows = append(rows, *row) + } + return rows, nil +} + +func DecodeAuditRootRows(ctx context.Context, rootLog EVMLogRecord, leafLogs []EVMLogRecord, workerBaseURL string, cache *EnvelopeCache) (*AuditRootRows, error) { + event, err := DecodeAuditRootAppendedV2Log(rootLog.Topics, rootLog.Data) + if err != nil { + return nil, err + } + block, err := parseUintAuto(rootLog.BlockNumber) + if err != nil { + return nil, fmt.Errorf("root blockNumber: %w", err) + } + logIndex, err := parseUintAuto(rootLog.LogIndex) + if err != nil { + return nil, fmt.Errorf("root logIndex: %w", err) + } + + rows, err := DecodeTypedAuditRows(ctx, leafLogs, workerBaseURL, cache) + if err != nil { + return nil, err + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].Block == rows[j].Block { + return rows[i].LogIndex < rows[j].LogIndex + } + return rows[i].Block < rows[j].Block + }) + leaves := make([]string, 0, len(rows)) + for _, row := range rows { + leaves = append(leaves, row.EnvelopeHash) + } + + return &AuditRootRows{ + ChainID: HeimaChainID, + ContractAddress: normalizeHexHash(rootLog.Address), + MerkleRoot: event.MerkleRoot, + OperatorOmni: event.OperatorOmni, + OpKindBitmapU256: event.OpKindBitmapU256, + EntryCount: event.EntryCount, + Block: block, + BlockHash: normalizeHexHash(rootLog.BlockHash), + Tx: normalizeHexHash(rootLog.TransactionHash), + LogIndex: logIndex, + Leaves: leaves, + Rows: rows, + }, nil +} + +func PaddedOpKindTopic(opKind uint8) string { + return "0x" + strings.Repeat("0", 62) + fmt.Sprintf("%02x", opKind) +} + +func OpKindTopicsFromBitmap(bitmap string) ([]string, error) { + bytes, err := hex.DecodeString(strings.TrimPrefix(strings.ToLower(bitmap), "0x")) + if err != nil { + return nil, err + } + if len(bytes) != 32 { + return nil, fmt.Errorf("op_kind_bitmap must be 32 bytes") + } + topics := make([]string, 0) + for opKind := 0; opKind < 256; opKind++ { + byteIndex := 31 - opKind/8 + mask := byte(1 << uint(opKind%8)) + if bytes[byteIndex]&mask != 0 { + topics = append(topics, PaddedOpKindTopic(uint8(opKind))) + } + } + return topics, nil +} + +func EncodeCanonicalEnvelope(envelope map[string]interface{}) ([]byte, error) { + return encodeCanonical(envelope) +} + +func DecodeAuditAppendedV2Log(topics []string, data string) (*AuditAppendedV2Event, error) { + if len(topics) != 4 { + return nil, fmt.Errorf("AuditAppendedV2 requires 4 topics") + } + if !strings.EqualFold(topics[0], AuditAppendedV2Topic) { + return nil, fmt.Errorf("unexpected AuditAppendedV2 topic0 %s", topics[0]) + } + opKind, err := topicUint8(topics[3]) + if err != nil { + return nil, err + } + hash, err := abiBytes32(data, 0) + if err != nil { + return nil, fmt.Errorf("envelope_hash: %w", err) + } + return &AuditAppendedV2Event{ + EventName: "AuditAppendedV2", + EventTopic: AuditAppendedV2Topic, + OperatorOmni: normalizeBytes32Topic(topics[1]), + ActorOmni: normalizeBytes32Topic(topics[2]), + OpKind: opKind, + EnvelopeHash: hash, + }, nil +} + +func DecodeAuditAppendedLog(topics []string, data string) (*AuditAppendedV2Event, error) { + if len(topics) == 0 { + return nil, fmt.Errorf("audit event requires topic0") + } + switch { + case strings.EqualFold(topics[0], AuditAppendedV2Topic): + return DecodeAuditAppendedV2Log(topics, data) + case strings.EqualFold(topics[0], AuditAppendedCurrentTopic): + return DecodeAuditAppendedCurrentLog(topics, data) + default: + return nil, fmt.Errorf("unexpected audit event topic0 %s", topics[0]) + } +} + +func DecodeAuditAppendedCurrentLog(topics []string, data string) (*AuditAppendedV2Event, error) { + if len(topics) != 4 { + return nil, fmt.Errorf("AuditAppended requires 4 topics") + } + if !strings.EqualFold(topics[0], AuditAppendedCurrentTopic) { + return nil, fmt.Errorf("unexpected AuditAppended topic0 %s", topics[0]) + } + opKind, err := abiUint8(data, 0) + if err != nil { + return nil, fmt.Errorf("op_kind: %w", err) + } + sequence, err := abiUint256Decimal(data, 1) + if err != nil { + return nil, fmt.Errorf("current_sequence: %w", err) + } + hash, err := abiBytes32(data, 2) + if err != nil { + return nil, fmt.Errorf("envelope_hash: %w", err) + } + return &AuditAppendedV2Event{ + EventName: "AuditAppended", + EventTopic: AuditAppendedCurrentTopic, + OperatorOmni: normalizeBytes32Topic(topics[1]), + ActorOmni: normalizeBytes32Topic(topics[2]), + OpKind: opKind, + EnvelopeHash: hash, + CurrentIndexedKey: normalizeBytes32Topic(topics[3]), + CurrentSequence: sequence, + }, nil +} + +func DecodeAuditRootAppendedV2Log(topics []string, data string) (*AuditRootAppendedV2Event, error) { + if len(topics) != 3 { + return nil, fmt.Errorf("AuditRootAppendedV2 requires 3 topics") + } + if !strings.EqualFold(topics[0], AuditRootAppendedV2Topic) { + return nil, fmt.Errorf("unexpected AuditRootAppendedV2 topic0 %s", topics[0]) + } + bitmap, err := abiBytes32(data, 0) + if err != nil { + return nil, fmt.Errorf("op_kind_bitmap: %w", err) + } + countHex, err := abiWord(data, 1) + if err != nil { + return nil, fmt.Errorf("entry_count: %w", err) + } + count, err := strconv.ParseUint(countHex[48:], 16, 64) + if err != nil { + return nil, fmt.Errorf("entry_count: %w", err) + } + return &AuditRootAppendedV2Event{ + OperatorOmni: normalizeBytes32Topic(topics[1]), + MerkleRoot: normalizeBytes32Topic(topics[2]), + OpKindBitmapU256: bitmap, + EntryCount: count, + }, nil +} + +func FetchEnvelope(ctx context.Context, workerBaseURL string, hash string) ([]byte, error) { + base, err := url.Parse(workerBaseURL) + if err != nil { + return nil, err + } + base.Path = strings.TrimRight(base.Path, "/") + "/v1/audit/envelope/" + strings.TrimPrefix(strings.ToLower(hash), "0x") + + client := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() // nolint: errcheck + if resp.StatusCode == http.StatusNotFound { + return nil, ErrEnvelopeNotFound + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("audit worker returned HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, nil +} + +func requireUint8(v cborValue, name string) (uint8, error) { + if v.kind != cborUint { + return 0, fmt.Errorf("%s must be uint", name) + } + if v.u > 255 { + return 0, fmt.Errorf("%s must fit uint8", name) + } + return uint8(v.u), nil +} + +func requireUint64(v cborValue, name string) (uint64, error) { + if v.kind != cborUint { + return 0, fmt.Errorf("%s must be uint", name) + } + return v.u, nil +} + +func requireBytesHex(v cborValue, name string, size int) (string, error) { + if v.kind != cborBytes { + return "", fmt.Errorf("%s must be bytes", name) + } + if len(v.b) != size { + return "", fmt.Errorf("%s must be %d bytes", name, size) + } + return "0x" + hex.EncodeToString(v.b), nil +} + +func optionalBytesHex(v cborValue, name string, size int) (*string, error) { + if v.kind == cborNull { + return nil, nil + } + s, err := requireBytesHex(v, name, size) + if err != nil { + return nil, err + } + return &s, nil +} + +func optionalText(v cborValue, name string) (*string, error) { + if v.kind == cborNull { + return nil, nil + } + if v.kind != cborText { + return nil, fmt.Errorf("%s must be text or null", name) + } + return &v.s, nil +} + +func resultName(result uint8) string { + switch result { + case 0: + return "Success" + case 1: + return "Failure" + case 2: + return "NotPermitted" + default: + return fmt.Sprintf("Reserved(%d)", result) + } +} + +func topicUint8(topic string) (uint8, error) { + word := strings.TrimPrefix(strings.ToLower(topic), "0x") + if len(word) != 64 { + return 0, fmt.Errorf("topic is not bytes32") + } + prefix := strings.TrimLeft(word[:62], "0") + if prefix != "" { + return 0, fmt.Errorf("topic does not fit uint8") + } + n, err := strconv.ParseUint(word[62:], 16, 8) + return uint8(n), err +} + +func normalizeBytes32Topic(topic string) string { + return "0x" + strings.TrimPrefix(strings.ToLower(topic), "0x") +} + +func normalizeHexHash(hash string) string { + hash = strings.TrimSpace(strings.ToLower(hash)) + if hash == "" { + return "" + } + return "0x" + strings.TrimPrefix(hash, "0x") +} + +func parseUintAuto(value string) (uint64, error) { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return 0, nil + } + if strings.HasPrefix(value, "0x") { + return strconv.ParseUint(strings.TrimPrefix(value, "0x"), 16, 64) + } + return strconv.ParseUint(value, 10, 64) +} + +func abiBytes32(data string, wordOffset int) (string, error) { + word, err := abiWord(data, wordOffset) + if err != nil { + return "", err + } + return "0x" + word, nil +} + +func abiUint8(data string, wordOffset int) (uint8, error) { + word, err := abiWord(data, wordOffset) + if err != nil { + return 0, err + } + prefix := strings.TrimLeft(word[:62], "0") + if prefix != "" { + return 0, fmt.Errorf("ABI word does not fit uint8") + } + n, err := strconv.ParseUint(word[62:], 16, 8) + return uint8(n), err +} + +func abiUint256Decimal(data string, wordOffset int) (string, error) { + word, err := abiWord(data, wordOffset) + if err != nil { + return "", err + } + n := new(big.Int) + if _, ok := n.SetString(word, 16); !ok { + return "", fmt.Errorf("invalid ABI uint256") + } + return n.String(), nil +} + +func abiWord(data string, wordOffset int) (string, error) { + clean := strings.TrimPrefix(strings.ToLower(data), "0x") + start := wordOffset * 64 + end := start + 64 + if len(clean) < end { + return "", fmt.Errorf("ABI data shorter than word %d", wordOffset) + } + return clean[start:end], nil +} diff --git a/internal/agentkeys/audit_test.go b/internal/agentkeys/audit_test.go new file mode 100644 index 0000000..dfc1fd6 --- /dev/null +++ b/internal/agentkeys/audit_test.go @@ -0,0 +1,702 @@ +package agentkeys + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeEnvelopeSignEip712(t *testing.T) { + actor := bytesOf(0x11) + operator := bytesOf(0x22) + commitment := bytesOf(0x33) + intent := "Approve USDC 1000 to Uniswap v4 router" + + body := map[string]interface{}{ + "chain_id": uint64(212013), + "verifying_contract": "0x1111111111111111111111111111111111111111", + "primary_type": "Permit", + "type_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "domain_separator": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "digest": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + } + envelope := map[string]interface{}{ + "version": uint8(1), + "ts_unix": uint64(1710000000), + "actor_omni": actor, + "operator_omni": operator, + "op_kind": uint8(21), + "op_body": body, + "result": uint8(0), + "intent_text": intent, + "intent_commitment": commitment, + } + + cborBytes, err := EncodeCanonicalEnvelope(envelope) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(hexOf(cborBytes), "a966726573756c74"), "top-level keys must start with canonical shortest key: result") + + hash := crypto.Keccak256Hash(cborBytes).Hex() + decoded, err := DecodeEnvelope(cborBytes, hash) + require.NoError(t, err) + + assert.Equal(t, uint8(21), decoded.OpKind) + assert.Equal(t, "SignEip712", decoded.OpKindName) + assert.Equal(t, "Success", decoded.ResultName) + assert.True(t, decoded.HashVerified) + assert.Equal(t, "0x"+strings.Repeat("11", 32), decoded.ActorOmni) + require.NotNil(t, decoded.IntentText) + assert.Equal(t, intent, *decoded.IntentText) + + rendered, ok := decoded.Body.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, uint64(212013), rendered["chain_id"]) + assert.Equal(t, "Permit", rendered["primary_type"]) + assert.Equal(t, body["digest"], rendered["digest"]) +} + +func TestCriticalOpKindRenderers(t *testing.T) { + tests := []struct { + name string + opKind uint8 + opName string + body map[string]interface{} + assertion func(t *testing.T, rendered map[string]interface{}) + }{ + { + name: "ScopeGrant", + opKind: 40, + opName: "ScopeGrant", + body: map[string]interface{}{ + "agent_omni": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "service": "credential-vault", + "max_calls": uint64(5), + "max_amount": "1000000000000000000", + }, + assertion: func(t *testing.T, rendered map[string]interface{}) { + assert.Equal(t, uint64(5), rendered["max_calls"]) + assert.Equal(t, "1000000000000000000", rendered["max_amount"]) + }, + }, + { + name: "DeviceAdd", + opKind: 50, + opName: "DeviceAdd", + body: map[string]interface{}{ + "device_key_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "role_bits": uint64(7), + "attestation_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + assertion: func(t *testing.T, rendered map[string]interface{}) { + assert.Equal(t, uint64(7), rendered["role_bits"]) + assert.Equal(t, "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", rendered["device_key_hash"]) + }, + }, + { + name: "PaymentDirect", + opKind: 31, + opName: "PaymentDirect", + body: map[string]interface{}{ + "rail": "stripe", + "ref": "invoice-123", + "amount_minor": "12345", + "currency": "USD", + }, + assertion: func(t *testing.T, rendered map[string]interface{}) { + assert.Equal(t, "12345", rendered["amount_minor"]) + assert.Equal(t, "USD", rendered["currency"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cborBytes, err := EncodeCanonicalEnvelope(map[string]interface{}{ + "version": uint8(1), + "ts_unix": uint64(1710000003), + "actor_omni": bytesOf(0x12), + "operator_omni": bytesOf(0x34), + "op_kind": tt.opKind, + "op_body": tt.body, + "result": uint8(0), + "intent_text": nil, + "intent_commitment": nil, + }) + require.NoError(t, err) + + decoded, err := DecodeEnvelope(cborBytes, crypto.Keccak256Hash(cborBytes).Hex()) + require.NoError(t, err) + assert.Equal(t, tt.opName, decoded.OpKindName) + + rendered, ok := decoded.Body.(map[string]interface{}) + require.True(t, ok) + tt.assertion(t, rendered) + }) + } +} + +func TestUnknownOpKindNonBreakFallback(t *testing.T) { + for _, opKind := range []uint8{250, 255} { + opBody := map[string]interface{}{"future_field": "opaque"} + opBodyBytes, err := encodeCanonical(opBody) + require.NoError(t, err) + envelope := map[string]interface{}{ + "version": uint8(1), + "ts_unix": uint64(1710000001), + "actor_omni": bytesOf(0x44), + "operator_omni": bytesOf(0x55), + "op_kind": opKind, + "op_body": opBody, + "result": uint8(2), + "intent_text": nil, + "intent_commitment": nil, + } + cborBytes, err := EncodeCanonicalEnvelope(envelope) + require.NoError(t, err) + + decoded, err := DecodeEnvelope(cborBytes, "") + require.NoError(t, err) + + assert.Equal(t, fmt.Sprintf("Unknown(%d)", opKind), decoded.OpKindName) + assert.Equal(t, "NotPermitted", decoded.ResultName) + fallback, ok := decoded.Body.(UnknownOpBody) + require.True(t, ok) + assert.Equal(t, opKind, fallback.OpKindByte) + assert.Equal(t, base64.StdEncoding.EncodeToString(opBodyBytes), fallback.OpBodyB64) + } +} + +func TestUnknownOpKindOpaqueArrayBodyDoesNotBreak(t *testing.T) { + opBody := []interface{}{"future", uint64(7), map[string]interface{}{"nested": []interface{}{"shape"}}} + opBodyBytes, err := encodeCanonical(opBody) + require.NoError(t, err) + cborBytes, err := EncodeCanonicalEnvelope(map[string]interface{}{ + "version": uint8(1), + "ts_unix": uint64(1710000005), + "actor_omni": bytesOf(0x44), + "operator_omni": bytesOf(0x55), + "op_kind": uint8(250), + "op_body": opBody, + "result": uint8(0), + "intent_text": nil, + "intent_commitment": nil, + }) + require.NoError(t, err) + + decoded, err := DecodeEnvelope(cborBytes, "") + require.NoError(t, err) + fallback, ok := decoded.Body.(UnknownOpBody) + require.True(t, ok) + assert.Equal(t, uint8(250), fallback.OpKindByte) + assert.Equal(t, base64.StdEncoding.EncodeToString(opBodyBytes), fallback.OpBodyB64) +} + +func TestAuditEnvelopeEvidenceMatrix(t *testing.T) { + type evidenceRow struct { + Case string `json:"case"` + OpKind uint8 `json:"op_kind"` + OpKindName string `json:"op_kind_name"` + ResultName string `json:"result_name"` + HashVerified bool `json:"hash_verified"` + EnvelopeHash string `json:"envelope_hash"` + Body interface{} `json:"body"` + OpaqueOpBodyCBORBytes bool `json:"opaque_op_body_cbor_bytes"` + } + + tests := []struct { + name string + opKind uint8 + opName string + body map[string]interface{} + assertBody func(t *testing.T, body interface{}) + }{ + { + name: "SignEip712", + opKind: 21, + opName: "SignEip712", + body: map[string]interface{}{ + "chain_id": uint64(212013), + "verifying_contract": "0x1111111111111111111111111111111111111111", + "primary_type": "Permit", + "type_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "domain_separator": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "digest": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + }, + assertBody: func(t *testing.T, body interface{}) { + rendered, ok := body.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, uint64(212013), rendered["chain_id"]) + assert.Equal(t, "Permit", rendered["primary_type"]) + assert.Equal(t, "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", rendered["digest"]) + }, + }, + { + name: "ScopeGrant", + opKind: 40, + opName: "ScopeGrant", + body: map[string]interface{}{ + "agent_omni": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "service": "credential-vault", + "max_calls": uint64(5), + "max_amount": "1000000000000000000", + }, + assertBody: func(t *testing.T, body interface{}) { + rendered, ok := body.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "credential-vault", rendered["service"]) + assert.Equal(t, uint64(5), rendered["max_calls"]) + assert.Equal(t, "1000000000000000000", rendered["max_amount"]) + }, + }, + { + name: "DeviceAdd", + opKind: 50, + opName: "DeviceAdd", + body: map[string]interface{}{ + "device_key_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "role_bits": uint64(7), + "attestation_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + assertBody: func(t *testing.T, body interface{}) { + rendered, ok := body.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", rendered["device_key_hash"]) + assert.Equal(t, uint64(7), rendered["role_bits"]) + assert.Equal(t, "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", rendered["attestation_hash"]) + }, + }, + { + name: "PaymentDirect", + opKind: 31, + opName: "PaymentDirect", + body: map[string]interface{}{ + "rail": "stripe", + "ref": "invoice-123", + "amount_minor": "12345", + "currency": "USD", + }, + assertBody: func(t *testing.T, body interface{}) { + rendered, ok := body.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "stripe", rendered["rail"]) + assert.Equal(t, "invoice-123", rendered["ref"]) + assert.Equal(t, "12345", rendered["amount_minor"]) + assert.Equal(t, "USD", rendered["currency"]) + }, + }, + { + name: "UnknownFuture", + opKind: 250, + opName: "Unknown(250)", + body: map[string]interface{}{ + "future_field": "opaque", + "future_nonce": uint64(9), + }, + assertBody: func(t *testing.T, body interface{}) { + fallback, ok := body.(UnknownOpBody) + require.True(t, ok) + assert.Equal(t, uint8(250), fallback.OpKindByte) + assert.NotEmpty(t, fallback.OpBodyB64) + }, + }, + } + + rows := make([]evidenceRow, 0, len(tests)) + for i, tt := range tests { + envelopeBytes, err := EncodeCanonicalEnvelope(map[string]interface{}{ + "version": uint8(1), + "ts_unix": uint64(1710000100 + i), + "actor_omni": bytesOf(0x42), + "operator_omni": bytesOf(0x24), + "op_kind": tt.opKind, + "op_body": tt.body, + "result": uint8(0), + "intent_text": "fixture-backed evidence matrix", + "intent_commitment": bytesOf(0x77), + }) + require.NoError(t, err) + + hash := crypto.Keccak256Hash(envelopeBytes).Hex() + decoded, err := DecodeEnvelope(envelopeBytes, hash) + require.NoError(t, err) + require.True(t, decoded.HashVerified) + require.NotEmpty(t, decoded.OpaqueOpBodyCBOR) + require.Equal(t, tt.opKind, decoded.OpKind) + require.Equal(t, tt.opName, decoded.OpKindName) + require.Equal(t, "Success", decoded.ResultName) + require.Equal(t, hash, decoded.EnvelopeHash) + tt.assertBody(t, decoded.Body) + + rows = append(rows, evidenceRow{ + Case: tt.name, + OpKind: decoded.OpKind, + OpKindName: decoded.OpKindName, + ResultName: decoded.ResultName, + HashVerified: decoded.HashVerified, + EnvelopeHash: decoded.EnvelopeHash, + Body: decoded.Body, + OpaqueOpBodyCBORBytes: decoded.OpaqueOpBodyCBOR != "", + }) + } + + payload, err := json.Marshal(rows) + require.NoError(t, err) + t.Logf("AGENTKEYS_EVIDENCE_JSON=%s", payload) +} + +func TestDecodeTypedAuditRowsAndRootLeaves(t *testing.T) { + operator := "0x" + strings.Repeat("24", 32) + actor := "0x" + strings.Repeat("42", 32) + signBody := map[string]interface{}{ + "chain_id": uint64(212013), + "verifying_contract": "0x1111111111111111111111111111111111111111", + "primary_type": "Permit", + "type_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "domain_separator": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "digest": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + } + deviceBody := map[string]interface{}{ + "device_key_hash": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "role_bits": uint64(7), + "attestation_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + } + signBytes, signHash := canonicalFixtureEnvelope(t, 21, operator, actor, signBody) + deviceBytes, deviceHash := canonicalFixtureEnvelope(t, 50, operator, actor, deviceBody) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch strings.TrimPrefix(r.URL.Path, "/v1/audit/envelope/") { + case strings.TrimPrefix(signHash, "0x"): + _, _ = w.Write(signBytes) + case strings.TrimPrefix(deviceHash, "0x"): + _, _ = w.Write(deviceBytes) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + logs := []EVMLogRecord{ + auditAppendedLog(operator, actor, 21, signHash, 12, 1), + auditAppendedLog(operator, actor, 50, deviceHash, 12, 2), + } + rows, err := DecodeTypedAuditRows(context.Background(), logs, srv.URL, NewEnvelopeCache()) + require.NoError(t, err) + require.Len(t, rows, 2) + assert.Equal(t, "SignEip712", rows[0].OpKindName) + assert.Equal(t, uint64(HeimaChainID), rows[0].ChainID) + assert.Equal(t, strings.ToLower(CredentialAuditContractAddress), rows[0].ContractAddress) + assert.Equal(t, uint64(12), rows[0].Block) + assert.Equal(t, uint64(1), rows[0].LogIndex) + assert.Equal(t, "12:1", rows[0].StreamPosition) + assert.Equal(t, "DeviceAdd", rows[1].OpKindName) + + currentLog := auditAppendedCurrentLog(operator, actor, 21, signHash, 7, 13, 0) + currentRows, err := DecodeTypedAuditRows(context.Background(), []EVMLogRecord{currentLog}, srv.URL, NewEnvelopeCache()) + require.NoError(t, err) + require.Len(t, currentRows, 1) + assert.Equal(t, "AuditAppended", currentRows[0].EventName) + assert.Equal(t, "7", currentRows[0].CurrentSequence) + assert.Equal(t, "SignEip712", currentRows[0].OpKindName) + assert.True(t, currentRows[0].EnvelopeAvailable) + + rootHash := "0x" + strings.Repeat("ab", 32) + rootRows, err := DecodeAuditRootRows(context.Background(), auditRootLog(operator, rootHash, []uint8{21, 50}, 2, 12, 3), logs, srv.URL, NewEnvelopeCache()) + require.NoError(t, err) + assert.Equal(t, uint64(HeimaChainID), rootRows.ChainID) + assert.Equal(t, strings.ToLower(CredentialAuditContractAddress), rootRows.ContractAddress) + assert.Equal(t, rootHash, rootRows.MerkleRoot) + assert.Equal(t, uint64(2), rootRows.EntryCount) + assert.Equal(t, []string{signHash, deviceHash}, rootRows.Leaves) + require.Len(t, rootRows.Rows, 2) + assert.Equal(t, "SignEip712", rootRows.Rows[0].OpKindName) + assert.Equal(t, "DeviceAdd", rootRows.Rows[1].OpKindName) +} + +func TestDecodeTypedAuditRowsBestEffortKeepsLiveChainRowsWhenWorkerMissing(t *testing.T) { + operator := "0x" + strings.Repeat("94", 32) + actor := "0x" + strings.Repeat("82", 32) + envelopeHash := "0x" + strings.Repeat("6a", 32) + log := auditAppendedCurrentLog(operator, actor, 0, envelopeHash, 0, 9631477, 0) + + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + + rows, err := DecodeTypedAuditRowsBestEffort(context.Background(), []EVMLogRecord{log}, srv.URL, NewEnvelopeCache()) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "AuditAppended", rows[0].EventName) + assert.Equal(t, uint8(0), rows[0].OpKind) + assert.Equal(t, "CredStore", rows[0].OpKindName) + assert.Equal(t, envelopeHash, rows[0].EnvelopeHash) + assert.False(t, rows[0].EnvelopeAvailable) + require.NotNil(t, rows[0].EnvelopeFetchError) + assert.Contains(t, *rows[0].EnvelopeFetchError, "agentkeys audit envelope not found") +} + +func TestDecodeLiveHeimaCurrentAuditFixture(t *testing.T) { + body, err := os.ReadFile("../../tests/fixtures/agentkeys/heima-mainnet-current-auditappended.jsonl") + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(string(body)), "\n") + require.NotEmpty(t, lines) + + for _, line := range lines { + var row struct { + EventTopic string `json:"event_topic"` + ContractAddress string `json:"contract_address"` + OperatorOmni string `json:"operator_omni"` + ActorOmni string `json:"actor_omni"` + CurrentIndexedKey string `json:"current_indexed_key"` + OpKind uint8 `json:"op_kind"` + CurrentSequence string `json:"current_sequence"` + EnvelopeHash string `json:"envelope_hash"` + RawTopics []string `json:"raw_topics"` + RawData string `json:"raw_data"` + EnvelopeFetchedFromWorker bool `json:"envelope_fetched_from_worker"` + EnvelopeWorkerStatus int `json:"envelope_worker_status"` + } + require.NoError(t, json.Unmarshal([]byte(line), &row)) + require.Equal(t, CredentialAuditContractAddress, row.ContractAddress) + require.Equal(t, AuditAppendedCurrentTopic, row.EventTopic) + + decoded, err := DecodeAuditAppendedCurrentLog(row.RawTopics, row.RawData) + require.NoError(t, err) + assert.Equal(t, row.OperatorOmni, decoded.OperatorOmni) + assert.Equal(t, row.ActorOmni, decoded.ActorOmni) + assert.Equal(t, row.CurrentIndexedKey, decoded.CurrentIndexedKey) + assert.Equal(t, row.OpKind, decoded.OpKind) + assert.Equal(t, row.CurrentSequence, decoded.CurrentSequence) + assert.Equal(t, row.EnvelopeHash, decoded.EnvelopeHash) + assert.False(t, row.EnvelopeFetchedFromWorker) + assert.Equal(t, http.StatusNotFound, row.EnvelopeWorkerStatus) + } +} + +func TestDecodeEnvelopeRejectsVersionAndNonCanonicalMap(t *testing.T) { + envelope := map[string]interface{}{ + "version": uint8(2), + "ts_unix": uint64(1), + "actor_omni": bytesOf(0x01), + "operator_omni": bytesOf(0x02), + "op_kind": uint8(21), + "op_body": map[string]interface{}{"chain_id": uint64(1), "verifying_contract": "0x0", "primary_type": "Permit", "type_hash": "0x1", "domain_separator": "0x2", "digest": "0x3"}, + "result": uint8(0), + "intent_text": nil, + "intent_commitment": nil, + } + cborBytes, err := EncodeCanonicalEnvelope(envelope) + require.NoError(t, err) + _, err = DecodeEnvelope(cborBytes, "") + require.ErrorContains(t, err, "unsupported AuditEnvelope version 2") + + _, err = decodeCBOR([]byte{0xa2, 0x61, 0x62, 0x01, 0x61, 0x61, 0x02}) + require.ErrorContains(t, err, "canonical order") +} + +func TestKnownOpKindTableMatchesCanonicalIssueTable(t *testing.T) { + expected := map[uint8]string{ + 0: "CredStore", 1: "CredFetch", 2: "CredTeardown", + 10: "MemoryPut", 11: "MemoryGet", 12: "MemoryTeardown", + 20: "SignEip191", 21: "SignEip712", + 30: "PaymentEscrowRedeem", 31: "PaymentDirect", + 40: "ScopeGrant", 41: "ScopeRevoke", + 50: "DeviceAdd", 51: "DeviceRevoke", 52: "K10Rotate", + 60: "EmailSend", 61: "EmailReceive", + 70: "K3EpochAdvance", + } + assert.Equal(t, expected, KnownOpKinds()) +} + +func TestDecodeAuditEventLogs(t *testing.T) { + operator := "0x" + strings.Repeat("aa", 32) + actor := "0x" + strings.Repeat("bb", 32) + currentIndexedKey := "0x" + strings.Repeat("12", 32) + envelopeHash := "0x" + strings.Repeat("cc", 32) + opKindTopic := "0x" + strings.Repeat("0", 62) + "15" + + appended, err := DecodeAuditAppendedV2Log([]string{AuditAppendedV2Topic, operator, actor, opKindTopic}, envelopeHash) + require.NoError(t, err) + assert.Equal(t, operator, appended.OperatorOmni) + assert.Equal(t, actor, appended.ActorOmni) + assert.Equal(t, uint8(21), appended.OpKind) + assert.Equal(t, envelopeHash, appended.EnvelopeHash) + assert.Equal(t, "AuditAppendedV2", appended.EventName) + + currentData := "0x" + fmt.Sprintf("%064x", 50) + fmt.Sprintf("%064x", 9) + strings.TrimPrefix(envelopeHash, "0x") + current, err := DecodeAuditAppendedCurrentLog([]string{AuditAppendedCurrentTopic, operator, actor, currentIndexedKey}, currentData) + require.NoError(t, err) + assert.Equal(t, operator, current.OperatorOmni) + assert.Equal(t, actor, current.ActorOmni) + assert.Equal(t, uint8(50), current.OpKind) + assert.Equal(t, envelopeHash, current.EnvelopeHash) + assert.Equal(t, currentIndexedKey, current.CurrentIndexedKey) + assert.Equal(t, "9", current.CurrentSequence) + assert.Equal(t, "AuditAppended", current.EventName) + + rootData := "0x" + strings.Repeat("dd", 32) + strings.Repeat("0", 63) + "7" + root, err := DecodeAuditRootAppendedV2Log([]string{AuditRootAppendedV2Topic, operator, envelopeHash}, rootData) + require.NoError(t, err) + assert.Equal(t, "0x"+strings.Repeat("dd", 32), root.OpKindBitmapU256) + assert.Equal(t, uint64(7), root.EntryCount) +} + +func TestFetchEnvelopeAndDecodeAcceptsHashWithoutPrefix(t *testing.T) { + cborBytes, err := EncodeCanonicalEnvelope(map[string]interface{}{ + "version": uint8(1), + "ts_unix": uint64(1710000002), + "actor_omni": bytesOf(0x66), + "operator_omni": bytesOf(0x77), + "op_kind": uint8(50), + "op_body": map[string]interface{}{"device_key_hash": "0xabc", "role_bits": uint64(7), "attestation_hash": "0xdef"}, + "result": uint8(1), + "intent_text": nil, + "intent_commitment": nil, + }) + require.NoError(t, err) + hash := crypto.Keccak256Hash(cborBytes).Hex() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/audit/envelope/"+strings.TrimPrefix(hash, "0x"), r.URL.Path) + w.Header().Set("Content-Type", "application/cbor") + _, _ = w.Write(cborBytes) + })) + defer srv.Close() + + fetched, err := FetchEnvelope(context.Background(), srv.URL, strings.TrimPrefix(hash, "0x")) + require.NoError(t, err) + decoded, err := DecodeEnvelope(fetched, strings.TrimPrefix(hash, "0x")) + require.NoError(t, err) + assert.Equal(t, "DeviceAdd", decoded.OpKindName) + assert.Equal(t, "Failure", decoded.ResultName) +} + +func TestEnvelopeCacheFetchesByImmutableHashOnce(t *testing.T) { + cborBytes, err := EncodeCanonicalEnvelope(map[string]interface{}{ + "version": uint8(1), + "ts_unix": uint64(1710000004), + "actor_omni": bytesOf(0x88), + "operator_omni": bytesOf(0x99), + "op_kind": uint8(40), + "op_body": map[string]interface{}{"agent_omni": "0xabc", "service": "vault", "max_calls": uint64(1), "max_amount": "0"}, + "result": uint8(0), + "intent_text": nil, + "intent_commitment": nil, + }) + require.NoError(t, err) + hash := crypto.Keccak256Hash(cborBytes).Hex() + + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/cbor") + _, _ = w.Write(cborBytes) + })) + defer srv.Close() + + cache := NewEnvelopeCache() + for i := 0; i < 2; i++ { + body, decoded, err := cache.FetchAndDecode(context.Background(), srv.URL, hash) + require.NoError(t, err) + assert.Equal(t, cborBytes, body) + assert.Equal(t, "ScopeGrant", decoded.OpKindName) + } + assert.Equal(t, 1, requests) +} + +func canonicalFixtureEnvelope(t *testing.T, opKind uint8, operator, actor string, body map[string]interface{}) ([]byte, string) { + t.Helper() + cborBytes, err := EncodeCanonicalEnvelope(map[string]interface{}{ + "version": uint8(1), + "ts_unix": uint64(1710000200), + "actor_omni": mustHexBytes(t, actor), + "operator_omni": mustHexBytes(t, operator), + "op_kind": opKind, + "op_body": body, + "result": uint8(0), + "intent_text": nil, + "intent_commitment": nil, + }) + require.NoError(t, err) + return cborBytes, crypto.Keccak256Hash(cborBytes).Hex() +} + +func auditAppendedLog(operator, actor string, opKind uint8, envelopeHash string, block uint64, logIndex uint64) EVMLogRecord { + return EVMLogRecord{ + Address: CredentialAuditContractAddress, + Topics: []string{AuditAppendedV2Topic, operator, actor, PaddedOpKindTopic(opKind)}, + Data: envelopeHash, + BlockNumber: fmt.Sprintf("0x%x", block), + BlockHash: "0x" + strings.Repeat("11", 32), + Timestamp: "0x65f00000", + LogIndex: fmt.Sprintf("0x%x", logIndex), + TransactionHash: "0x" + strings.Repeat("22", 32), + TransactionIndex: "0x0", + } +} + +func auditAppendedCurrentLog(operator, actor string, opKind uint8, envelopeHash string, sequence uint64, block uint64, logIndex uint64) EVMLogRecord { + return EVMLogRecord{ + Address: CredentialAuditContractAddress, + Topics: []string{AuditAppendedCurrentTopic, operator, actor, "0x" + strings.Repeat("12", 32)}, + Data: "0x" + fmt.Sprintf("%064x", opKind) + fmt.Sprintf("%064x", sequence) + strings.TrimPrefix(envelopeHash, "0x"), + BlockNumber: fmt.Sprintf("0x%x", block), + BlockHash: "0x" + strings.Repeat("55", 32), + Timestamp: "0x65f00000", + LogIndex: fmt.Sprintf("0x%x", logIndex), + TransactionHash: "0x" + strings.Repeat("66", 32), + TransactionIndex: "0x0", + } +} + +func auditRootLog(operator, merkleRoot string, opKinds []uint8, entryCount uint64, block uint64, logIndex uint64) EVMLogRecord { + bitmap := make([]byte, 32) + for _, opKind := range opKinds { + bitmap[31-int(opKind)/8] |= byte(1 << uint(opKind%8)) + } + return EVMLogRecord{ + Address: CredentialAuditContractAddress, + Topics: []string{AuditRootAppendedV2Topic, operator, merkleRoot}, + Data: "0x" + hexOf(bitmap) + fmt.Sprintf("%064x", entryCount), + BlockNumber: fmt.Sprintf("0x%x", block), + BlockHash: "0x" + strings.Repeat("33", 32), + Timestamp: "0x65f00000", + LogIndex: fmt.Sprintf("0x%x", logIndex), + TransactionHash: "0x" + strings.Repeat("44", 32), + TransactionIndex: "0x0", + } +} + +func mustHexBytes(t *testing.T, value string) []byte { + t.Helper() + b, err := hex.DecodeString(strings.TrimPrefix(value, "0x")) + require.NoError(t, err) + return b +} + +func bytesOf(b byte) []byte { + out := make([]byte, 32) + for i := range out { + out[i] = b + } + return out +} + +func hexOf(b []byte) string { + const digits = "0123456789abcdef" + out := make([]byte, len(b)*2) + for i, v := range b { + out[i*2] = digits[v>>4] + out[i*2+1] = digits[v&0x0f] + } + return string(out) +} diff --git a/internal/agentkeys/cbor.go b/internal/agentkeys/cbor.go new file mode 100644 index 0000000..79f072f --- /dev/null +++ b/internal/agentkeys/cbor.go @@ -0,0 +1,339 @@ +package agentkeys + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" +) + +type cborKind int + +const ( + cborUint cborKind = iota + cborBytes + cborText + cborBool + cborNull + cborArray + cborMap +) + +type cborValue struct { + kind cborKind + u uint64 + b []byte + s string + bool bool + array []cborValue + pairs []cborPair + raw []byte +} + +type cborPair struct { + key cborValue + value cborValue +} + +func decodeCBOR(data []byte) (cborValue, error) { + p := cborParser{data: data} + v, err := p.parse() + if err != nil { + return cborValue{}, err + } + if p.off != len(data) { + return cborValue{}, fmt.Errorf("trailing CBOR bytes at offset %d", p.off) + } + return v, nil +} + +type cborParser struct { + data []byte + off int +} + +func (p *cborParser) parse() (cborValue, error) { + if p.off >= len(p.data) { + return cborValue{}, fmt.Errorf("unexpected end of CBOR") + } + start := p.off + head := p.data[p.off] + p.off++ + major := head >> 5 + ai := head & 0x1f + + switch major { + case 0: + n, err := p.readArgument(ai) + if err != nil { + return cborValue{}, err + } + return cborValue{kind: cborUint, u: n, raw: p.data[start:p.off]}, nil + case 2: + n, err := p.readArgument(ai) + if err != nil { + return cborValue{}, err + } + if n > uint64(len(p.data)-p.off) { + return cborValue{}, fmt.Errorf("byte string length %d exceeds remaining CBOR bytes", n) + } + b := append([]byte(nil), p.data[p.off:p.off+int(n)]...) + p.off += int(n) + return cborValue{kind: cborBytes, b: b, raw: p.data[start:p.off]}, nil + case 3: + n, err := p.readArgument(ai) + if err != nil { + return cborValue{}, err + } + if n > uint64(len(p.data)-p.off) { + return cborValue{}, fmt.Errorf("text string length %d exceeds remaining CBOR bytes", n) + } + s := string(p.data[p.off : p.off+int(n)]) + p.off += int(n) + return cborValue{kind: cborText, s: s, raw: p.data[start:p.off]}, nil + case 4: + n, err := p.readArgument(ai) + if err != nil { + return cborValue{}, err + } + items := make([]cborValue, 0, n) + for i := uint64(0); i < n; i++ { + item, err := p.parse() + if err != nil { + return cborValue{}, fmt.Errorf("array item %d: %w", i, err) + } + items = append(items, item) + } + return cborValue{kind: cborArray, array: items, raw: p.data[start:p.off]}, nil + case 5: + n, err := p.readArgument(ai) + if err != nil { + return cborValue{}, err + } + pairs := make([]cborPair, 0, n) + var prev []byte + seen := make(map[string]struct{}, n) + for i := uint64(0); i < n; i++ { + key, err := p.parse() + if err != nil { + return cborValue{}, fmt.Errorf("map key %d: %w", i, err) + } + if prev != nil && bytes.Compare(prev, key.raw) >= 0 { + return cborValue{}, fmt.Errorf("CBOR map keys are not in canonical order") + } + prev = key.raw + if _, ok := seen[string(key.raw)]; ok { + return cborValue{}, fmt.Errorf("duplicate CBOR map key") + } + seen[string(key.raw)] = struct{}{} + value, err := p.parse() + if err != nil { + return cborValue{}, fmt.Errorf("map value %d: %w", i, err) + } + pairs = append(pairs, cborPair{key: key, value: value}) + } + return cborValue{kind: cborMap, pairs: pairs, raw: p.data[start:p.off]}, nil + case 7: + switch ai { + case 20: + return cborValue{kind: cborBool, bool: false, raw: p.data[start:p.off]}, nil + case 21: + return cborValue{kind: cborBool, bool: true, raw: p.data[start:p.off]}, nil + case 22: + return cborValue{kind: cborNull, raw: p.data[start:p.off]}, nil + default: + return cborValue{}, fmt.Errorf("unsupported CBOR simple or float item 0x%x", head) + } + default: + return cborValue{}, fmt.Errorf("unsupported CBOR major type %d", major) + } +} + +func (p *cborParser) readArgument(ai byte) (uint64, error) { + switch ai { + case 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23: + return uint64(ai), nil + case 24: + n, err := p.readN(1) + if err != nil { + return 0, err + } + if n < 24 { + return 0, fmt.Errorf("non-shortest CBOR integer or length") + } + return n, nil + case 25: + n, err := p.readN(2) + if err != nil { + return 0, err + } + if n <= 0xff { + return 0, fmt.Errorf("non-shortest CBOR integer or length") + } + return n, nil + case 26: + n, err := p.readN(4) + if err != nil { + return 0, err + } + if n <= 0xffff { + return 0, fmt.Errorf("non-shortest CBOR integer or length") + } + return n, nil + case 27: + n, err := p.readN(8) + if err != nil { + return 0, err + } + if n <= 0xffffffff { + return 0, fmt.Errorf("non-shortest CBOR integer or length") + } + return n, nil + default: + return 0, fmt.Errorf("indefinite-length CBOR item is not canonical") + } +} + +func (p *cborParser) readN(n int) (uint64, error) { + if len(p.data)-p.off < n { + return 0, fmt.Errorf("unexpected end of CBOR") + } + var out uint64 + for i := 0; i < n; i++ { + out = (out << 8) | uint64(p.data[p.off+i]) + } + p.off += n + return out, nil +} + +func (v cborValue) textMap() (map[string]cborValue, error) { + if v.kind != cborMap { + return nil, fmt.Errorf("expected CBOR map") + } + out := make(map[string]cborValue, len(v.pairs)) + for _, pair := range v.pairs { + if pair.key.kind != cborText { + return nil, fmt.Errorf("expected text map key") + } + out[pair.key.s] = pair.value + } + return out, nil +} + +func encodeCanonical(v interface{}) ([]byte, error) { + var out []byte + if err := appendCanonical(&out, v); err != nil { + return nil, err + } + return out, nil +} + +func appendCanonical(out *[]byte, v interface{}) error { + switch x := v.(type) { + case nil: + *out = append(*out, 0xf6) + case bool: + if x { + *out = append(*out, 0xf5) + } else { + *out = append(*out, 0xf4) + } + case uint8: + appendMajor(out, 0, uint64(x)) + case uint64: + appendMajor(out, 0, x) + case uint: + appendMajor(out, 0, uint64(x)) + case int: + if x < 0 { + return fmt.Errorf("negative CBOR integers are not supported") + } + appendMajor(out, 0, uint64(x)) + case int64: + if x < 0 { + return fmt.Errorf("negative CBOR integers are not supported") + } + appendMajor(out, 0, uint64(x)) + case json.Number: + n, err := x.Int64() + if err != nil || n < 0 { + return fmt.Errorf("invalid unsigned JSON number %q", x) + } + appendMajor(out, 0, uint64(n)) + case string: + appendMajor(out, 3, uint64(len(x))) + *out = append(*out, x...) + case []byte: + appendMajor(out, 2, uint64(len(x))) + *out = append(*out, x...) + case map[string]interface{}: + return appendCanonicalMap(out, x) + case map[string]string: + m := make(map[string]interface{}, len(x)) + for k, v := range x { + m[k] = v + } + return appendCanonicalMap(out, m) + case []interface{}: + appendMajor(out, 4, uint64(len(x))) + for i := range x { + if err := appendCanonical(out, x[i]); err != nil { + return fmt.Errorf("encode array item %d: %w", i, err) + } + } + case []string: + appendMajor(out, 4, uint64(len(x))) + for i := range x { + if err := appendCanonical(out, x[i]); err != nil { + return fmt.Errorf("encode array item %d: %w", i, err) + } + } + case cborValue: + *out = append(*out, x.raw...) + default: + return fmt.Errorf("unsupported CBOR encode type %T", v) + } + return nil +} + +func appendCanonicalMap(out *[]byte, m map[string]interface{}) error { + type encodedPair struct { + key string + kcbor []byte + } + pairs := make([]encodedPair, 0, len(m)) + for k := range m { + kcbor, err := encodeCanonical(k) + if err != nil { + return err + } + pairs = append(pairs, encodedPair{key: k, kcbor: kcbor}) + } + sort.Slice(pairs, func(i, j int) bool { + return bytes.Compare(pairs[i].kcbor, pairs[j].kcbor) < 0 + }) + appendMajor(out, 5, uint64(len(pairs))) + for _, pair := range pairs { + *out = append(*out, pair.kcbor...) + if err := appendCanonical(out, m[pair.key]); err != nil { + return fmt.Errorf("encode map value %q: %w", pair.key, err) + } + } + return nil +} + +func appendMajor(out *[]byte, major byte, n uint64) { + head := major << 5 + switch { + case n < 24: + *out = append(*out, head|byte(n)) + case n <= 0xff: + *out = append(*out, head|24, byte(n)) + case n <= 0xffff: + *out = append(*out, head|25, byte(n>>8), byte(n)) + case n <= 0xffffffff: + *out = append(*out, head|26, byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) + default: + *out = append(*out, head|27, byte(n>>56), byte(n>>48), byte(n>>40), byte(n>>32), byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) + } +} diff --git a/internal/server/http/agentkeys.go b/internal/server/http/agentkeys.go new file mode 100644 index 0000000..511afba --- /dev/null +++ b/internal/server/http/agentkeys.go @@ -0,0 +1,317 @@ +package http + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "sort" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/itering/subscan/internal/agentkeys" + "github.com/itering/subscan/model" + evmdao "github.com/itering/subscan/plugins/evm/dao" +) + +const defaultAgentKeysAuditWorkerURL = "https://audit.litentry.org" + +var agentkeysEnvelopeCache = agentkeys.NewEnvelopeCache() +var agentkeysEvmAPI = &evmdao.ApiSrv{} + +type agentkeysAuditCursor struct { + Block uint64 `json:"block"` + LogIndex uint64 `json:"log_index"` +} + +type agentkeysAuditLogQuery struct { + opts []model.Option + opKind *uint8 +} + +func agentkeysAuditEnvelopeHandle(c *gin.Context) { + hash := c.Param("hash") + body, _, err := agentkeysEnvelopeCache.FetchAndDecode(c.Request.Context(), agentkeysAuditWorkerURL(), hash) + if errors.Is(err, agentkeys.ErrEnvelopeNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "not_found"}) + return + } + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.Data(http.StatusOK, "application/octet-stream", body) +} + +func agentkeysAuditRowsHandle(c *gin.Context) { + limit, err := agentkeysAuditLimit(c.Query("limit")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + sortDir := strings.ToLower(c.DefaultQuery("sort", "desc")) + if sortDir != "asc" && sortDir != "desc" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sort must be asc or desc"}) + return + } + + query, err := agentkeysAuditLogFilters(c, c.Param("operator_omni")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if cursorRaw := c.Query("cursor"); cursorRaw != "" { + cursor, err := decodeAgentKeysCursor(cursorRaw) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if sortDir == "asc" { + query.opts = append(query.opts, model.Where("(block_num > ? OR (block_num = ? AND `index` > ?))", cursor.Block, cursor.Block, cursor.LogIndex)) + } else { + query.opts = append(query.opts, model.Where("(block_num < ? OR (block_num = ? AND `index` < ?))", cursor.Block, cursor.Block, cursor.LogIndex)) + } + } + + order := "block_num desc, `index` desc" + if sortDir == "asc" { + order = "block_num asc, `index` asc" + } + logs := agentkeysAuditLogs(c, order, limit+1, query) + rows, err := agentkeys.DecodeTypedAuditRowsBestEffort(c.Request.Context(), toAgentKeysLogs(logs), agentkeysAuditWorkerURL(), agentkeysEnvelopeCache) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if query.opKind != nil { + filtered := rows[:0] + for _, row := range rows { + if row.OpKind == *query.opKind { + filtered = append(filtered, row) + } + } + rows = filtered + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].Block == rows[j].Block { + if sortDir == "asc" { + return rows[i].LogIndex < rows[j].LogIndex + } + return rows[i].LogIndex > rows[j].LogIndex + } + if sortDir == "asc" { + return rows[i].Block < rows[j].Block + } + return rows[i].Block > rows[j].Block + }) + hasNext := len(rows) > limit + if hasNext { + rows = rows[:limit] + } + var nextCursor *string + if hasNext && len(rows) > 0 { + cursor := encodeAgentKeysCursor(agentkeysAuditCursor{Block: rows[len(rows)-1].Block, LogIndex: rows[len(rows)-1].LogIndex}) + nextCursor = &cursor + } + c.JSON(http.StatusOK, agentkeys.AuditRowsPage{ + ChainID: agentkeys.HeimaChainID, + ContractAddress: agentkeysAuditContractAddress(), + Events: rows, + NextCursor: nextCursor, + }) +} + +func agentkeysAuditRootHandle(c *gin.Context) { + root := normalizeAgentKeysBytes32(c.Param("merkle_root")) + rootLogs := agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), "block_num desc, `index` desc", 1, + model.Where("address = ?", agentkeysAuditContractAddress()), + model.Where("method_hash = ?", agentkeys.AuditRootAppendedV2Topic), + model.Where("topic2 = ?", root), + ) + if len(rootLogs) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "not_found"}) + return + } + + rootRecord := toAgentKeysLogs(rootLogs)[0] + rootEvent, err := agentkeys.DecodeAuditRootAppendedV2Log(rootRecord.Topics, rootRecord.Data) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + rootBlock, err := parseAgentKeysUint(rootRecord.BlockNumber) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "root blockNumber: " + err.Error()}) + return + } + rootLogIndex, err := parseAgentKeysUint(rootRecord.LogIndex) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "root logIndex: " + err.Error()}) + return + } + opKindTopics, err := agentkeys.OpKindTopicsFromBitmap(rootEvent.OpKindBitmapU256) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + + leafLogs := []evmdao.EtherscanLogsRes{} + if rootEvent.EntryCount > 0 && len(opKindTopics) > 0 { + leafLogs = agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), "block_num desc, `index` desc", int(rootEvent.EntryCount), + model.Where("address = ?", agentkeysAuditContractAddress()), + model.Where("method_hash = ?", agentkeys.AuditAppendedV2Topic), + model.Where("topic1 = ?", rootEvent.OperatorOmni), + model.Where("topic3 in ?", opKindTopics), + model.Where("(block_num < ? OR (block_num = ? AND `index` < ?))", rootBlock, rootBlock, rootLogIndex), + ) + } + + rows, err := agentkeys.DecodeAuditRootRows(c.Request.Context(), rootRecord, toAgentKeysLogs(leafLogs), agentkeysAuditWorkerURL(), agentkeysEnvelopeCache) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, rows) +} + +func agentkeysAuditWorkerURL() string { + workerURL := os.Getenv("AGENTKEYS_AUDIT_WORKER_URL") + if workerURL == "" { + return defaultAgentKeysAuditWorkerURL + } + return workerURL +} + +func agentkeysAuditContractAddress() string { + contract := os.Getenv("AGENTKEYS_CREDENTIAL_AUDIT_CONTRACT") + if contract == "" { + contract = agentkeys.CredentialAuditContractAddress + } + return normalizeAgentKeysAddress(contract) +} + +func agentkeysAuditLimit(raw string) (int, error) { + if raw == "" { + return 50, nil + } + limit, err := strconv.Atoi(raw) + if err != nil || limit < 1 { + return 0, fmt.Errorf("limit must be a positive integer") + } + if limit > 500 { + return 0, fmt.Errorf("limit must be <= 500") + } + return limit, nil +} + +func agentkeysAuditLogFilters(c *gin.Context, operator string) (agentkeysAuditLogQuery, error) { + opts := []model.Option{ + model.Where("address = ?", agentkeysAuditContractAddress()), + model.Where("topic1 = ?", normalizeAgentKeysBytes32(operator)), + } + var opKind *uint8 + if opKindRaw := c.Query("op_kind"); opKindRaw != "" { + n, err := strconv.ParseUint(opKindRaw, 10, 8) + if err != nil { + return agentkeysAuditLogQuery{}, fmt.Errorf("op_kind must fit uint8") + } + value := uint8(n) + opKind = &value + } + if actor := c.Query("actor_omni"); actor != "" { + opts = append(opts, model.Where("topic2 = ?", normalizeAgentKeysBytes32(actor))) + } + if from := c.Query("from_block"); from != "" { + n, err := strconv.ParseUint(from, 10, 64) + if err != nil { + return agentkeysAuditLogQuery{}, fmt.Errorf("from_block must be uint") + } + opts = append(opts, model.Where("block_num >= ?", n)) + } + if to := c.Query("to_block"); to != "" { + n, err := strconv.ParseUint(to, 10, 64) + if err != nil { + return agentkeysAuditLogQuery{}, fmt.Errorf("to_block must be uint") + } + opts = append(opts, model.Where("block_num <= ?", n)) + } + return agentkeysAuditLogQuery{opts: opts, opKind: opKind}, nil +} + +func agentkeysAuditLogs(c *gin.Context, order string, limit int, query agentkeysAuditLogQuery) []evmdao.EtherscanLogsRes { + v2Opts := appendAgentKeysAuditOpts(query.opts, model.Where("method_hash = ?", agentkeys.AuditAppendedV2Topic)) + if query.opKind != nil { + v2Opts = append(v2Opts, model.Where("topic3 = ?", agentkeys.PaddedOpKindTopic(*query.opKind))) + } + currentOpts := appendAgentKeysAuditOpts(query.opts, model.Where("method_hash = ?", agentkeys.AuditAppendedCurrentTopic)) + if query.opKind != nil { + currentOpts = append(currentOpts, model.Where("data like ?", "0x"+fmt.Sprintf("%064x", *query.opKind)+"%")) + } + logs := agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), order, limit, v2Opts...) + logs = append(logs, agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), order, limit, currentOpts...)...) + return logs +} + +func appendAgentKeysAuditOpts(base []model.Option, extra ...model.Option) []model.Option { + out := make([]model.Option, 0, len(base)+len(extra)) + out = append(out, base...) + out = append(out, extra...) + return out +} + +func toAgentKeysLogs(logs []evmdao.EtherscanLogsRes) []agentkeys.EVMLogRecord { + out := make([]agentkeys.EVMLogRecord, 0, len(logs)) + for _, log := range logs { + out = append(out, agentkeys.EVMLogRecord{ + Address: log.Address, + Topics: log.Topics, + Data: log.Data, + BlockNumber: log.BlockNumber, + BlockHash: log.BlockHash, + Timestamp: log.Timestamp, + LogIndex: log.LogIndex, + TransactionHash: log.TransactionHash, + TransactionIndex: log.TransactionIndex, + }) + } + return out +} + +func normalizeAgentKeysBytes32(value string) string { + return "0x" + strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x") +} + +func normalizeAgentKeysAddress(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "0x") + value = strings.TrimPrefix(value, "0X") + return "0x" + value +} + +func encodeAgentKeysCursor(cursor agentkeysAuditCursor) string { + payload, _ := json.Marshal(cursor) + return base64.RawURLEncoding.EncodeToString(payload) +} + +func decodeAgentKeysCursor(raw string) (agentkeysAuditCursor, error) { + payload, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return agentkeysAuditCursor{}, fmt.Errorf("invalid cursor") + } + var cursor agentkeysAuditCursor + if err := json.Unmarshal(payload, &cursor); err != nil { + return agentkeysAuditCursor{}, fmt.Errorf("invalid cursor") + } + return cursor, nil +} + +func parseAgentKeysUint(value string) (uint64, error) { + value = strings.TrimSpace(strings.ToLower(value)) + if strings.HasPrefix(value, "0x") { + return strconv.ParseUint(strings.TrimPrefix(value, "0x"), 16, 64) + } + return strconv.ParseUint(value, 10, 64) +} diff --git a/internal/server/http/http.go b/internal/server/http/http.go index fac5122..d8dc075 100644 --- a/internal/server/http/http.go +++ b/internal/server/http/http.go @@ -46,6 +46,9 @@ func initRouter(e *gin.Engine) { e.GET("ping", ping) e.GET("healthz", livenessProbe) e.GET("readiness", readinessProbe) + e.GET("agentkeys/audit/envelope/:hash", agentkeysAuditEnvelopeHandle) + e.GET("agentkeys/audit/root/:merkle_root", agentkeysAuditRootHandle) + e.GET("agentkeys/audit/:operator_omni", agentkeysAuditRowsHandle) customValidator.RegisterCustomValidator() // internal g := e.Group("/api") diff --git a/plugins/evm/dao/api.go b/plugins/evm/dao/api.go index 00b53ac..659cae0 100644 --- a/plugins/evm/dao/api.go +++ b/plugins/evm/dao/api.go @@ -58,7 +58,23 @@ type EtherscanLogsRes struct { func (a *ApiSrv) API_GetLogs(ctx context.Context, opts ...model.Option) (res []EtherscanLogsRes) { var list []TransactionReceipt sg.db.WithContext(ctx).Scopes(opts...).Order("id desc").Find(&list) + return transactionReceiptsToEtherscanLogs(ctx, list) +} +func (a *ApiSrv) API_GetLogsForAgentKeys(ctx context.Context, order string, limit int, opts ...model.Option) (res []EtherscanLogsRes) { + var list []TransactionReceipt + query := sg.db.WithContext(ctx).Scopes(opts...) + if order != "" { + query = query.Order(order) + } + if limit > 0 { + query = query.Limit(limit) + } + query.Find(&list) + return transactionReceiptsToEtherscanLogs(ctx, list) +} + +func transactionReceiptsToEtherscanLogs(ctx context.Context, list []TransactionReceipt) (res []EtherscanLogsRes) { var ( blockNums []uint64 hashes []string @@ -477,12 +493,12 @@ func (a *ApiSrv) AccountsCursor(ctx context.Context, address string, limit int, fetch := limit + 1 q := sg.db.WithContext(ctx).Select("evm_account,balance").Model(&Account{}).Joins("join balance_accounts on evm_accounts.address=balance_accounts.address") if address != "" { - q.Where("evm_account = ?", address) + q = q.Where("evm_account = ?", address) } if cursor := cursorDecode(after); len(cursor) == 2 { q = q.Where("(balance,evm_account) < (?,?)", cursor[0], cursor[1]).Order("balance desc").Order("balance_accounts.address desc") - } else if cursor = cursorDecode(after); len(cursor) == 2 { - q = q.Where("(balance,evm_account) < (?,?)", cursor[0], cursor[1]).Order("balance asc").Order("balance_accounts.address asc") + } else if cursor = cursorDecode(before); len(cursor) == 2 { + q = q.Where("(balance,evm_account) > (?,?)", cursor[0], cursor[1]).Order("balance asc").Order("balance_accounts.address asc") } else { q = q.Order("balance desc").Order("balance_accounts.address desc") } @@ -582,7 +598,7 @@ func (a *ApiSrv) AccountTokens(ctx context.Context, address, category string) [] q := sg.db.WithContext(ctx).Select("evm_token_holders.contract,balance,category,decimals,symbol,name").Model(&TokenHolder{}). Joins("join evm_tokens on evm_token_holders.contract=evm_tokens.contract").Where("holder = ?", address) if category != "" { - q.Where("category = ?", category) + q = q.Where("category = ?", category) } q.Scan(&tokenHolders) return tokenHolders @@ -609,10 +625,10 @@ func (a *ApiSrv) CollectiblesCursor(ctx context.Context, address string, contrac fetch := limit + 1 q := sg.db.WithContext(ctx).Model(&Erc721Holders{}) if address != "" { - q.Where("holder = ?", address) + q = q.Where("holder = ?", address) } if contract != "" { - q.Where("contract = ?", contract) + q = q.Where("contract = ?", contract) } if cursor := cursorDecode(after); len(cursor) == 2 { q = q.Where("(contract,token_id) < (?,?)", cursor[0], cursor[1]).Order("contract desc").Order("token_id desc") @@ -662,10 +678,10 @@ func (a *ApiSrv) TokenListCursor(ctx context.Context, contract, category string, fetch := limit + 1 q := sg.db.WithContext(ctx).Model(&Token{}) if category != "" { - q.Where("category = ?", category) + q = q.Where("category = ?", category) } if contract != "" { - q.Where("contract = ?", contract) + q = q.Where("contract = ?", contract) } if cursor := cursorDecode(after); len(cursor) == 2 { q = q.Where("(holders,contract) < (?,?)", cursor[0], cursor[1]).Order("holders desc").Order("contract desc") @@ -715,13 +731,13 @@ func (a *ApiSrv) TokenTransfersCursor(ctx context.Context, address, tokenAddress fetch := limit + 1 q := sg.db.WithContext(ctx).Model(&TokensTransfers{}) if address != "" { - q.Where("sender = ? or receiver = ?", address, address) + q = q.Where("sender = ? or receiver = ?", address, address) } if tokenAddress != "" { - q.Where("contract = ?", tokenAddress) + q = q.Where("contract = ?", tokenAddress) } if category != "" { - q.Where("category = ?", category) + q = q.Where("category = ?", category) } if after != nil && *after > 0 { q = q.Where("transfer_id < ?", *after).Order("transfer_id desc") @@ -782,7 +798,7 @@ func (a *ApiSrv) TokenHoldersCursor(ctx context.Context, address string, limit i var list []TokenHolder fetch := limit + 1 q := sg.db.WithContext(ctx).Model(&TokenHolder{}).Where("balance > 0") - q.Where("contract = ?", address) + q = q.Where("contract = ?", address) if cursor := cursorDecode(after); len(cursor) == 2 { q = q.Where("(balance,id) < (?,?)", cursor[0], cursor[1]).Order("balance desc").Order("id desc") } else if cursor = cursorDecode(before); len(cursor) == 2 { diff --git a/plugins/evm/dao/api_cursor_test.go b/plugins/evm/dao/api_cursor_test.go new file mode 100644 index 0000000..6236dbd --- /dev/null +++ b/plugins/evm/dao/api_cursor_test.go @@ -0,0 +1,71 @@ +package dao + +import ( + "context" + "testing" + + balanceModel "github.com/itering/subscan/plugins/balance/model" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAccountsCursorFiltersByEvmAccount(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&Account{}, &balanceModel.Account{})) + + sg = &Storage{db: db} + + ctx := context.Background() + target := "0x63c4545ac01c77cc74044f25b8edea3880224577" + other := "0x1111111111111111111111111111111111111111" + + require.NoError(t, db.Create(&Account{Address: "target-account", EvmAccount: target}).Error) + require.NoError(t, db.Create(&Account{Address: "other-account", EvmAccount: other}).Error) + require.NoError(t, db.Create(&balanceModel.Account{Address: "target-account", Balance: decimal.NewFromInt(5)}).Error) + require.NoError(t, db.Create(&balanceModel.Account{Address: "other-account", Balance: decimal.NewFromInt(10)}).Error) + + list, page := (&ApiSrv{}).AccountsCursor(ctx, target, 10, nil, nil) + + require.Len(t, list, 1) + assert.Equal(t, target, list[0].EvmAccount) + assert.Equal(t, decimal.NewFromInt(5), list[0].Balance) + assert.Equal(t, false, page["has_next_page"]) +} + +func TestAccountsCursorBeforeUsesBeforeCursor(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&Account{}, &balanceModel.Account{})) + + sg = &Storage{db: db} + + ctx := context.Background() + accounts := []struct { + account string + balance int64 + }{ + {account: "0x0000000000000000000000000000000000000003", balance: 30}, + {account: "0x0000000000000000000000000000000000000002", balance: 20}, + {account: "0x0000000000000000000000000000000000000001", balance: 10}, + } + for _, account := range accounts { + address := "substrate-" + account.account + require.NoError(t, db.Create(&Account{Address: address, EvmAccount: account.account}).Error) + require.NoError(t, db.Create(&balanceModel.Account{Address: address, Balance: decimal.NewFromInt(account.balance)}).Error) + } + + cursor := AccountsJson{ + EvmAccount: "0x0000000000000000000000000000000000000002", + Balance: decimal.NewFromInt(20), + }.Cursor() + list, page := (&ApiSrv{}).AccountsCursor(ctx, "", 10, &cursor, nil) + + require.Len(t, list, 1) + assert.Equal(t, accounts[0].account, list[0].EvmAccount) + assert.Equal(t, false, page["has_previous_page"]) + assert.Equal(t, true, page["has_next_page"]) +} diff --git a/plugins/evm/dao/contract.go b/plugins/evm/dao/contract.go index a372b95..0792646 100644 --- a/plugins/evm/dao/contract.go +++ b/plugins/evm/dao/contract.go @@ -6,12 +6,15 @@ import ( "errors" "fmt" "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "github.com/itering/subscan/model" evmABI "github.com/itering/subscan/plugins/evm/abi" evmContract "github.com/itering/subscan/plugins/evm/contract" "github.com/itering/subscan/plugins/evm/feature/delegateProxy" "github.com/itering/subscan/share/web3" "github.com/itering/subscan/util" + "github.com/itering/subscan/util/address" "regexp" "strconv" "strings" @@ -264,8 +267,22 @@ func ContractMethodList(ctx context.Context) (list []datatypes.JSON, err error) } func ContractsByAddr(ctx context.Context, contracts string) (contract *Contract) { - if q := sg.db.Model(Contract{}).Where("address = ?", contracts).First(&contract); q.Error != nil { - return nil + contractAddress := address.Format(contracts) + if contractAddress == "" { + contractAddress = contracts + } + + var dbContract Contract + if q := sg.db.WithContext(ctx).Model(Contract{}).Where("address = ?", contractAddress).First(&dbContract); q.Error != nil { + if !errors.Is(q.Error, gorm.ErrRecordNotFound) { + return nil + } + contract = backfillContractFromRuntimeCode(ctx, contractAddress) + if contract == nil { + return nil + } + } else { + contract = &dbContract } if len(contract.Abi) > 0 && contract.Abi.String() != "null" { @@ -274,6 +291,27 @@ func ContractsByAddr(ctx context.Context, contracts string) (contract *Contract) return } +func backfillContractFromRuntimeCode(ctx context.Context, contractAddress string) *Contract { + if web3.RPC == nil || web3.RPC.Eth == nil { + return nil + } + + code, err := web3.RPC.Eth.GetCode(ctx, contractAddress, "latest") + if err != nil || code == "" || code == "0x" { + return nil + } + + contract := &Contract{ + Address: contractAddress, + CreationBytecode: code, + DeployCodeHash: common.BytesToHash(crypto.Keccak256(util.HexToBytes(code))).Hex(), + } + if err = sg.AddOrUpdateItem(ctx, contract, []string{"address"}, "creation_bytecode", "deploy_code_hash").Error; err != nil { + return nil + } + return contract +} + func findEventIdentifiers(_ context.Context, abiRaw []byte) []byte { var abiValue abi.ABI _ = abiValue.UnmarshalJSON(abiRaw) diff --git a/scripts/deploy-test-explorer-api.sh b/scripts/deploy-test-explorer-api.sh new file mode 100755 index 0000000..c092db1 --- /dev/null +++ b/scripts/deploy-test-explorer-api.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="${REPO_URL:-https://github.com/litentry/subscan-essentials.git}" +BRANCH="${BRANCH:-crossagent}" +DEPLOY_ROOT="${DEPLOY_ROOT:-/opt/subscan}" +SRC_DIR="${SRC_DIR:-$DEPLOY_ROOT/subscan-essentials-ci}" +NETWORK="${NETWORK:-subscan-essentials_subscan_net}" + +MAIN_CONTAINER="${MAIN_CONTAINER:-subscan-essentials-subscan-api-1}" +CROSSAGENT_CONTAINER="${CROSSAGENT_CONTAINER:-subscan-essentials-crossagent-subscan-api-crossagent-1}" +MAIN_IMAGE="${MAIN_IMAGE:-subscan/api}" +CROSSAGENT_IMAGE="${CROSSAGENT_IMAGE:-subscan/api:crossagent}" + +MYSQL_PASSWORD="${MYSQL_PASSWORD:-subscan2024heima}" +CHAIN_WS_ENDPOINT="${CHAIN_WS_ENDPOINT:-wss://rpc.heima-parachain.heima.network}" +ETH_RPC="${ETH_RPC:-https://rpc.heima-parachain.heima.network}" +NETWORK_NODE="${NETWORK_NODE:-heima}" + +run_sudo() { + sudo -n "$@" +} + +ensure_source() { + run_sudo mkdir -p "$DEPLOY_ROOT" + run_sudo chown "$(id -u):$(id -g)" "$DEPLOY_ROOT" + + if [[ ! -d "$SRC_DIR/.git" ]]; then + rm -rf "$SRC_DIR.tmp" + git clone --branch "$BRANCH" --single-branch "$REPO_URL" "$SRC_DIR.tmp" + touch "$SRC_DIR.tmp/.ci-deploy-owned" + mv "$SRC_DIR.tmp" "$SRC_DIR" + return + fi + + if [[ ! -f "$SRC_DIR/.ci-deploy-owned" ]]; then + echo "Refusing to update $SRC_DIR because it is not marked as CI-owned." >&2 + exit 1 + fi + + git -C "$SRC_DIR" fetch --prune origin "$BRANCH" + git -C "$SRC_DIR" checkout -B "$BRANCH" "origin/$BRANCH" + git -C "$SRC_DIR" reset --hard "origin/$BRANCH" + git -C "$SRC_DIR" clean -fdx -e .ci-deploy-owned +} + +ensure_supporting_services() { + run_sudo docker start subscan-essentials-mysql-1 >/dev/null + run_sudo docker start subscan-essentials-redis-1 >/dev/null +} + +build_images() { + run_sudo docker build --pull -t "$CROSSAGENT_IMAGE" "$SRC_DIR" + run_sudo docker tag "$CROSSAGENT_IMAGE" "$MAIN_IMAGE" +} + +replace_api_container() { + local name="$1" + local image="$2" + local host_port="$3" + local mysql_host="$4" + local redis_addr="$5" + + if run_sudo docker ps -a --format '{{.Names}}' | grep -Fxq "$name"; then + run_sudo docker rm -f "$name" >/dev/null + fi + + run_sudo docker run -d \ + --name "$name" \ + --restart always \ + --network "$NETWORK" \ + -p "$host_port:4399" \ + -e "MYSQL_HOST=$mysql_host" \ + -e "MYSQL_PASS=$MYSQL_PASSWORD" \ + -e MYSQL_USER=root \ + -e MYSQL_DB=subscan \ + -e "REDIS_ADDR=$redis_addr" \ + -e "CHAIN_WS_ENDPOINT=$CHAIN_WS_ENDPOINT" \ + -e "ETH_RPC=$ETH_RPC" \ + -e "NETWORK_NODE=$NETWORK_NODE" \ + -e DEPLOY_ENV=prod \ + "$image" >/dev/null +} + +wait_for_api() { + local host_port="$1" + local body="" + + for _ in {1..30}; do + body="$(curl -fsS -X POST "http://127.0.0.1:$host_port/api/plugin/evm/accounts" \ + -H 'content-type: application/json' \ + --data '{"row":1}' 2>/dev/null || true)" + if [[ "$body" == *'"code":0'* ]]; then + echo "API on $host_port is healthy." + return + fi + sleep 2 + done + + echo "API on $host_port did not become healthy." >&2 + run_sudo docker ps --filter "publish=$host_port" + exit 1 +} + +main() { + ensure_source + ensure_supporting_services + build_images + + replace_api_container "$MAIN_CONTAINER" "$MAIN_IMAGE" 4399 mysql redis:6379 + wait_for_api 4399 + + replace_api_container "$CROSSAGENT_CONTAINER" "$CROSSAGENT_IMAGE" 4599 subscan-essentials-mysql-1 subscan-essentials-redis-1:6379 + wait_for_api 4599 + + run_sudo docker ps \ + --filter "name=$MAIN_CONTAINER" \ + --filter "name=$CROSSAGENT_CONTAINER" \ + --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}' +} + +main "$@" diff --git a/tests/fixtures/agentkeys/LIVE_HEIMA_CAPTURE.md b/tests/fixtures/agentkeys/LIVE_HEIMA_CAPTURE.md new file mode 100644 index 0000000..f77aa78 --- /dev/null +++ b/tests/fixtures/agentkeys/LIVE_HEIMA_CAPTURE.md @@ -0,0 +1,35 @@ +# AgentKeys Heima Live Capture + +Issue #12 comment data identifies the current Heima Mainnet +`CredentialAudit` contract as: + +`0x63c4545ac01c77cc74044f25b8edea3880224577` + +This fixture set records real logs from that contract and keeps them separate +from hand-crafted unit fixtures. + +## Files + +- `heima-mainnet-current-auditappended.jsonl` captures live + `AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)` logs from + Heima Mainnet over the latest-block window used when the fixture was + generated. + +Each JSONL row records the raw topics, raw ABI data, decoded operator/actor, +decoded `op_kind`, decoded current sequence, transaction identity, block +identity, and the worker fetch status for the emitted hash. + +## Runtime behavior represented by this capture + +- The backend supports the issue #12 V2 event topic: + `AuditAppendedV2(bytes32,bytes32,uint8,bytes32)`. +- The backend also supports the live Heima current event topic: + `AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)`. +- The live current-event hashes in this capture returned `404` from + `https://audit.litentry.org/v1/audit/envelope/` at capture time, so + the REST row path preserves the chain row with `envelope_available=false` + instead of failing the whole page. + +When the upstream publisher starts emitting worker-backed V2 hashes, the same +strict decoder path verifies `keccak256(canonical_cbor_bytes) == envelope_hash` +and renders typed bodies. diff --git a/tests/fixtures/agentkeys/heima-mainnet-current-auditappended.jsonl b/tests/fixtures/agentkeys/heima-mainnet-current-auditappended.jsonl new file mode 100644 index 0000000..dbba6b0 --- /dev/null +++ b/tests/fixtures/agentkeys/heima-mainnet-current-auditappended.jsonl @@ -0,0 +1,13 @@ +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9625257,"block_hash":"0xd1b2a15dfc58a9921f5cb1d16e4d6cc07da8aec8fac68f093cdf21d36cec358f","timestamp":1779211140,"txhash":"0xcc5dfeac4a35f3850a5684003e4449508333d7cd8ffa200dcf747d39748f8725","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"0","envelope_hash":"0xdb927ad4467c02867819a1379c7c0b9a35103452c789badeae6e531b5d2f8e1c","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000db927ad4467c02867819a1379c7c0b9a35103452c789badeae6e531b5d2f8e1c","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/db927ad4467c02867819a1379c7c0b9a35103452c789badeae6e531b5d2f8e1c","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9625271,"block_hash":"0xbdf6678d2b2964c3f9ef661f7d29ce06fadaab28ddfdd92a8bcfb7cd128f39f8","timestamp":1779211392,"txhash":"0xe36fa154d7cd19571091f6628c1b2690afa18944e07bba7603e7dd92cf77c23b","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"1","envelope_hash":"0x3d67f9734a38829d9e2289cd9551caa7f50ba66bd521981fb8504be4ab23a223","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000013d67f9734a38829d9e2289cd9551caa7f50ba66bd521981fb8504be4ab23a223","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/3d67f9734a38829d9e2289cd9551caa7f50ba66bd521981fb8504be4ab23a223","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9629067,"block_hash":"0x91062edd9f91d85bd7a640d28f16a42635626e8a3c56e29045ac8e3868e3c4e8","timestamp":1779279768,"txhash":"0x2ebe0882d13657656cadad6ac6f49fb970f664c8146a4cf6b14fb8df8ab705c4","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"2","envelope_hash":"0x49cae2750e5482695bce0e3d9f044aa35e12bb9f497fa40c8ce100744ca5af16","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000249cae2750e5482695bce0e3d9f044aa35e12bb9f497fa40c8ce100744ca5af16","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/49cae2750e5482695bce0e3d9f044aa35e12bb9f497fa40c8ce100744ca5af16","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9629177,"block_hash":"0xc107d52d71d35a773f452ce67b417736c249a9cc7953501680c8dd0c862db809","timestamp":1779281748,"txhash":"0xfdf500787f707f530d326fdff42b45d15e065e35354b9d8d5227115dd6077435","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"3","envelope_hash":"0xf9388e99e146695f7f36d17884dbf9d1515341bfcc2c075c0d3aefefbab770c4","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003f9388e99e146695f7f36d17884dbf9d1515341bfcc2c075c0d3aefefbab770c4","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/f9388e99e146695f7f36d17884dbf9d1515341bfcc2c075c0d3aefefbab770c4","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9629259,"block_hash":"0x438eddbf73190d357cc2db14f910c9e67c539508c447a4d4dff1716e2da107bf","timestamp":1779283224,"txhash":"0xbbfadd4aa1bfef30dc72b492e74eda9f226bd286a1f98647df6504265f25ab9f","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"4","envelope_hash":"0xc9756bb650034e1a258711fe34fcac3e532b2d094a6a9458ca4b75c5faef43e5","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004c9756bb650034e1a258711fe34fcac3e532b2d094a6a9458ca4b75c5faef43e5","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/c9756bb650034e1a258711fe34fcac3e532b2d094a6a9458ca4b75c5faef43e5","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9629582,"block_hash":"0x3c33abf3785c05ee8884e3aeed8bd49521b6b778a77030f25677faf16920b8d4","timestamp":1779289044,"txhash":"0xff4603060ce729e47f034ceb3e1bcb3acec5f8c594ad4ccfa5c100fe86a92110","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"5","envelope_hash":"0x791f5c3905c4720187206ba9beb5a64fc7b46ac076700fb65247b63063927bd4","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005791f5c3905c4720187206ba9beb5a64fc7b46ac076700fb65247b63063927bd4","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/791f5c3905c4720187206ba9beb5a64fc7b46ac076700fb65247b63063927bd4","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9629797,"block_hash":"0x70ca081ca11b4f691de395858a83a72ee437f60b17fe87847b9ae43f85a113bb","timestamp":1779292908,"txhash":"0xc0cb5d4bd8a4cb03a295584d1c3711b5e8fc19c30f034e888909f1c506042b70","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"6","envelope_hash":"0xa8c6845b18080cfcb147955b7bae95c000aab0f2862f2334117db7df033f77e6","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006a8c6845b18080cfcb147955b7bae95c000aab0f2862f2334117db7df033f77e6","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/a8c6845b18080cfcb147955b7bae95c000aab0f2862f2334117db7df033f77e6","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9631477,"block_hash":"0x8bd2b4238b927e6775d6e30584a24f43266d2ae081836693552a2c88dd5551ab","timestamp":1779323148,"txhash":"0x1de17851782b78d908271777a55b65c82b541cfef51f749660008b67072c93a6","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"7","envelope_hash":"0x441ba20cbdcef45e003bd4976fbe2696b8dd3bb5fdb002ae785d2e574eed072f","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007441ba20cbdcef45e003bd4976fbe2696b8dd3bb5fdb002ae785d2e574eed072f","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/441ba20cbdcef45e003bd4976fbe2696b8dd3bb5fdb002ae785d2e574eed072f","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9631511,"block_hash":"0x1e3427080d8c131413cec89247c684c9f6288420ea21c23f8c4b0292e598e553","timestamp":1779323760,"txhash":"0x86611a1a2d6495d2c4907bdf73df7f954135cd472020e20f510898715614ce47","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"8","envelope_hash":"0x43b1a36f8394c0fc1d56205f3f991293f7068d63cbe3c746f8d7be7977a3d0c3","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000843b1a36f8394c0fc1d56205f3f991293f7068d63cbe3c746f8d7be7977a3d0c3","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/43b1a36f8394c0fc1d56205f3f991293f7068d63cbe3c746f8d7be7977a3d0c3","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9631526,"block_hash":"0x496a78da5167ca9c558348094b59dc972b9b14e1512d323382dbc131656d5238","timestamp":1779324036,"txhash":"0x0714108c2b5698955e06201b4f33f1fa34df05c4d967d797a4392876ec55ee06","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"9","envelope_hash":"0x79000f2a453441ba43a9fcfd8753bd80a4291dd46aaa4aa9cc96d19d209e8baa","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000979000f2a453441ba43a9fcfd8753bd80a4291dd46aaa4aa9cc96d19d209e8baa","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/79000f2a453441ba43a9fcfd8753bd80a4291dd46aaa4aa9cc96d19d209e8baa","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9631590,"block_hash":"0x25c5d4cf72be520182bbaf12d7d094fdc5f774b147253fe77985527e40893015","timestamp":1779325188,"txhash":"0x843d88b0f296057dbda37a7d59c2831bcd10cc27e57232cb746e20293d61c60d","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"10","envelope_hash":"0x4687f760770e410ac33cc5acbd5fb8679d9d14d73b36cbbfe26432858532db84","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a4687f760770e410ac33cc5acbd5fb8679d9d14d73b36cbbfe26432858532db84","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/4687f760770e410ac33cc5acbd5fb8679d9d14d73b36cbbfe26432858532db84","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9631811,"block_hash":"0x4d94ae5346984f74ab609d27ebcc3f2844ccad4917c4b53f7d4a0f91affa4d60","timestamp":1779329160,"txhash":"0x857a1e9970d9515365ff1b38113a7e784365d85c077521dbb0c1c43dc04558f1","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"11","envelope_hash":"0x6a40139f8bbe202c51c732a0ccd94a2046a10b351cdd0f32f50b8fd2967f9c8e","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b6a40139f8bbe202c51c732a0ccd94a2046a10b351cdd0f32f50b8fd2967f9c8e","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/6a40139f8bbe202c51c732a0ccd94a2046a10b351cdd0f32f50b8fd2967f9c8e","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} +{"artifact":"heima-mainnet-current-auditappended","chain_id":212013,"contract_address":"0x63c4545ac01c77cc74044f25b8edea3880224577","event_name":"AuditAppended","event_signature":"AuditAppended(bytes32,bytes32,bytes32,uint8,uint256,bytes32)","event_topic":"0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","capture_from_block":9621504,"capture_to_block":9634819,"block_number":9632387,"block_hash":"0xeef750f12cf5adcc9b415d0e9fc1ad8d5b0744935251651741336bc201da3e30","timestamp":1779339528,"txhash":"0xff432b788cd1b3d4aacf24d527b35209a108ad29b2b395fbfa26a3c63c49e2e8","transaction_index":0,"log_index":0,"operator_omni":"0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","actor_omni":"0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","current_indexed_key":"0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901","op_kind":0,"current_sequence":"12","envelope_hash":"0xea64d7dc3b0e6c83ec9afb57294aa58ce786fdd1675e7f69c6bd5372c1a13446","raw_topics":["0x4e21321d01571fa35038552651b1fd51fdb2935e1c8566378607aecf7fa70919","0x941cb1c3260518bbf40eac7d02663517fc7cff304d9b03e80d2cc54126c6bef2","0x82a0609ae28527453e7c7654ec11d57f1b66a442e39a2ec5e3b3f76178c87268","0x9d7e3ece28d263e0a6e678cbd132b82021f93a63ae6e7daadf937b2ed9a1e901"],"raw_data":"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cea64d7dc3b0e6c83ec9afb57294aa58ce786fdd1675e7f69c6bd5372c1a13446","envelope_worker_url":"https://audit.litentry.org/v1/audit/envelope/ea64d7dc3b0e6c83ec9afb57294aa58ce786fdd1675e7f69c6bd5372c1a13446","envelope_worker_status":404,"envelope_fetched_from_worker":false,"envelope_worker_content_type":"","envelope_worker_size_bytes":0} diff --git a/ui-react/package.json b/ui-react/package.json index aa3cbdd..b43d4a6 100644 --- a/ui-react/package.json +++ b/ui-react/package.json @@ -2,7 +2,7 @@ "name": "subscan-essential-ui-next", "version": "0.1.0", "scripts": { - "dev": "NEXT_PUBLIC_API_HOST='https://assethub-westend-lite.webapi.subscan.io' next dev", + "dev": "NEXT_PUBLIC_API_HOST='https://explorer-api.heima.network' next dev", "build": "next build", "analyze-build": "ANALYZE=true npm run build", "start": "next start", diff --git a/ui-react/src/__tests__/utils/const.test.ts b/ui-react/src/__tests__/utils/const.test.ts new file mode 100644 index 0000000..9473f1a --- /dev/null +++ b/ui-react/src/__tests__/utils/const.test.ts @@ -0,0 +1,8 @@ +import { API_HOST, DEFAULT_API_HOST } from '@/utils/const'; + +describe('API host configuration', () => { + it('defaults to the Heima explorer API', () => { + expect(DEFAULT_API_HOST).toBe('https://explorer-api.heima.network'); + expect(API_HOST).toBe(DEFAULT_API_HOST); + }); +}); diff --git a/ui-react/src/components/contract/verify.tsx b/ui-react/src/components/contract/verify.tsx index 604ba61..01d6cc7 100644 --- a/ui-react/src/components/contract/verify.tsx +++ b/ui-react/src/components/contract/verify.tsx @@ -1,16 +1,7 @@ import React, { useMemo, useState } from 'react' import { BareProps } from '@/types/page' -import { - Button, - RadioGroup, - Radio, - Input, - Select, - SelectItem, - Textarea, - addToast, -} from '@heroui/react' +import { Button, RadioGroup, Radio, Input, Select, SelectItem, Textarea, addToast } from '@heroui/react' import { unwrap, usePVMResolcs, usePVMSolcs } from '@/utils/api' import { getThemeColor, parseFileText } from '@/utils/text' import { FileUpload } from '../file' @@ -99,19 +90,19 @@ const Component: React.FC = ({ children, className, address }) => { if (compilerType === 'json') { if (!files) { addToast({ - title: 'Warning', - description: 'Please upload the Standard-Input-JSON file.', - color: 'warning', - }); + title: 'Warning', + description: 'Please upload the Standard-Input-JSON file.', + color: 'warning', + }) return } } else if (compilerType === 'single') { if (!code) { addToast({ - title: 'Warning', - description: 'Please enter the Solidity contract code.', - color: 'warning', - }); + title: 'Warning', + description: 'Please enter the Solidity contract code.', + color: 'warning', + }) return } } @@ -172,7 +163,7 @@ const Component: React.FC = ({ children, className, address }) => { title: 'Error', description: res.data.result || res.data.message, color: 'secondary', - }); + }) } else { window.location.reload() } @@ -180,19 +171,19 @@ const Component: React.FC = ({ children, className, address }) => { }) .catch((err) => { addToast({ - title: 'Error', - description: err.response?.data?.result || err.message, - color: 'secondary', - }); + title: 'Error', + description: err.response?.data?.result || err.message, + color: 'secondary', + }) setIsLoading(false) }) } - + return ( -
-
+
+
- +
@@ -307,12 +298,7 @@ const Component: React.FC = ({ children, className, address }) => { {compilerType === 'single' && (
Enter the Solidity Contract Code
-