diff --git a/x/logic/interpreter/registry.go b/x/logic/interpreter/registry.go index 6dfefb64..26db4afe 100644 --- a/x/logic/interpreter/registry.go +++ b/x/logic/interpreter/registry.go @@ -117,6 +117,7 @@ var Registry = map[string]RegistryEntry{ "did_components/2": {predicate.DIDComponents, 1}, "sha_hash/2": {predicate.SHAHash, 1}, "hex_bytes/2": {predicate.HexBytes, 1}, + "bech32_address/2": {predicate.Bech32Address, 1}, } // RegistryNames is the list of the predicate names in the Registry. diff --git a/x/logic/predicate/address.go b/x/logic/predicate/address.go new file mode 100644 index 00000000..f82f60da --- /dev/null +++ b/x/logic/predicate/address.go @@ -0,0 +1,91 @@ +package predicate + +import ( + "context" + "fmt" + + bech322 "github.com/cosmos/cosmos-sdk/types/bech32" + "github.com/ichiban/prolog/engine" + "github.com/okp4/okp4d/x/logic/util" +) + +// Bech32Address is a predicate that convert a bech32 encoded string into base64 bytes and give the address prefix, or +// convert a prefix (HRP) and base64 encoded bytes to bech32 encoded string. The signature is as follows: +// +// bech32_address(-Address, +Bech32) +// bech32_address(+Address, -Bech32) +// bech32_address(+Address, +Bech32) +// +// where: +// - Address is a pair of, HRP (Human-Readable Part) containing the address prefix and the list of integers +// between 0 and 255 (byte) that represent the base64 encoded bech32 address string. Represented like this : -(HRP, Address) +// - Bech32 is an Atom or string representing the bech32 encoded string address +// +// # Example: +// +// - Convert the given bech32 address into base64 encoded byte by unify the prefix of given address (Hrp) and +// the base64 encoded value (Address). +// +// bech32_address(-(Hrp, Address), 'okp415wn30a9z4uc692s0kkx5fp5d4qfr3ac7sj9dqn'). +// +// - Convert the given pair of HRP and base64 encoded address byte by unify the Bech32 string encoded value. +// +// bech32_address(-('okp4', [163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]), Bech32). +func Bech32Address(vm *engine.VM, address, bech32 engine.Term, cont engine.Cont, env *engine.Env) *engine.Promise { + return engine.Delay(func(ctx context.Context) *engine.Promise { + switch b := env.Resolve(bech32).(type) { + case engine.Variable: + case engine.Atom: + h, a, err := bech322.DecodeAndConvert(b.String()) + if err != nil { + return engine.Error(fmt.Errorf("bech32_address/2: failed to decode Bech32: %w", err)) + } + pair := AtomPair.Apply(util.StringToTerm(h), BytesToList(a)) + return engine.Unify(vm, address, pair, cont, env) + default: + return engine.Error(fmt.Errorf("bech32_address/2: invalid Bech32 type: %T, should be Atom or Variable", b)) + } + + switch addressPair := env.Resolve(address).(type) { + case engine.Compound: + bech32Decoded, err := addressPairToBech32(addressPair, env) + if err != nil { + return engine.Error(fmt.Errorf("bech32_address/2: %w", err)) + } + return engine.Unify(vm, bech32, util.StringToTerm(bech32Decoded), cont, env) + default: + return engine.Error(fmt.Errorf("bech32_address/2: invalid address type: %T, should be Compound (Hrp, Address)", addressPair)) + } + }) +} + +func addressPairToBech32(addressPair engine.Compound, env *engine.Env) (string, error) { + if addressPair.Functor() != AtomPair || addressPair.Arity() != 2 { + return "", fmt.Errorf("address should be a Pair '-(Hrp, Address)'") + } + + switch a := env.Resolve(addressPair.Arg(1)).(type) { + case engine.Compound: + if a.Arity() != 2 || a.Functor().String() != "." { + return "", fmt.Errorf("address should be a List of bytes") + } + + iter := engine.ListIterator{List: a, Env: env} + data, err := ListToBytes(iter, env) + if err != nil { + return "", fmt.Errorf("failed to convert term to bytes list: %w", err) + } + hrp, ok := env.Resolve(addressPair.Arg(0)).(engine.Atom) + if !ok { + return "", fmt.Errorf("HRP should be instantiated") + } + b, err := bech322.ConvertAndEncode(hrp.String(), data) + if err != nil { + return "", fmt.Errorf("failed to convert base64 encoded address to bech32 string encoded: %w", err) + } + + return b, nil + default: + return "", fmt.Errorf("address should be a Pair with a List of bytes in arity 2, give %T", addressPair.Arg(1)) + } +} diff --git a/x/logic/predicate/address_test.go b/x/logic/predicate/address_test.go new file mode 100644 index 00000000..69b4adf1 --- /dev/null +++ b/x/logic/predicate/address_test.go @@ -0,0 +1,168 @@ +//nolint:gocognit,lll +package predicate + +import ( + "fmt" + "testing" + + "github.com/cosmos/cosmos-sdk/store" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ichiban/prolog/engine" + "github.com/okp4/okp4d/x/logic/testutil" + "github.com/okp4/okp4d/x/logic/types" + . "github.com/smartystreets/goconvey/convey" + "github.com/tendermint/tendermint/libs/log" + tmproto "github.com/tendermint/tendermint/proto/tendermint/types" + tmdb "github.com/tendermint/tm-db" +) + +func TestBech32(t *testing.T) { + Convey("Given a test cases", t, func() { + cases := []struct { + program string + query string + wantResult []types.TermResults + wantError error + wantSuccess bool + }{ + { + query: `bech32_address(-(Hrp, Address), 'okp415wn30a9z4uc692s0kkx5fp5d4qfr3ac7sj9dqn').`, + wantResult: []types.TermResults{{ + "Hrp": "okp4", + "Address": "[163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]", + }}, + wantSuccess: true, + }, + { + query: `bech32_address(Address, 'okp415wn30a9z4uc692s0kkx5fp5d4qfr3ac7sj9dqn').`, + wantResult: []types.TermResults{{ + "Address": "okp4-[163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]", + }}, + wantSuccess: true, + }, + { + query: `bech32_address(-('okp4', [163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]), foo(bar)).`, + wantError: fmt.Errorf("bech32_address/2: invalid Bech32 type: *engine.compound, should be Atom or Variable"), + wantSuccess: false, + }, + { + query: `bech32_address(-('okp4', Address), 'okp415wn30a9z4uc692s0kkx5fp5d4qfr3ac7sj9dqn').`, + wantResult: []types.TermResults{{ + "Address": "[163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]", + }}, + wantSuccess: true, + }, + { + query: `bech32_address(-('okp5', Address), 'okp415wn30a9z4uc692s0kkx5fp5d4qfr3ac7sj9dqn').`, + wantSuccess: false, + }, + { + query: `bech32_address(-('okp4', [163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]), Bech32).`, + wantResult: []types.TermResults{{ + "Bech32": "okp415wn30a9z4uc692s0kkx5fp5d4qfr3ac7sj9dqn", + }}, + wantSuccess: true, + }, + { + query: `bech32_address(-('okp4', [163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]), 'okp415wn30a9z4uc692s0kkx5fp5d4qfr3ac7sj9dqn').`, + wantResult: []types.TermResults{{}}, + wantSuccess: true, + }, + { + query: `bech32_address(-(Hrp, [163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]), 'okp415wn30a9z4uc692s0kkx5fp5d4qfr3ac7sj9dqn').`, + wantResult: []types.TermResults{{"Hrp": "okp4"}}, + wantSuccess: true, + }, + { + query: `bech32_address(-(Hrp, [163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]), 'okp415wn30a9z4uc692s0kkx5fp5d4qfr3ac7sj9dqn').`, + wantResult: []types.TermResults{{"Hrp": "okp4"}}, + wantSuccess: true, + }, + { + query: `bech32_address(foo(Bar), Bech32).`, + wantError: fmt.Errorf("bech32_address/2: address should be a Pair '-(Hrp, Address)'"), + wantSuccess: false, + }, + { + query: `bech32_address(-('okp4', ['8956',167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]), Bech32).`, + wantError: fmt.Errorf("bech32_address/2: failed to convert term to bytes list: invalid term type in list engine.Atom, only integer allowed"), + wantSuccess: false, + }, + { + query: `bech32_address(-(Hrp, [163,167,23,244,162,175,49,162,170,15,181,141,68,134,141,168,18,56,247,30]), Bech32).`, + wantError: fmt.Errorf("bech32_address/2: HRP should be instantiated"), + wantSuccess: false, + }, + { + query: `bech32_address(-('okp4', hey(2)), Bech32).`, + wantError: fmt.Errorf("bech32_address/2: address should be a List of bytes"), + wantSuccess: false, + }, + { + query: `bech32_address(-('okp4', 'foo'), Bech32).`, + wantError: fmt.Errorf("bech32_address/2: address should be a Pair with a List of bytes in arity 2, give engine.Variable"), + wantSuccess: false, + }, + { + query: `bech32_address(Address, Bech32).`, + wantError: fmt.Errorf("bech32_address/2: invalid address type: engine.Variable, should be Compound (Hrp, Address)"), + wantSuccess: false, + }, + } + for nc, tc := range cases { + Convey(fmt.Sprintf("Given the query #%d: %s", nc, tc.query), func() { + Convey("and a context", func() { + db := tmdb.NewMemDB() + stateStore := store.NewCommitMultiStore(db) + ctx := sdk.NewContext(stateStore, tmproto.Header{}, false, log.NewNopLogger()) + + Convey("and a vm", func() { + interpreter := testutil.NewInterpreterMust(ctx) + interpreter.Register2(engine.NewAtom("bech32_address"), Bech32Address) + + err := interpreter.Compile(ctx, tc.program) + So(err, ShouldBeNil) + + Convey("When the predicate is called", func() { + sols, err := interpreter.QueryContext(ctx, tc.query) + + Convey("Then the error should be nil", func() { + So(err, ShouldBeNil) + So(sols, ShouldNotBeNil) + + Convey("and the bindings should be as expected", func() { + var got []types.TermResults + for sols.Next() { + m := types.TermResults{} + err := sols.Scan(m) + So(err, ShouldBeNil) + + got = append(got, m) + } + if tc.wantError != nil { + So(sols.Err(), ShouldNotBeNil) + So(sols.Err().Error(), ShouldEqual, tc.wantError.Error()) + } else { + So(sols.Err(), ShouldBeNil) + + if tc.wantSuccess { + So(len(got), ShouldBeGreaterThan, 0) + So(len(got), ShouldEqual, len(tc.wantResult)) + for iGot, resultGot := range got { + for varGot, termGot := range resultGot { + So(testutil.ReindexUnknownVariables(termGot), ShouldEqual, tc.wantResult[iGot][varGot]) + } + } + } else { + So(len(got), ShouldEqual, 0) + } + } + }) + }) + }) + }) + }) + }) + } + }) +}