diff --git a/Makefile b/Makefile
index 48bed61..c94e67b 100644
--- a/Makefile
+++ b/Makefile
@@ -22,7 +22,7 @@ all: sidechain mainnet
 sidechain: alphabet morph nns
 
 alphabet_sc = alphabet
-morph_sc = audit balance container frostfsid netmap proxy reputation
+morph_sc = audit balance container frostfsid netmap proxy reputation policy
 mainnet_sc = frostfs processing
 nns_sc = nns
 all_sc = $(alphabet_sc) $(morph_sc) $(mainnet_sc) $(nns_sc)
diff --git a/policy/config.yml b/policy/config.yml
new file mode 100644
index 0000000..4b43ec9
--- /dev/null
+++ b/policy/config.yml
@@ -0,0 +1,2 @@
+name: "APE"
+safemethods: ["listChains"]
diff --git a/policy/doc.go b/policy/doc.go
new file mode 100644
index 0000000..dee664a
--- /dev/null
+++ b/policy/doc.go
@@ -0,0 +1,12 @@
+/*
+
+# Contract storage scheme
+
+ |   Key                                    | Value  |           Description             |
+ |------------------------------------------|--------|-----------------------------------|
+ | 'c' + uint16(len(container)) + container | []byte | Namespace chain                   |
+ | 'n' + uint16(len(namespace)) + namespace | []byte | Container chain                   |
+
+*/
+
+package policy
diff --git a/policy/policy_contract.go b/policy/policy_contract.go
new file mode 100644
index 0000000..b7e7b21
--- /dev/null
+++ b/policy/policy_contract.go
@@ -0,0 +1,79 @@
+package policy
+
+import (
+	"git.frostfs.info/TrueCloudLab/frostfs-contract/common"
+	"github.com/nspcc-dev/neo-go/pkg/interop/iterator"
+	"github.com/nspcc-dev/neo-go/pkg/interop/storage"
+)
+
+// Kind represents the object the chain is attached to.
+// Currently only namespace and container are supported.
+type Kind byte
+
+const (
+	Namespace = 'n'
+	Container = 'c'
+	IAM       = 'i'
+)
+
+// _deploy function sets up initial list of inner ring public keys.
+func _deploy(data interface{}, isUpdate bool) {
+}
+
+func storageKey(prefix Kind, entityName, name string) []byte {
+	ln := len(entityName)
+	key := append([]byte{byte(prefix)}, byte(ln&0xFF), byte(ln>>8))
+	key = append(key, entityName...)
+	return append(key, name...)
+}
+
+func AddChain(entity Kind, entityName, name string, chain []byte) {
+	common.CheckAlphabetWitness() // TODO: Allow to work with chain directly for everyone?
+
+	ctx := storage.GetContext()
+	key := storageKey(entity, entityName, name)
+	storage.Put(ctx, key, chain)
+}
+
+func RemoveChain(entity Kind, entityName string, name string) {
+	common.CheckAlphabetWitness()
+
+	ctx := storage.GetContext()
+	key := storageKey(entity, entityName, name)
+	storage.Delete(ctx, key)
+}
+
+func RemoveChainsByPrefix(entity Kind, entityName string, name string) {
+	common.CheckAlphabetWitness()
+
+	ctx := storage.GetContext()
+	key := storageKey(entity, entityName, name)
+	it := storage.Find(ctx, key, storage.KeysOnly)
+	for iterator.Next(it) {
+		storage.Delete(ctx, iterator.Value(it).([]byte))
+	}
+}
+
+// ListChains lists all chains for the namespace by prefix.
+// container may be empty.
+func ListChains(namespace, container, name string) [][]byte {
+	ctx := storage.GetReadOnlyContext()
+
+	var result [][]byte
+
+	prefixNs := storageKey(Namespace, namespace, name)
+	it := storage.Find(ctx, prefixNs, storage.ValuesOnly)
+	for iterator.Next(it) {
+		result = append(result, iterator.Value(it).([]byte))
+	}
+
+	if container != "" {
+		prefixCnr := storageKey(Container, container, name)
+		it = storage.Find(ctx, prefixCnr, storage.ValuesOnly)
+		for iterator.Next(it) {
+			result = append(result, iterator.Value(it).([]byte))
+		}
+	}
+
+	return result
+}
diff --git a/rpcclient/policy/client.go b/rpcclient/policy/client.go
new file mode 100644
index 0000000..1c48c99
--- /dev/null
+++ b/rpcclient/policy/client.go
@@ -0,0 +1,124 @@
+// Package ape contains RPC wrappers for APE contract.
+//
+// Code generated by neo-go contract generate-rpcwrapper --manifest <file.json> --out <file.go> [--hash <hash>] [--config <config>]; DO NOT EDIT.
+package ape
+
+import (
+	"github.com/nspcc-dev/neo-go/pkg/core/transaction"
+	"github.com/nspcc-dev/neo-go/pkg/neorpc/result"
+	"github.com/nspcc-dev/neo-go/pkg/rpcclient/unwrap"
+	"github.com/nspcc-dev/neo-go/pkg/util"
+	"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
+	"math/big"
+)
+
+// Invoker is used by ContractReader to call various safe methods.
+type Invoker interface {
+	Call(contract util.Uint160, operation string, params ...any) (*result.Invoke, error)
+}
+
+// Actor is used by Contract to call state-changing methods.
+type Actor interface {
+	Invoker
+
+	MakeCall(contract util.Uint160, method string, params ...any) (*transaction.Transaction, error)
+	MakeRun(script []byte) (*transaction.Transaction, error)
+	MakeUnsignedCall(contract util.Uint160, method string, attrs []transaction.Attribute, params ...any) (*transaction.Transaction, error)
+	MakeUnsignedRun(script []byte, attrs []transaction.Attribute) (*transaction.Transaction, error)
+	SendCall(contract util.Uint160, method string, params ...any) (util.Uint256, uint32, error)
+	SendRun(script []byte) (util.Uint256, uint32, error)
+}
+
+// ContractReader implements safe contract methods.
+type ContractReader struct {
+	invoker Invoker
+	hash    util.Uint160
+}
+
+// Contract implements all contract methods.
+type Contract struct {
+	ContractReader
+	actor Actor
+	hash  util.Uint160
+}
+
+// NewReader creates an instance of ContractReader using provided contract hash and the given Invoker.
+func NewReader(invoker Invoker, hash util.Uint160) *ContractReader {
+	return &ContractReader{invoker, hash}
+}
+
+// New creates an instance of Contract using provided contract hash and the given Actor.
+func New(actor Actor, hash util.Uint160) *Contract {
+	return &Contract{ContractReader{actor, hash}, actor, hash}
+}
+
+// ListChains invokes `listChains` method of contract.
+func (c *ContractReader) ListChains(namespace string, container string, name string) ([]stackitem.Item, error) {
+	return unwrap.Array(c.invoker.Call(c.hash, "listChains", namespace, container, name))
+}
+
+// AddChain creates a transaction invoking `addChain` method of the contract.
+// This transaction is signed and immediately sent to the network.
+// The values returned are its hash, ValidUntilBlock value and error if any.
+func (c *Contract) AddChain(entity *big.Int, entityName string, name string, chain []byte) (util.Uint256, uint32, error) {
+	return c.actor.SendCall(c.hash, "addChain", entity, entityName, name, chain)
+}
+
+// AddChainTransaction creates a transaction invoking `addChain` method of the contract.
+// This transaction is signed, but not sent to the network, instead it's
+// returned to the caller.
+func (c *Contract) AddChainTransaction(entity *big.Int, entityName string, name string, chain []byte) (*transaction.Transaction, error) {
+	return c.actor.MakeCall(c.hash, "addChain", entity, entityName, name, chain)
+}
+
+// AddChainUnsigned creates a transaction invoking `addChain` method of the contract.
+// This transaction is not signed, it's simply returned to the caller.
+// Any fields of it that do not affect fees can be changed (ValidUntilBlock,
+// Nonce), fee values (NetworkFee, SystemFee) can be increased as well.
+func (c *Contract) AddChainUnsigned(entity *big.Int, entityName string, name string, chain []byte) (*transaction.Transaction, error) {
+	return c.actor.MakeUnsignedCall(c.hash, "addChain", nil, entity, entityName, name, chain)
+}
+
+// RemoveChain creates a transaction invoking `removeChain` method of the contract.
+// This transaction is signed and immediately sent to the network.
+// The values returned are its hash, ValidUntilBlock value and error if any.
+func (c *Contract) RemoveChain(entity *big.Int, entityName string, name string) (util.Uint256, uint32, error) {
+	return c.actor.SendCall(c.hash, "removeChain", entity, entityName, name)
+}
+
+// RemoveChainTransaction creates a transaction invoking `removeChain` method of the contract.
+// This transaction is signed, but not sent to the network, instead it's
+// returned to the caller.
+func (c *Contract) RemoveChainTransaction(entity *big.Int, entityName string, name string) (*transaction.Transaction, error) {
+	return c.actor.MakeCall(c.hash, "removeChain", entity, entityName, name)
+}
+
+// RemoveChainUnsigned creates a transaction invoking `removeChain` method of the contract.
+// This transaction is not signed, it's simply returned to the caller.
+// Any fields of it that do not affect fees can be changed (ValidUntilBlock,
+// Nonce), fee values (NetworkFee, SystemFee) can be increased as well.
+func (c *Contract) RemoveChainUnsigned(entity *big.Int, entityName string, name string) (*transaction.Transaction, error) {
+	return c.actor.MakeUnsignedCall(c.hash, "removeChain", nil, entity, entityName, name)
+}
+
+// RemoveChainsByPrefix creates a transaction invoking `removeChainsByPrefix` method of the contract.
+// This transaction is signed and immediately sent to the network.
+// The values returned are its hash, ValidUntilBlock value and error if any.
+func (c *Contract) RemoveChainsByPrefix(entity *big.Int, entityName string, name string) (util.Uint256, uint32, error) {
+	return c.actor.SendCall(c.hash, "removeChainsByPrefix", entity, entityName, name)
+}
+
+// RemoveChainsByPrefixTransaction creates a transaction invoking `removeChainsByPrefix` method of the contract.
+// This transaction is signed, but not sent to the network, instead it's
+// returned to the caller.
+func (c *Contract) RemoveChainsByPrefixTransaction(entity *big.Int, entityName string, name string) (*transaction.Transaction, error) {
+	return c.actor.MakeCall(c.hash, "removeChainsByPrefix", entity, entityName, name)
+}
+
+// RemoveChainsByPrefixUnsigned creates a transaction invoking `removeChainsByPrefix` method of the contract.
+// This transaction is not signed, it's simply returned to the caller.
+// Any fields of it that do not affect fees can be changed (ValidUntilBlock,
+// Nonce), fee values (NetworkFee, SystemFee) can be increased as well.
+func (c *Contract) RemoveChainsByPrefixUnsigned(entity *big.Int, entityName string, name string) (*transaction.Transaction, error) {
+	return c.actor.MakeUnsignedCall(c.hash, "removeChainsByPrefix", nil, entity, entityName, name)
+}
diff --git a/tests/policy_test.go b/tests/policy_test.go
new file mode 100644
index 0000000..10a74db
--- /dev/null
+++ b/tests/policy_test.go
@@ -0,0 +1,91 @@
+package tests
+
+import (
+	"path"
+	"testing"
+
+	"git.frostfs.info/TrueCloudLab/frostfs-contract/policy"
+	"github.com/nspcc-dev/neo-go/pkg/neotest"
+	"github.com/nspcc-dev/neo-go/pkg/util"
+	"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
+	"github.com/stretchr/testify/require"
+)
+
+const policyPath = "../policy"
+
+func deployPolicyContract(t *testing.T, e *neotest.Executor) util.Uint160 {
+	cfgPath := path.Join(policyPath, "config.yml")
+	c := neotest.CompileFile(t, e.CommitteeHash, policyPath, cfgPath)
+	e.DeployContract(t, c, nil)
+	return c.Hash
+}
+
+func newPolicyInvoker(t *testing.T) *neotest.ContractInvoker {
+	e := newExecutor(t)
+	h := deployPolicyContract(t, e)
+	return e.CommitteeInvoker(h)
+}
+
+func TestPolicy(t *testing.T) {
+	e := newPolicyInvoker(t)
+
+	// Policies are opaque to the contract and are just raw bytes to store.
+	p1 := []byte("chain1")
+	p2 := []byte("chain2")
+	p3 := []byte("chain3")
+	p33 := []byte("chain33")
+
+	e.Invoke(t, stackitem.Null{}, "addChain", policy.Namespace, "mynamespace", "ingress:123", p1)
+	checkChains(t, e, "mynamespace", "", "ingress", [][]byte{p1})
+	checkChains(t, e, "mynamespace", "", "all", nil)
+
+	e.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule2", p2)
+	checkChains(t, e, "mynamespace", "", "ingress", [][]byte{p1}) // Only namespace chains.
+	checkChains(t, e, "mynamespace", "cnr1", "ingress", [][]byte{p1, p2})
+	checkChains(t, e, "mynamespace", "cnr1", "all", nil)              // No chains attached to 'all'.
+	checkChains(t, e, "mynamespace", "cnr2", "ingress", [][]byte{p1}) // Only namespace, no chains for the container.
+
+	e.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule3", p3)
+	checkChains(t, e, "mynamespace", "cnr1", "ingress", [][]byte{p1, p2, p3})
+
+	e.Invoke(t, stackitem.Null{}, "addChain", policy.Container, "cnr1", "ingress:myrule3", p33)
+	checkChains(t, e, "mynamespace", "cnr1", "ingress", [][]byte{p1, p2, p33}) // Override chain.
+
+	t.Run("removal", func(t *testing.T) {
+		t.Run("wrong name", func(t *testing.T) {
+			e.Invoke(t, stackitem.Null{}, "removeChain", policy.Namespace, "mynamespace", "ingress")
+			checkChains(t, e, "mynamespace", "", "ingress", [][]byte{p1})
+		})
+
+		e.Invoke(t, stackitem.Null{}, "removeChain", policy.Namespace, "mynamespace", "ingress:123")
+		checkChains(t, e, "mynamespace", "", "ingress", nil)
+		checkChains(t, e, "mynamespace", "cnr1", "ingress", [][]byte{p2, p33}) // Container chains still exist.
+
+		// Remove by prefix.
+		e.Invoke(t, stackitem.Null{}, "removeChainsByPrefix", policy.Container, "cnr1", "ingress")
+		checkChains(t, e, "mynamespace", "cnr1", "ingress", nil)
+	})
+}
+
+func checkChains(t *testing.T, e *neotest.ContractInvoker, namespace, container, name string, expected [][]byte) {
+	s, err := e.TestInvoke(t, "listChains", namespace, container, name)
+	require.NoError(t, err)
+	require.Equal(t, 1, s.Len())
+
+	if len(expected) == 0 {
+		_, ok := s.Pop().Item().(stackitem.Null)
+		require.True(t, ok)
+		return
+	}
+
+	var actual [][]byte
+	arr := s.Pop().Array()
+	for i := range arr {
+		bs, err := arr[i].TryBytes()
+
+		require.NoError(t, err)
+		actual = append(actual, bs)
+	}
+
+	require.ElementsMatch(t, expected, actual)
+}