From 8045fb9749b196204c1714e8aa6608341d3095ba Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Thu, 21 May 2026 17:01:22 +0800 Subject: [PATCH 01/17] Add AgentKeys audit envelope decoder --- internal/agentkeys/audit.go | 483 ++++++++++++++++++++++++++++++ internal/agentkeys/audit_test.go | 296 ++++++++++++++++++ internal/agentkeys/cbor.go | 309 +++++++++++++++++++ internal/server/http/agentkeys.go | 33 ++ internal/server/http/http.go | 1 + 5 files changed, 1122 insertions(+) create mode 100644 internal/agentkeys/audit.go create mode 100644 internal/agentkeys/audit_test.go create mode 100644 internal/agentkeys/cbor.go create mode 100644 internal/server/http/agentkeys.go diff --git a/internal/agentkeys/audit.go b/internal/agentkeys/audit.go new file mode 100644 index 0000000..0c45479 --- /dev/null +++ b/internal/agentkeys/audit.go @@ -0,0 +1,483 @@ +package agentkeys + +import ( + "context" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/crypto" +) + +const ( + EnvelopeVersion = 1 + + AuditAppendedV2Signature = "AuditAppendedV2(bytes32,bytes32,uint8,bytes32)" + AuditRootAppendedV2Signature = "AuditRootAppendedV2(bytes32,bytes32,bytes32,uint64)" +) + +var ( + AuditAppendedV2Topic = crypto.Keccak256Hash([]byte(AuditAppendedV2Signature)).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 { + OperatorOmni string `json:"operator_omni"` + ActorOmni string `json:"actor_omni"` + OpKind uint8 `json:"op_kind"` + EnvelopeHash string `json:"envelope_hash"` +} + +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 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 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{ + OperatorOmni: normalizeBytes32Topic(topics[1]), + ActorOmni: normalizeBytes32Topic(topics[2]), + OpKind: opKind, + EnvelopeHash: hash, + }, 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 abiBytes32(data string, wordOffset int) (string, error) { + word, err := abiWord(data, wordOffset) + if err != nil { + return "", err + } + return "0x" + word, 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..2cf0020 --- /dev/null +++ b/internal/agentkeys/audit_test.go @@ -0,0 +1,296 @@ +package agentkeys + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "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"]) + }, + }, + } + + 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 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) + 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) + + 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 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..3dcdcd1 --- /dev/null +++ b/internal/agentkeys/cbor.go @@ -0,0 +1,309 @@ +package agentkeys + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" +) + +type cborKind int + +const ( + cborUint cborKind = iota + cborBytes + cborText + cborBool + cborNull + cborMap +) + +type cborValue struct { + kind cborKind + u uint64 + b []byte + s string + bool bool + 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 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 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..eb1b2d3 --- /dev/null +++ b/internal/server/http/agentkeys.go @@ -0,0 +1,33 @@ +package http + +import ( + "errors" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "github.com/itering/subscan/internal/agentkeys" +) + +const defaultAgentKeysAuditWorkerURL = "https://audit.litentry.org" + +var agentkeysEnvelopeCache = agentkeys.NewEnvelopeCache() + +func agentkeysAuditEnvelopeHandle(c *gin.Context) { + hash := c.Param("hash") + workerURL := os.Getenv("AGENTKEYS_AUDIT_WORKER_URL") + if workerURL == "" { + workerURL = defaultAgentKeysAuditWorkerURL + } + + body, _, err := agentkeysEnvelopeCache.FetchAndDecode(c.Request.Context(), workerURL, 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) +} diff --git a/internal/server/http/http.go b/internal/server/http/http.go index fac5122..f5d4a11 100644 --- a/internal/server/http/http.go +++ b/internal/server/http/http.go @@ -46,6 +46,7 @@ func initRouter(e *gin.Engine) { e.GET("ping", ping) e.GET("healthz", livenessProbe) e.GET("readiness", readinessProbe) + e.GET("agentkeys/audit/envelope/:hash", agentkeysAuditEnvelopeHandle) customValidator.RegisterCustomValidator() // internal g := e.Group("/api") From e6219a86076c8119a04fd064d3cb0a61a34a726f Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Thu, 21 May 2026 17:11:22 +0800 Subject: [PATCH 02/17] Fix PaymentDirect amount_minor schema --- internal/agentkeys/audit.go | 2 +- internal/agentkeys/audit_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/agentkeys/audit.go b/internal/agentkeys/audit.go index 0c45479..0fdd4b8 100644 --- a/internal/agentkeys/audit.go +++ b/internal/agentkeys/audit.go @@ -100,7 +100,7 @@ var opKindSpecs = map[uint8]opKindSpec{ 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"))}, + 31: {"PaymentDirect", "payments", fields(text("rail"), text("ref"), uintf("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"))}, diff --git a/internal/agentkeys/audit_test.go b/internal/agentkeys/audit_test.go index 2cf0020..65de982 100644 --- a/internal/agentkeys/audit_test.go +++ b/internal/agentkeys/audit_test.go @@ -100,6 +100,21 @@ func TestCriticalOpKindRenderers(t *testing.T) { 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": uint64(12345), + "currency": "USD", + }, + assertion: func(t *testing.T, rendered map[string]interface{}) { + assert.Equal(t, uint64(12345), rendered["amount_minor"]) + assert.Equal(t, "USD", rendered["currency"]) + }, + }, } for _, tt := range tests { From 09e0fd8da0d6063a2d959ba680d62677180b1a30 Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Thu, 21 May 2026 18:17:53 +0800 Subject: [PATCH 03/17] Add AgentKeys evidence matrix test --- internal/agentkeys/audit_test.go | 154 +++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/internal/agentkeys/audit_test.go b/internal/agentkeys/audit_test.go index 65de982..eb6b601 100644 --- a/internal/agentkeys/audit_test.go +++ b/internal/agentkeys/audit_test.go @@ -3,6 +3,7 @@ package agentkeys import ( "context" "encoding/base64" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -174,6 +175,159 @@ func TestUnknownOpKindNonBreakFallback(t *testing.T) { } } +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": uint64(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, uint64(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 TestDecodeEnvelopeRejectsVersionAndNonCanonicalMap(t *testing.T) { envelope := map[string]interface{}{ "version": uint8(2), From cfa41b0462b4a4e9816595b8a48876b7ec67b77b Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Thu, 21 May 2026 19:12:54 +0800 Subject: [PATCH 04/17] Add AgentKeys typed audit endpoints --- internal/agentkeys/audit.go | 186 ++++++++++++++- internal/agentkeys/audit_test.go | 145 +++++++++++- internal/agentkeys/cbor.go | 30 +++ internal/server/http/agentkeys.go | 224 +++++++++++++++++- internal/server/http/http.go | 2 + plugins/evm/dao/api.go | 16 ++ .../agentkeys/OPERATOR_INPUT_BLOCKERS.md | 24 ++ 7 files changed, 616 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md diff --git a/internal/agentkeys/audit.go b/internal/agentkeys/audit.go index 0fdd4b8..5f42df2 100644 --- a/internal/agentkeys/audit.go +++ b/internal/agentkeys/audit.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "sort" "strconv" "strings" "sync" @@ -67,6 +68,48 @@ type AuditRootAppendedV2Event struct { 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 + ContractAddress string `json:"contract_address"` + 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"` +} + +type AuditRowsPage struct { + Events []TypedAuditRow `json:"events"` + NextCursor *string `json:"next_cursor"` +} + +type AuditRootRows struct { + 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 @@ -100,7 +143,7 @@ var opKindSpecs = map[uint8]opKindSpec{ 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"), uintf("amount_minor"), text("currency"))}, + 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"))}, @@ -294,6 +337,136 @@ func (c *EnvelopeCache) FetchAndDecode(ctx context.Context, workerBaseURL string return append([]byte(nil), body...), envelope, nil } +func DecodeTypedAuditRow(ctx context.Context, log EVMLogRecord, workerBaseURL string, cache *EnvelopeCache) (*TypedAuditRow, error) { + event, err := DecodeAuditAppendedV2Log(log.Topics, log.Data) + if err != nil { + return nil, err + } + if cache == nil { + cache = NewEnvelopeCache() + } + _, envelope, err := cache.FetchAndDecode(ctx, workerBaseURL, event.EnvelopeHash) + if err != 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) + } + + 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{ + Envelope: *envelope, + ContractAddress: normalizeHexHash(log.Address), + Block: block, + BlockHash: normalizeHexHash(log.BlockHash), + Timestamp: timestamp, + Tx: normalizeHexHash(log.TransactionHash), + TransactionIndex: txIndex, + LogIndex: logIndex, + StreamPosition: fmt.Sprintf("%d:%d", block, logIndex), + }, 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 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{ + 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) } @@ -464,6 +637,17 @@ func normalizeHexHash(hash string) string { 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 { diff --git a/internal/agentkeys/audit_test.go b/internal/agentkeys/audit_test.go index eb6b601..ef0969f 100644 --- a/internal/agentkeys/audit_test.go +++ b/internal/agentkeys/audit_test.go @@ -3,6 +3,7 @@ package agentkeys import ( "context" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -108,11 +109,11 @@ func TestCriticalOpKindRenderers(t *testing.T) { body: map[string]interface{}{ "rail": "stripe", "ref": "invoice-123", - "amount_minor": uint64(12345), + "amount_minor": "12345", "currency": "USD", }, assertion: func(t *testing.T, rendered map[string]interface{}) { - assert.Equal(t, uint64(12345), rendered["amount_minor"]) + assert.Equal(t, "12345", rendered["amount_minor"]) assert.Equal(t, "USD", rendered["currency"]) }, }, @@ -175,6 +176,31 @@ func TestUnknownOpKindNonBreakFallback(t *testing.T) { } } +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"` @@ -256,7 +282,7 @@ func TestAuditEnvelopeEvidenceMatrix(t *testing.T) { body: map[string]interface{}{ "rail": "stripe", "ref": "invoice-123", - "amount_minor": uint64(12345), + "amount_minor": "12345", "currency": "USD", }, assertBody: func(t *testing.T, body interface{}) { @@ -264,7 +290,7 @@ func TestAuditEnvelopeEvidenceMatrix(t *testing.T) { require.True(t, ok) assert.Equal(t, "stripe", rendered["rail"]) assert.Equal(t, "invoice-123", rendered["ref"]) - assert.Equal(t, uint64(12345), rendered["amount_minor"]) + assert.Equal(t, "12345", rendered["amount_minor"]) assert.Equal(t, "USD", rendered["currency"]) }, }, @@ -328,6 +354,61 @@ func TestAuditEnvelopeEvidenceMatrix(t *testing.T) { 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(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) + + 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, 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 TestDecodeEnvelopeRejectsVersionAndNonCanonicalMap(t *testing.T) { envelope := map[string]interface{}{ "version": uint8(2), @@ -446,6 +527,62 @@ func TestEnvelopeCacheFetchesByImmutableHashOnce(t *testing.T) { 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: "0x1111111111111111111111111111111111111111", + 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 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: "0x1111111111111111111111111111111111111111", + 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 { diff --git a/internal/agentkeys/cbor.go b/internal/agentkeys/cbor.go index 3dcdcd1..79f072f 100644 --- a/internal/agentkeys/cbor.go +++ b/internal/agentkeys/cbor.go @@ -15,6 +15,7 @@ const ( cborText cborBool cborNull + cborArray cborMap ) @@ -24,6 +25,7 @@ type cborValue struct { b []byte s string bool bool + array []cborValue pairs []cborPair raw []byte } @@ -89,6 +91,20 @@ func (p *cborParser) parse() (cborValue, error) { 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 { @@ -258,6 +274,20 @@ func appendCanonical(out *[]byte, v interface{}) error { 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: diff --git a/internal/server/http/agentkeys.go b/internal/server/http/agentkeys.go index eb1b2d3..071d384 100644 --- a/internal/server/http/agentkeys.go +++ b/internal/server/http/agentkeys.go @@ -1,26 +1,34 @@ package http import ( + "encoding/base64" + "encoding/json" "errors" + "fmt" "net/http" "os" + "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"` +} func agentkeysAuditEnvelopeHandle(c *gin.Context) { hash := c.Param("hash") - workerURL := os.Getenv("AGENTKEYS_AUDIT_WORKER_URL") - if workerURL == "" { - workerURL = defaultAgentKeysAuditWorkerURL - } - - body, _, err := agentkeysEnvelopeCache.FetchAndDecode(c.Request.Context(), workerURL, 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 @@ -31,3 +39,207 @@ func agentkeysAuditEnvelopeHandle(c *gin.Context) { } 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 + } + + opts, 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" { + opts = append(opts, model.Where("(block_num > ? OR (block_num = ? AND `index` > ?))", cursor.Block, cursor.Block, cursor.LogIndex)) + } else { + opts = append(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 := agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), order, limit+1, opts...) + hasNext := len(logs) > limit + if hasNext { + logs = logs[:limit] + } + rows, err := agentkeys.DecodeTypedAuditRows(c.Request.Context(), toAgentKeysLogs(logs), agentkeysAuditWorkerURL(), agentkeysEnvelopeCache) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + 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{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("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("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 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) ([]model.Option, error) { + opts := []model.Option{ + model.Where("method_hash = ?", agentkeys.AuditAppendedV2Topic), + model.Where("topic1 = ?", normalizeAgentKeysBytes32(operator)), + } + if opKindRaw := c.Query("op_kind"); opKindRaw != "" { + opKind, err := strconv.ParseUint(opKindRaw, 10, 8) + if err != nil { + return nil, fmt.Errorf("op_kind must fit uint8") + } + opts = append(opts, model.Where("topic3 = ?", agentkeys.PaddedOpKindTopic(uint8(opKind)))) + } + 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 nil, 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 nil, fmt.Errorf("to_block must be uint") + } + opts = append(opts, model.Where("block_num <= ?", n)) + } + return opts, nil +} + +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 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 f5d4a11..d8dc075 100644 --- a/internal/server/http/http.go +++ b/internal/server/http/http.go @@ -47,6 +47,8 @@ func initRouter(e *gin.Engine) { 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..75db99f 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 diff --git a/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md b/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md new file mode 100644 index 0000000..3e83fbd --- /dev/null +++ b/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md @@ -0,0 +1,24 @@ +# AgentKeys Operator Input Blockers + +This PR implements decoder, typed-row, and root-row plumbing, but the following issue #12 closing artifacts require operator-supplied inputs that are not present in this repository snapshot. + +## Heima V2 Capture / Bulk Replay + +Blocked on the canonical `CredentialAudit` V2 deployment address and captured Heima Mainnet logs from the operator demo runs. + +Required files once supplied: + +- `tests/fixtures/heima-mainnet-canonical-demos.jsonl` +- `tests/fixtures/mainnet-bulk-replay.jsonl` + +Each row must include the V2 transaction hash, block number, log index, indexed topics, envelope hash, worker fetch status, and decoded typed body from the indexer. + +## Rust Canonical Vectors + +Blocked on reference Rust canonical CBOR vector files. + +Required files once supplied: + +- `tests/fixtures/cross-language-vectors/.json` + +Each vector must include `envelope_json`, `canonical_cbor_hex`, and `envelope_hash_hex` so the Go decoder can verify byte-for-byte deterministic CBOR compatibility. From 79dbf376132c90da65bc4c6e534e4f9f60d9d011 Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Thu, 21 May 2026 20:19:12 +0800 Subject: [PATCH 05/17] Document AgentKeys real-data capture blockers --- .../agentkeys/OPERATOR_INPUT_BLOCKERS.md | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md b/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md index 3e83fbd..60d1e80 100644 --- a/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md +++ b/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md @@ -1,19 +1,60 @@ # AgentKeys Operator Input Blockers -This PR implements decoder, typed-row, and root-row plumbing, but the following issue #12 closing artifacts require operator-supplied inputs that are not present in this repository snapshot. +This PR implements decoder, typed-row, and root-row plumbing, but issue #14 +requires a real Heima Mainnet delivery loop. Issue #12 was scanned for the +`CredentialAudit` V2 deployment inputs and only states that the exact address +will be supplied by the operator alongside the closing PR tx capture. The exact +contract address and deploy block are not present in issue #12, PR #13, or this +repository snapshot. -## Heima V2 Capture / Bulk Replay +Do not replace the missing inputs with mock logs or hand-crafted fixture rows. -Blocked on the canonical `CredentialAudit` V2 deployment address and captured Heima Mainnet logs from the operator demo runs. +## Required Heima Mainnet Contract Inputs -Required files once supplied: +Blocked on all of the following operator-supplied values: + +- Heima Mainnet chain ID: `212013` +- `CredentialAudit` V2 contract address +- V2 deploy block, or the earliest block height that can be used as the + `eth_getLogs` lower bound +- Worker base URL if it differs from `https://audit.litentry.org` +- Operator account / operator omni used for the canonical demo and bulk replay + capture filters + +## Required Canonical Demo Capture + +Blocked on real `AuditAppendedV2` and `AuditRootAppendedV2` logs for the +foundation, hardening, and isolation demo runs. + +The resulting `tests/fixtures/heima-mainnet-canonical-demos.jsonl` rows must be +created from real chain logs and worker responses. Each row must include: + +- `demo` +- `txhash` +- `block_number` +- `log_index` +- `topics` +- `op_kind` +- `envelope_hash` +- `envelope_fetched_from_worker` +- `hash_verified` where `keccak256(canonical_cbor_bytes) == envelope_hash` +- decoded typed body as returned through `/agentkeys/audit/...` + +## Required Bulk Replay Capture + +Blocked on a reproducible mainnet log dump from the canonical contract between +`deploy_block` and the latest block used when the PR evidence is published. + +Required file once supplied: -- `tests/fixtures/heima-mainnet-canonical-demos.jsonl` - `tests/fixtures/mainnet-bulk-replay.jsonl` -Each row must include the V2 transaction hash, block number, log index, indexed topics, envelope hash, worker fetch status, and decoded typed body from the indexer. +Each row must include the V2 transaction hash, block number, log index, indexed +topics, `op_kind`, and `envelope_hash`. The replay must prove every real event +in that range can be worker-fetched, hash-verified, decoded, and exposed as a +typed or `Unknown(byte)` row. -## Rust Canonical Vectors +## Required Rust Canonical Vectors Blocked on reference Rust canonical CBOR vector files. @@ -21,4 +62,6 @@ Required files once supplied: - `tests/fixtures/cross-language-vectors/.json` -Each vector must include `envelope_json`, `canonical_cbor_hex`, and `envelope_hash_hex` so the Go decoder can verify byte-for-byte deterministic CBOR compatibility. +Each vector must include `envelope_json`, `canonical_cbor_hex`, and +`envelope_hash_hex` so the Go decoder can verify byte-for-byte deterministic +CBOR compatibility. From 3f64a0d030f2a16f8c946be254a40f70a481e644 Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Thu, 21 May 2026 04:56:19 +0000 Subject: [PATCH 06/17] Clarify AgentKeys audit contract input --- .../agentkeys/OPERATOR_INPUT_BLOCKERS.md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md b/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md index 60d1e80..1139c87 100644 --- a/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md +++ b/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md @@ -1,20 +1,28 @@ # AgentKeys Operator Input Blockers -This PR implements decoder, typed-row, and root-row plumbing, but issue #14 -requires a real Heima Mainnet delivery loop. Issue #12 was scanned for the -`CredentialAudit` V2 deployment inputs and only states that the exact address -will be supplied by the operator alongside the closing PR tx capture. The exact -contract address and deploy block are not present in issue #12, PR #13, or this -repository snapshot. +This PR implements decoder, typed-row, and root-row plumbing, but issue #12 +requires a real Heima Mainnet delivery loop. Issue #12 itself says the exact +V2 address is supplied with the closing PR capture, while issues #3 and #4 +already list the current AgentKeys stage-1 `CredentialAudit` address: +`0x1801ded1a4FBD8c9224Ab18B9EcbB293B8674c06`. + +That address has non-empty bytecode on Heima Mainnet via `eth_getCode`, so the +remaining blocker is not the address. The remaining blockers are the log lower +bound/deploy block for the V2 audit events and the real canonical event/worker +capture artifacts. Do not replace the missing inputs with mock logs or hand-crafted fixture rows. ## Required Heima Mainnet Contract Inputs -Blocked on all of the following operator-supplied values: +Known contract input: - Heima Mainnet chain ID: `212013` -- `CredentialAudit` V2 contract address +- `CredentialAudit`: `0x1801ded1a4FBD8c9224Ab18B9EcbB293B8674c06` +- Source: litentry/subscan-essentials issues #3 and #4 + +Blocked on the following operator-supplied values: + - V2 deploy block, or the earliest block height that can be used as the `eth_getLogs` lower bound - Worker base URL if it differs from `https://audit.litentry.org` From 716754a0be5e68bbcbee64ebae35ac2d62496ecc Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Thu, 21 May 2026 22:26:59 +0800 Subject: [PATCH 07/17] Scope AgentKeys audit queries to CredentialAudit --- internal/agentkeys/audit.go | 15 +++++++++-- internal/agentkeys/audit_test.go | 8 ++++-- internal/server/http/agentkeys.go | 25 ++++++++++++++++++- .../agentkeys/OPERATOR_INPUT_BLOCKERS.md | 13 +++++----- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/internal/agentkeys/audit.go b/internal/agentkeys/audit.go index 5f42df2..dab3904 100644 --- a/internal/agentkeys/audit.go +++ b/internal/agentkeys/audit.go @@ -21,6 +21,9 @@ import ( const ( EnvelopeVersion = 1 + HeimaChainID = 212013 + CredentialAuditContractAddress = "0x1801ded1a4FBD8c9224Ab18B9EcbB293B8674c06" + AuditAppendedV2Signature = "AuditAppendedV2(bytes32,bytes32,uint8,bytes32)" AuditRootAppendedV2Signature = "AuditRootAppendedV2(bytes32,bytes32,bytes32,uint64)" ) @@ -82,6 +85,7 @@ type EVMLogRecord struct { type TypedAuditRow struct { Envelope + ChainID uint64 `json:"chain_id"` ContractAddress string `json:"contract_address"` Block uint64 `json:"block"` BlockHash string `json:"block_hash"` @@ -93,11 +97,15 @@ type TypedAuditRow struct { } type AuditRowsPage struct { - Events []TypedAuditRow `json:"events"` - NextCursor *string `json:"next_cursor"` + 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"` @@ -378,6 +386,7 @@ func DecodeTypedAuditRow(ctx context.Context, log EVMLogRecord, workerBaseURL st return &TypedAuditRow{ Envelope: *envelope, + ChainID: HeimaChainID, ContractAddress: normalizeHexHash(log.Address), Block: block, BlockHash: normalizeHexHash(log.BlockHash), @@ -431,6 +440,8 @@ func DecodeAuditRootRows(ctx context.Context, rootLog EVMLogRecord, leafLogs []E } return &AuditRootRows{ + ChainID: HeimaChainID, + ContractAddress: normalizeHexHash(rootLog.Address), MerkleRoot: event.MerkleRoot, OperatorOmni: event.OperatorOmni, OpKindBitmapU256: event.OpKindBitmapU256, diff --git a/internal/agentkeys/audit_test.go b/internal/agentkeys/audit_test.go index ef0969f..41aa4d8 100644 --- a/internal/agentkeys/audit_test.go +++ b/internal/agentkeys/audit_test.go @@ -393,6 +393,8 @@ func TestDecodeTypedAuditRowsAndRootLeaves(t *testing.T) { 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) @@ -401,6 +403,8 @@ func TestDecodeTypedAuditRowsAndRootLeaves(t *testing.T) { 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) @@ -546,7 +550,7 @@ func canonicalFixtureEnvelope(t *testing.T, opKind uint8, operator, actor string func auditAppendedLog(operator, actor string, opKind uint8, envelopeHash string, block uint64, logIndex uint64) EVMLogRecord { return EVMLogRecord{ - Address: "0x1111111111111111111111111111111111111111", + Address: CredentialAuditContractAddress, Topics: []string{AuditAppendedV2Topic, operator, actor, PaddedOpKindTopic(opKind)}, Data: envelopeHash, BlockNumber: fmt.Sprintf("0x%x", block), @@ -564,7 +568,7 @@ func auditRootLog(operator, merkleRoot string, opKinds []uint8, entryCount uint6 bitmap[31-int(opKind)/8] |= byte(1 << uint(opKind%8)) } return EVMLogRecord{ - Address: "0x1111111111111111111111111111111111111111", + Address: CredentialAuditContractAddress, Topics: []string{AuditRootAppendedV2Topic, operator, merkleRoot}, Data: "0x" + hexOf(bitmap) + fmt.Sprintf("%064x", entryCount), BlockNumber: fmt.Sprintf("0x%x", block), diff --git a/internal/server/http/agentkeys.go b/internal/server/http/agentkeys.go index 071d384..bae2483 100644 --- a/internal/server/http/agentkeys.go +++ b/internal/server/http/agentkeys.go @@ -89,12 +89,18 @@ func agentkeysAuditRowsHandle(c *gin.Context) { cursor := encodeAgentKeysCursor(agentkeysAuditCursor{Block: rows[len(rows)-1].Block, LogIndex: rows[len(rows)-1].LogIndex}) nextCursor = &cursor } - c.JSON(http.StatusOK, agentkeys.AuditRowsPage{Events: rows, NextCursor: nextCursor}) + 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), ) @@ -128,6 +134,7 @@ func agentkeysAuditRootHandle(c *gin.Context) { 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), @@ -151,6 +158,14 @@ func agentkeysAuditWorkerURL() string { 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 @@ -167,6 +182,7 @@ func agentkeysAuditLimit(raw string) (int, error) { func agentkeysAuditLogFilters(c *gin.Context, operator string) ([]model.Option, error) { opts := []model.Option{ + model.Where("address = ?", agentkeysAuditContractAddress()), model.Where("method_hash = ?", agentkeys.AuditAppendedV2Topic), model.Where("topic1 = ?", normalizeAgentKeysBytes32(operator)), } @@ -219,6 +235,13 @@ 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) diff --git a/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md b/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md index 1139c87..bc2bdf3 100644 --- a/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md +++ b/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md @@ -1,15 +1,14 @@ # AgentKeys Operator Input Blockers This PR implements decoder, typed-row, and root-row plumbing, but issue #12 -requires a real Heima Mainnet delivery loop. Issue #12 itself says the exact -V2 address is supplied with the closing PR capture, while issues #3 and #4 -already list the current AgentKeys stage-1 `CredentialAudit` address: +requires a real Heima Mainnet delivery loop. Issues #3 and #4 confirm the +current main audit `CredentialAudit` address: `0x1801ded1a4FBD8c9224Ab18B9EcbB293B8674c06`. -That address has non-empty bytecode on Heima Mainnet via `eth_getCode`, so the -remaining blocker is not the address. The remaining blockers are the log lower -bound/deploy block for the V2 audit events and the real canonical event/worker -capture artifacts. +That address has non-empty bytecode on Heima Mainnet via `eth_getCode`, and +PR #13 filters AgentKeys audit queries to that contract. The remaining blocker +is not the address. The remaining blockers are the log lower bound/deploy block +for the V2 audit events and the real canonical event/worker capture artifacts. Do not replace the missing inputs with mock logs or hand-crafted fixture rows. From de3553bd5bfb2dc763c3357670c64533e58c837a Mon Sep 17 00:00:00 2001 From: CrossAgent Date: Fri, 22 May 2026 01:13:26 +0800 Subject: [PATCH 08/17] Support live AgentKeys audit logs --- internal/agentkeys/audit.go | 191 +++++++++++++++--- internal/agentkeys/audit_test.go | 96 +++++++++ internal/server/http/agentkeys.go | 83 ++++++-- .../fixtures/agentkeys/LIVE_HEIMA_CAPTURE.md | 35 ++++ .../agentkeys/OPERATOR_INPUT_BLOCKERS.md | 74 ------- .../heima-mainnet-current-auditappended.jsonl | 13 ++ 6 files changed, 371 insertions(+), 121 deletions(-) create mode 100644 tests/fixtures/agentkeys/LIVE_HEIMA_CAPTURE.md delete mode 100644 tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md create mode 100644 tests/fixtures/agentkeys/heima-mainnet-current-auditappended.jsonl diff --git a/internal/agentkeys/audit.go b/internal/agentkeys/audit.go index dab3904..794a006 100644 --- a/internal/agentkeys/audit.go +++ b/internal/agentkeys/audit.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math/big" "net/http" "net/url" "sort" @@ -22,16 +23,18 @@ const ( EnvelopeVersion = 1 HeimaChainID = 212013 - CredentialAuditContractAddress = "0x1801ded1a4FBD8c9224Ab18B9EcbB293B8674c06" + CredentialAuditContractAddress = "0x63c4545ac01c77cc74044f25b8edea3880224577" - AuditAppendedV2Signature = "AuditAppendedV2(bytes32,bytes32,uint8,bytes32)" - AuditRootAppendedV2Signature = "AuditRootAppendedV2(bytes32,bytes32,bytes32,uint64)" + 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() - AuditRootAppendedV2Topic = crypto.Keccak256Hash([]byte(AuditRootAppendedV2Signature)).Hex() - ErrEnvelopeNotFound = errors.New("agentkeys audit envelope not found") + 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 { @@ -58,10 +61,14 @@ type UnknownOpBody struct { } type AuditAppendedV2Event struct { - OperatorOmni string `json:"operator_omni"` - ActorOmni string `json:"actor_omni"` - OpKind uint8 `json:"op_kind"` - EnvelopeHash string `json:"envelope_hash"` + 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 { @@ -85,15 +92,21 @@ type EVMLogRecord struct { type TypedAuditRow struct { Envelope - ChainID uint64 `json:"chain_id"` - ContractAddress string `json:"contract_address"` - 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"` + 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 { @@ -346,7 +359,19 @@ func (c *EnvelopeCache) FetchAndDecode(ctx context.Context, workerBaseURL string } func DecodeTypedAuditRow(ctx context.Context, log EVMLogRecord, workerBaseURL string, cache *EnvelopeCache) (*TypedAuditRow, error) { - event, err := DecodeAuditAppendedV2Log(log.Topics, log.Data) + 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 } @@ -355,6 +380,19 @@ func DecodeTypedAuditRow(ctx context.Context, log EVMLogRecord, workerBaseURL st } _, 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) { @@ -367,6 +405,12 @@ func DecodeTypedAuditRow(ctx context.Context, log EVMLogRecord, workerBaseURL st 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) @@ -385,16 +429,19 @@ func DecodeTypedAuditRow(ctx context.Context, log EVMLogRecord, workerBaseURL st } return &TypedAuditRow{ - Envelope: *envelope, - ChainID: HeimaChainID, - ContractAddress: normalizeHexHash(log.Address), - Block: block, - BlockHash: normalizeHexHash(log.BlockHash), - Timestamp: timestamp, - Tx: normalizeHexHash(log.TransactionHash), - TransactionIndex: txIndex, - LogIndex: logIndex, - StreamPosition: fmt.Sprintf("%d:%d", block, logIndex), + 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 } @@ -410,6 +457,18 @@ func DecodeTypedAuditRows(ctx context.Context, logs []EVMLogRecord, workerBaseUR 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 { @@ -498,6 +557,8 @@ func DecodeAuditAppendedV2Log(topics []string, data string) (*AuditAppendedV2Eve return nil, fmt.Errorf("envelope_hash: %w", err) } return &AuditAppendedV2Event{ + EventName: "AuditAppendedV2", + EventTopic: AuditAppendedV2Topic, OperatorOmni: normalizeBytes32Topic(topics[1]), ActorOmni: normalizeBytes32Topic(topics[2]), OpKind: opKind, @@ -505,6 +566,51 @@ func DecodeAuditAppendedV2Log(topics []string, data string) (*AuditAppendedV2Eve }, 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") @@ -667,6 +773,31 @@ func abiBytes32(data string, wordOffset int) (string, error) { 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 diff --git a/internal/agentkeys/audit_test.go b/internal/agentkeys/audit_test.go index 41aa4d8..dfc1fd6 100644 --- a/internal/agentkeys/audit_test.go +++ b/internal/agentkeys/audit_test.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -400,6 +401,15 @@ func TestDecodeTypedAuditRowsAndRootLeaves(t *testing.T) { 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) @@ -413,6 +423,65 @@ func TestDecodeTypedAuditRowsAndRootLeaves(t *testing.T) { 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), @@ -451,6 +520,7 @@ func TestKnownOpKindTableMatchesCanonicalIssueTable(t *testing.T) { 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" @@ -460,6 +530,18 @@ func TestDecodeAuditEventLogs(t *testing.T) { 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) @@ -562,6 +644,20 @@ func auditAppendedLog(operator, actor string, opKind uint8, envelopeHash string, } } +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 { diff --git a/internal/server/http/agentkeys.go b/internal/server/http/agentkeys.go index bae2483..511afba 100644 --- a/internal/server/http/agentkeys.go +++ b/internal/server/http/agentkeys.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "sort" "strconv" "strings" @@ -26,6 +27,11 @@ type agentkeysAuditCursor struct { 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) @@ -52,7 +58,7 @@ func agentkeysAuditRowsHandle(c *gin.Context) { return } - opts, err := agentkeysAuditLogFilters(c, c.Param("operator_omni")) + query, err := agentkeysAuditLogFilters(c, c.Param("operator_omni")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -64,9 +70,9 @@ func agentkeysAuditRowsHandle(c *gin.Context) { return } if sortDir == "asc" { - opts = append(opts, model.Where("(block_num > ? OR (block_num = ? AND `index` > ?))", cursor.Block, cursor.Block, cursor.LogIndex)) + query.opts = append(query.opts, model.Where("(block_num > ? OR (block_num = ? AND `index` > ?))", cursor.Block, cursor.Block, cursor.LogIndex)) } else { - opts = append(opts, model.Where("(block_num < ? OR (block_num = ? AND `index` < ?))", cursor.Block, cursor.Block, cursor.LogIndex)) + query.opts = append(query.opts, model.Where("(block_num < ? OR (block_num = ? AND `index` < ?))", cursor.Block, cursor.Block, cursor.LogIndex)) } } @@ -74,16 +80,37 @@ func agentkeysAuditRowsHandle(c *gin.Context) { if sortDir == "asc" { order = "block_num asc, `index` asc" } - logs := agentkeysEvmAPI.API_GetLogsForAgentKeys(c.Request.Context(), order, limit+1, opts...) - hasNext := len(logs) > limit - if hasNext { - logs = logs[:limit] - } - rows, err := agentkeys.DecodeTypedAuditRows(c.Request.Context(), toAgentKeysLogs(logs), agentkeysAuditWorkerURL(), agentkeysEnvelopeCache) + 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}) @@ -180,18 +207,19 @@ func agentkeysAuditLimit(raw string) (int, error) { return limit, nil } -func agentkeysAuditLogFilters(c *gin.Context, operator string) ([]model.Option, error) { +func agentkeysAuditLogFilters(c *gin.Context, operator string) (agentkeysAuditLogQuery, error) { opts := []model.Option{ model.Where("address = ?", agentkeysAuditContractAddress()), - model.Where("method_hash = ?", agentkeys.AuditAppendedV2Topic), model.Where("topic1 = ?", normalizeAgentKeysBytes32(operator)), } + var opKind *uint8 if opKindRaw := c.Query("op_kind"); opKindRaw != "" { - opKind, err := strconv.ParseUint(opKindRaw, 10, 8) + n, err := strconv.ParseUint(opKindRaw, 10, 8) if err != nil { - return nil, fmt.Errorf("op_kind must fit uint8") + return agentkeysAuditLogQuery{}, fmt.Errorf("op_kind must fit uint8") } - opts = append(opts, model.Where("topic3 = ?", agentkeys.PaddedOpKindTopic(uint8(opKind)))) + value := uint8(n) + opKind = &value } if actor := c.Query("actor_omni"); actor != "" { opts = append(opts, model.Where("topic2 = ?", normalizeAgentKeysBytes32(actor))) @@ -199,18 +227,39 @@ func agentkeysAuditLogFilters(c *gin.Context, operator string) ([]model.Option, if from := c.Query("from_block"); from != "" { n, err := strconv.ParseUint(from, 10, 64) if err != nil { - return nil, fmt.Errorf("from_block must be uint") + 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 nil, fmt.Errorf("to_block must be uint") + return agentkeysAuditLogQuery{}, fmt.Errorf("to_block must be uint") } opts = append(opts, model.Where("block_num <= ?", n)) } - return opts, nil + 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 { 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/OPERATOR_INPUT_BLOCKERS.md b/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md deleted file mode 100644 index bc2bdf3..0000000 --- a/tests/fixtures/agentkeys/OPERATOR_INPUT_BLOCKERS.md +++ /dev/null @@ -1,74 +0,0 @@ -# AgentKeys Operator Input Blockers - -This PR implements decoder, typed-row, and root-row plumbing, but issue #12 -requires a real Heima Mainnet delivery loop. Issues #3 and #4 confirm the -current main audit `CredentialAudit` address: -`0x1801ded1a4FBD8c9224Ab18B9EcbB293B8674c06`. - -That address has non-empty bytecode on Heima Mainnet via `eth_getCode`, and -PR #13 filters AgentKeys audit queries to that contract. The remaining blocker -is not the address. The remaining blockers are the log lower bound/deploy block -for the V2 audit events and the real canonical event/worker capture artifacts. - -Do not replace the missing inputs with mock logs or hand-crafted fixture rows. - -## Required Heima Mainnet Contract Inputs - -Known contract input: - -- Heima Mainnet chain ID: `212013` -- `CredentialAudit`: `0x1801ded1a4FBD8c9224Ab18B9EcbB293B8674c06` -- Source: litentry/subscan-essentials issues #3 and #4 - -Blocked on the following operator-supplied values: - -- V2 deploy block, or the earliest block height that can be used as the - `eth_getLogs` lower bound -- Worker base URL if it differs from `https://audit.litentry.org` -- Operator account / operator omni used for the canonical demo and bulk replay - capture filters - -## Required Canonical Demo Capture - -Blocked on real `AuditAppendedV2` and `AuditRootAppendedV2` logs for the -foundation, hardening, and isolation demo runs. - -The resulting `tests/fixtures/heima-mainnet-canonical-demos.jsonl` rows must be -created from real chain logs and worker responses. Each row must include: - -- `demo` -- `txhash` -- `block_number` -- `log_index` -- `topics` -- `op_kind` -- `envelope_hash` -- `envelope_fetched_from_worker` -- `hash_verified` where `keccak256(canonical_cbor_bytes) == envelope_hash` -- decoded typed body as returned through `/agentkeys/audit/...` - -## Required Bulk Replay Capture - -Blocked on a reproducible mainnet log dump from the canonical contract between -`deploy_block` and the latest block used when the PR evidence is published. - -Required file once supplied: - -- `tests/fixtures/mainnet-bulk-replay.jsonl` - -Each row must include the V2 transaction hash, block number, log index, indexed -topics, `op_kind`, and `envelope_hash`. The replay must prove every real event -in that range can be worker-fetched, hash-verified, decoded, and exposed as a -typed or `Unknown(byte)` row. - -## Required Rust Canonical Vectors - -Blocked on reference Rust canonical CBOR vector files. - -Required files once supplied: - -- `tests/fixtures/cross-language-vectors/.json` - -Each vector must include `envelope_json`, `canonical_cbor_hex`, and -`envelope_hash_hex` so the Go decoder can verify byte-for-byte deterministic -CBOR compatibility. 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} From 3e8c399e3f87d7ae7839079badea85f54002de22 Mon Sep 17 00:00:00 2001 From: "crossagent-production-app[bot]" <283591059+crossagent-production-app[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 13:31:09 +0800 Subject: [PATCH 09/17] Fix EVM account cursor filters (#18) Co-authored-by: CrossAgent --- go.mod | 1 + plugins/evm/dao/api.go | 24 +++++----- plugins/evm/dao/api_cursor_test.go | 71 ++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 plugins/evm/dao/api_cursor_test.go 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/plugins/evm/dao/api.go b/plugins/evm/dao/api.go index 75db99f..659cae0 100644 --- a/plugins/evm/dao/api.go +++ b/plugins/evm/dao/api.go @@ -493,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") } @@ -598,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 @@ -625,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") @@ -678,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") @@ -731,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") @@ -798,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"]) +} From a993a62f0a2fe3ad98386c2feae063467839bd91 Mon Sep 17 00:00:00 2001 From: Xin Date: Fri, 22 May 2026 13:49:25 +0800 Subject: [PATCH 10/17] Add test explorer API deployment workflow --- .../workflows/deploy-test-explorer-api.yml | 47 +++++++ scripts/deploy-test-explorer-api.sh | 122 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 .github/workflows/deploy-test-explorer-api.yml create mode 100755 scripts/deploy-test-explorer-api.sh 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/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 "$@" From 56cdfad9a761180851fbdd54baff06ec341f2708 Mon Sep 17 00:00:00 2001 From: Xin Date: Fri, 22 May 2026 15:00:09 +0800 Subject: [PATCH 11/17] Backfill EVM contracts from runtime code --- plugins/evm/dao/contract.go | 42 +++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) 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) From 15f3238bdc9f254b242d4c30c6afce05a397907a Mon Sep 17 00:00:00 2001 From: Xin Date: Fri, 22 May 2026 15:58:24 +0800 Subject: [PATCH 12/17] Trigger Vercel preview for monorepo From 5bc035da9e0f6c378eb491406802c25df4a0aaee Mon Sep 17 00:00:00 2001 From: Xin Date: Fri, 22 May 2026 16:12:19 +0800 Subject: [PATCH 13/17] Replace frontend deploy actions with backend CI --- .github/workflows/backend-ci.yml | 70 ++++++++++++++++++++ .github/workflows/ui-react-deploy-docker.yml | 39 ----------- .github/workflows/ui-react-deploy-stg.yml | 30 --------- 3 files changed, 70 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/backend-ci.yml delete mode 100644 .github/workflows/ui-react-deploy-docker.yml delete mode 100644 .github/workflows/ui-react-deploy-stg.yml diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 0000000..17aeb03 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,70 @@ +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: 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/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 }} From f64d7c2b537bc7228fccf730bd76e8308a4d5b30 Mon Sep 17 00:00:00 2001 From: Xin Date: Fri, 22 May 2026 16:18:20 +0800 Subject: [PATCH 14/17] Remove legacy backend Docker publish action --- .github/workflows/backend-ci.yml | 7 ++++++ .github/workflows/docker.yaml | 43 -------------------------------- 2 files changed, 7 insertions(+), 43 deletions(-) delete mode 100644 .github/workflows/docker.yaml diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 17aeb03..068de0b 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -60,6 +60,13 @@ jobs: - 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: | 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 From ab60480a4e50edbcf17c879dd0535c77b912f5e4 Mon Sep 17 00:00:00 2001 From: "crossagent-production-app[bot]" <283591059+crossagent-production-app[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 17:12:45 +0800 Subject: [PATCH 15/17] Improve contract verification dark contrast (#23) Co-authored-by: CrossAgent --- ui-react/src/components/contract/verify.tsx | 60 +++----- ui-react/src/styles/globals.css | 160 +++++++++++++++----- 2 files changed, 145 insertions(+), 75 deletions(-) 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
-