Skip to content

Commit 6a234ff

Browse files
authored
Add opt-in identity topic manager and lookup service (#368)
1 parent f5d8074 commit 6a234ff

22 files changed

Lines changed: 4067 additions & 16 deletions

api/openapi/paths/non_admin/responses.yaml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,18 @@ components:
8888
type: object
8989
properties:
9090
beef:
91-
type: string
92-
format: byte
91+
type: array
92+
description: >-
93+
BEEF bytes encoded as the BRC-24 JSON byte array. Earlier Go
94+
server releases emitted a base64 string here through Go's []byte
95+
JSON encoding; that legacy representation is not BRC-24 JSON
96+
interoperable.
97+
x-go-type: '[]int32'
98+
items:
99+
type: integer
100+
format: int32
101+
minimum: 0
102+
maximum: 255
93103
outputIndex:
94104
type: integer
95105
format: uint32

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414
github.com/spf13/viper v1.21.0
1515
github.com/stretchr/testify v1.12.1
1616
golang.org/x/sync v0.23.0
17+
golang.org/x/text v0.41.0
1718
gopkg.in/yaml.v3 v3.0.1
1819
)
1920

@@ -54,7 +55,6 @@ require (
5455
golang.org/x/mod v0.38.0 // indirect
5556
golang.org/x/net v0.58.0 // indirect
5657
golang.org/x/sys v0.47.0 // indirect
57-
golang.org/x/text v0.41.0 // indirect
5858
golang.org/x/tools v0.48.0 // indirect
5959
)
6060

pkg/core/engine/tests/engine_lookup_test.go

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"testing"
77

8-
"github.com/bsv-blockchain/go-sdk/chainhash"
98
"github.com/bsv-blockchain/go-sdk/overlay/lookup"
109
"github.com/bsv-blockchain/go-sdk/transaction"
1110
"github.com/stretchr/testify/require"
@@ -111,13 +110,9 @@ func TestEngine_Lookup_ShouldReturnDirectResult_WhenAnswerTypeIsOutputList(t *te
111110
func TestEngine_Lookup_ShouldHydrateOutputs_WhenFormulasProvided(t *testing.T) {
112111
// given
113112
ctx := context.Background()
114-
outpoint := &transaction.Outpoint{Txid: fakeTxID(t), Index: 0}
115-
116-
// Create a proper BEEF object for testing
117-
expectedBeef := &transaction.Beef{
118-
Version: transaction.BEEF_V2,
119-
Transactions: make(map[chainhash.Hash]*transaction.BeefTx),
120-
}
113+
beef, subjectTx, _, err := transaction.ParseBeef(createDummyBEEF(t))
114+
require.NoError(t, err)
115+
outpoint := &transaction.Outpoint{Txid: *subjectTx.TxID(), Index: 0}
121116

122117
sut := engine.NewEngine(&engine.Config{
123118
LookupServices: map[string]engine.LookupService{
@@ -126,7 +121,7 @@ func TestEngine_Lookup_ShouldHydrateOutputs_WhenFormulasProvided(t *testing.T) {
126121
return &lookup.LookupAnswer{
127122
Type: lookup.AnswerTypeFormula,
128123
Formulas: []lookup.LookupFormula{
129-
{Outpoint: &transaction.Outpoint{Txid: fakeTxID(t), Index: 0}},
124+
{Outpoint: outpoint},
130125
},
131126
}, nil
132127
},
@@ -136,7 +131,7 @@ func TestEngine_Lookup_ShouldHydrateOutputs_WhenFormulasProvided(t *testing.T) {
136131
findOutputFunc: func(_ context.Context, outpoint *transaction.Outpoint, _ *string, _ *bool, _ bool) (*engine.Output, error) {
137132
return &engine.Output{
138133
Outpoint: *outpoint,
139-
Beef: expectedBeef,
134+
Beef: beef,
140135
}, nil
141136
},
142137
},
@@ -150,4 +145,7 @@ func TestEngine_Lookup_ShouldHydrateOutputs_WhenFormulasProvided(t *testing.T) {
150145
require.Equal(t, lookup.AnswerTypeOutputList, actualAnswer.Type)
151146
require.Len(t, actualAnswer.Outputs, 1)
152147
require.Equal(t, outpoint.Index, actualAnswer.Outputs[0].OutputIndex)
148+
_, hydratedTx, _, err := transaction.ParseBeef(actualAnswer.Outputs[0].Beef)
149+
require.NoError(t, err)
150+
require.Equal(t, subjectTx.Bytes(), hydratedTx.Bytes())
153151
}

pkg/server/internal/ports/lookup_question_handler.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func NewLookupQuestionSuccessResponse(dto *app.LookupAnswerDTO) (*openapi.Lookup
7171
outputs = make([]openapi.OutputListItem, len(dto.Outputs))
7272
for i, output := range dto.Outputs {
7373
outputs[i] = openapi.OutputListItem{
74-
Beef: output.BEEF,
74+
Beef: brc24Bytes(output.BEEF),
7575
OutputIndex: output.OutputIndex,
7676
}
7777
}
@@ -83,3 +83,17 @@ func NewLookupQuestionSuccessResponse(dto *app.LookupAnswerDTO) (*openapi.Lookup
8383
Type: dto.Type,
8484
}, nil
8585
}
86+
87+
// brc24Bytes converts Go's base64-marshaled []byte form into BRC-24's
88+
// portable JSON byte array. This conversion is confined to the JSON lookup
89+
// response; aggregated binary lookup responses are unchanged.
90+
func brc24Bytes(bytes []byte) []int32 {
91+
if bytes == nil {
92+
return nil
93+
}
94+
result := make([]int32, len(bytes))
95+
for i, value := range bytes {
96+
result[i] = int32(value)
97+
}
98+
return result
99+
}

pkg/server/internal/ports/lookup_question_handler_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ports_test
22

33
import (
4+
"encoding/json"
45
"testing"
56

67
"github.com/bsv-blockchain/go-sdk/overlay/lookup"
@@ -102,3 +103,39 @@ func TestLookupQuestionHandler_ValidCase(t *testing.T) {
102103

103104
stub.AssertProvidersState()
104105
}
106+
107+
func TestLookupQuestionHandlerOutputListUsesBRC24ByteArray(t *testing.T) {
108+
stub := testabilities.NewTestOverlayEngineStub(t, testabilities.WithLookupQuestionProvider(testabilities.NewLookupQuestionProviderMock(t, testabilities.LookupQuestionProviderMockExpectations{
109+
LookupQuestionCall: true,
110+
Answer: &lookup.LookupAnswer{
111+
Type: lookup.AnswerTypeOutputList,
112+
Outputs: []*lookup.OutputListItem{{
113+
Beef: []byte{1, 2, 255},
114+
OutputIndex: 7,
115+
}},
116+
},
117+
})))
118+
fixture := server.NewTestFixture(t, server.WithEngine(stub))
119+
120+
res, err := fixture.Client().
121+
R().
122+
SetHeader("Content-Type", "application/json").
123+
SetBody(openapi.LookupQuestionJSONRequestBody{
124+
Query: map[string]any{},
125+
Service: "test-service",
126+
}).
127+
Post("/api/v1/lookup")
128+
129+
require.NoError(t, err)
130+
require.Equal(t, fiber.StatusOK, res.StatusCode())
131+
var body struct {
132+
Outputs []struct {
133+
Beef json.RawMessage `json:"beef"`
134+
} `json:"outputs"`
135+
}
136+
require.NoError(t, json.Unmarshal(res.Body(), &body))
137+
require.Len(t, body.Outputs, 1)
138+
require.JSONEq(t, `[1,2,255]`, string(body.Outputs[0].Beef))
139+
140+
stub.AssertProvidersState()
141+
}

pkg/server/internal/ports/openapi/openapi_non_admin_response_types.gen.go

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/topics/identity/README.md

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Identity companion service
2+
3+
This opt-in package supplies `engine.TopicManager` for `tm_identity` and
4+
`engine.LookupService` for `ls_identity`. It does not register either service,
5+
start HTTP, or supply a database adapter. The generic engine must independently
6+
verify transactions, scripts and chain proofs before topic admission.
7+
8+
`NewTopicManager` evaluates outputs independently. It verifies the exact
9+
concatenation of PushDrop fields using the subject's `[1, "identity"]`, key
10+
ID `"1"` signature, then verifies the certificate and decrypts public fields.
11+
The certificate subject need not equal the derived script locking key.
12+
Retention is always empty. Admission alone does not establish SPV validity,
13+
unspentness, certificate revocation status, freshness or trusted-certifier policy.
14+
15+
`NewLookupService(projection)` indexes admitted outputs and removes them on
16+
spend or legal eviction. History-retention and block-height notifications do
17+
not change the current-output index. Lookup results are actual outpoint
18+
formulas for engine BEEF hydration. `ProjectOutput` exposes the same validated
19+
public record derivation without a storage write for future atomic admission
20+
and projection-intent integration.
21+
22+
## Projection contract
23+
24+
Implement `Projection` with a durable adapter and explicitly inject the topic
25+
and lookup service into the engine's configuration. `Upsert` must use the
26+
outpoint as a unique key, and repeated `Delete` calls must succeed. Public
27+
decrypted fields remain in `Record.Certificate.Fields`; searchable text excludes
28+
`profilePhoto` and `icon`. Its concatenation preserves TS keyring property
29+
enumeration, including numeric field names.
30+
31+
`Find` must honor `Query.Kind`, including an empty-string identity key, all
32+
selected exact filters, attribute predicates, offset and limit. Attribute
33+
predicates combine with AND. Empty optional certifier filters are unrestricted;
34+
an empty certifier-only query is empty. No new ordering guarantee is implied.
35+
`Field == ""` selects `SearchableAttributes`; otherwise it names a literal
36+
certificate field. Adapters must use server-owned operators and must not splice
37+
query values into database operators or regular expressions.
38+
39+
Preserved TS query semantics:
40+
41+
- Precedence: serial number; attributes; identity key with certificate types;
42+
identity key; certifiers. Serial number ignores lower-priority filters.
43+
- Attribute whitespace follows ECMAScript trimming/collapsing. `userName` is an
44+
exact, case-sensitive normalized match; other attributes use escaped tokens
45+
joined by `.*` with case-insensitive matching.
46+
- `attributes.any` takes precedence within attributes. After normalization,
47+
fewer than two UTF-16 code units yields empty; two uses a fuzzy text regex;
48+
more than two requires the native Mongo text-search semantics/index used by
49+
the TS service. It must not be approximated by substring or fuzzy matching.
50+
- Empty/blank attribute searches are empty. Native Mongo text language,
51+
stemming, stop words, phrases and index behavior need real adapter tests.
52+
53+
The adapter and host own admission/projection atomicity, ordered replay,
54+
tombstones or equivalent fencing, outbox retries, read-your-write boundaries,
55+
rebuilds, migration and readiness. An old admission replay must not resurrect
56+
an output after spend/eviction. Callback idempotency alone cannot guarantee
57+
this. The legacy engine callbacks are not an atomic transaction merely because
58+
this interface exists. Use the separately reviewed engine persistence/outbox
59+
capability when integrating; this package adds no competing operation ledger.
60+
61+
## Budgets and narrow compatibility limits
62+
63+
`DefaultAdmissionPolicy()` limits one output script to 1 MiB, certificate and
64+
PushDrop fields to 128, selected outputs to 10,000, total selected transaction
65+
locking-script bytes to 32 MiB, and engine notification BEEF to 64 MiB.
66+
`NewTopicManagerWithPolicy` and `NewLookupServiceWithPolicies` accept explicit
67+
positive policy values. These are application budgets; the host separately
68+
needs bounded transport and BEEF/transaction/proof parsing. Notification BEEF
69+
is an already admitted engine payload, not an independent network ingress API.
70+
71+
`DefaultQueryPolicy()` permits at most 10,000 results and offset 100,000.
72+
The policy may lower those operational ceilings. Explicit positive limits
73+
within the cap retain limit/offset behavior. Omitted or zero legacy limits
74+
fetch `cap + 1`: results within the cap succeed; overflow returns `ErrQueryBudget`
75+
and requires explicit bounded pagination. No truncated success or new wire
76+
metadata is invented. Large offsets remain workload-dependent scans, not a
77+
promise of cheap access.
78+
79+
Queries are capped at 64 KiB, 4 KiB per string, 64 attributes and 128 certifiers
80+
or certificate types. Pagination accepts finite integral JSON numbers and
81+
rejects negative, fractional, quoted or over-budget values before projection
82+
work. Attribute selectors must be nonempty and exclude `.`, `$` and NUL to
83+
prevent unsafe Mongo path construction; certificate data is not normalized or
84+
renamed. Those selectors and resource ceilings are explicit restrictions on
85+
the otherwise permissive TS input shape.
86+
87+
## Certificate serialization checkpoint
88+
89+
This component deliberately uses the existing Go SDK v1.4.1 certificate
90+
verification profile. Its serializer orders UTF-8 field names with Go byte
91+
ordering. Pinned TS `2bc799a8d8e535242e6de2d305f426ce3975ea7b` reconstructs
92+
the certificate preimage with `localeCompare`; even mixed ASCII case can
93+
differ. Collation ties preserve JSON insertion order. The PushDrop envelope
94+
carries JSON and the subject signature, not a separate signed binary preimage.
95+
96+
The common-compatible real TS fixtures pass. Other legitimate TS certificates
97+
may not verify under this profile. `ErrCertificateVerification` describes a
98+
failure to verify under the Go profile, not a diagnosis of forgery. There is
99+
no alternate-order retry, arbitrary permutation search, ambient locale
100+
dependency or normalization of signed data. Full TS certificate interoperability
101+
remains open pending explicit deterministic profile/migration review. The
102+
[portable fixture provenance](testdata/data/provenance.md) records mixed ASCII,
103+
accent/combining ties, astral/BMP and searchable-field cases with exact bytes.
104+
105+
## Verification and integration gates
106+
107+
Package tests consume actual TS signed certificate/PushDrop/transaction/BEEF
108+
bytes. They exercise independently admitted/rejected outputs, exact signature
109+
and decryption checks, replay/upsert and deletion seams, query behavior and
110+
budgets, and malformed scripts. In-memory projections exist only in tests;
111+
they do not claim Mongo text-search or persistent-server acceptance.
112+
113+
Run package tests with `GOTOOLCHAIN=go1.26.8 go test ./pkg/topics/identity`.
114+
The fixture generator requires the pinned TS sources and built artifacts;
115+
committed fixture bytes make ordinary Go checks independent of Node or a
116+
database. Regeneration uses SDK randomness, producing new signed bytes with
117+
the same tested behavior, and records their SHA-256 digests.
118+
119+
Remaining acceptance includes the durable Mongo adapter/indexes, atomic engine
120+
admission/projection/outbox integration, actual engine+HTTP lookup consumed by
121+
the real TS IdentityClient/wallet, restart/spend/eviction, independent chain
122+
validation and identity-history integration. SDK v1.4.1 also misserializes
123+
parsed V1 BEEF through `AtomicBytes`; the generic SDK repair is a separate
124+
primary-profile dependency. Tests here consume exact TS `toAtomicBEEF()` bytes
125+
and do not conceal that unresolved engine interoperability dependency.

pkg/topics/identity/identity.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Package identity implements the opt-in tm_identity application rules and
2+
// ls_identity lookup service. Transaction/script/SPV verification belongs to the
3+
// engine and must precede admission. Certificate admission alone establishes
4+
// neither transaction validity, unspentness, revocation status nor client trust.
5+
//
6+
// A host must inject a durable Projection and register both services explicitly.
7+
// This package supplies no production storage or default server registration.
8+
package identity
9+
10+
import (
11+
"errors"
12+
)
13+
14+
const (
15+
// Topic is the identity certificate admission topic.
16+
Topic = "tm_identity"
17+
// Service is the public identity lookup service.
18+
Service = "ls_identity"
19+
)
20+
21+
var (
22+
// ErrInvalidOutput means an output does not satisfy identity application rules.
23+
ErrInvalidOutput = errors.New("invalid identity output")
24+
// ErrCertificateVerification means the certifier signature could not be
25+
// verified using the Go SDK serialization profile. This may indicate an
26+
// invalid signature or an unsupported TS field-order profile; it does not
27+
// diagnose forgery. The package does not try alternative permutations.
28+
ErrCertificateVerification = errors.New("identity certificate verification failed under Go SDK serialization profile")
29+
// ErrAdmissionBudget means a configured application validation budget was exceeded.
30+
ErrAdmissionBudget = errors.New("identity admission budget exceeded")
31+
// ErrInvalidTransaction means the selected transaction is absent or malformed.
32+
ErrInvalidTransaction = errors.New("invalid identity transaction")
33+
// ErrInvalidPolicy means a service resource policy is unusable.
34+
ErrInvalidPolicy = errors.New("invalid identity policy")
35+
)
36+
37+
// AdmissionPolicy bounds work on identity output scripts, separately from the
38+
// host's transport, transaction graph, proof and engine admission budgets.
39+
type AdmissionPolicy struct {
40+
MaxScriptBytes int
41+
MaxFields int
42+
MaxOutputs int
43+
MaxTotalScriptBytes int
44+
MaxNotificationBytes int
45+
}
46+
47+
// DefaultAdmissionPolicy returns finite limits for this opt-in application.
48+
// Larger payload deployments must select and test their own limits explicitly.
49+
func DefaultAdmissionPolicy() AdmissionPolicy {
50+
return AdmissionPolicy{
51+
MaxScriptBytes: 1 << 20,
52+
MaxFields: 128,
53+
MaxOutputs: 10000,
54+
MaxTotalScriptBytes: 32 << 20,
55+
MaxNotificationBytes: 64 << 20,
56+
}
57+
}
58+
59+
func (p AdmissionPolicy) validate() error {
60+
if p.MaxScriptBytes < 1 || p.MaxFields < 1 || p.MaxOutputs < 1 ||
61+
p.MaxTotalScriptBytes < p.MaxScriptBytes || p.MaxNotificationBytes < 1 {
62+
return ErrInvalidPolicy
63+
}
64+
return nil
65+
}

0 commit comments

Comments
 (0)